diff options
| author | syuilo <syuilotan@yahoo.co.jp> | 2016-12-29 07:49:51 +0900 |
|---|---|---|
| committer | syuilo <syuilotan@yahoo.co.jp> | 2016-12-29 07:49:51 +0900 |
| commit | b3f42e62af698a67c2250533c437569559f1fdf9 (patch) | |
| tree | cdf6937576e99cccf85e6fa3aa8860a1173c7cfb /src/web | |
| download | sharkey-b3f42e62af698a67c2250533c437569559f1fdf9.tar.gz sharkey-b3f42e62af698a67c2250533c437569559f1fdf9.tar.bz2 sharkey-b3f42e62af698a67c2250533c437569559f1fdf9.zip | |
Initial commit :four_leaf_clover:
Diffstat (limited to 'src/web')
249 files changed, 20411 insertions, 0 deletions
diff --git a/src/web/about/base.pug b/src/web/about/base.pug new file mode 100644 index 0000000000..a026c03f28 --- /dev/null +++ b/src/web/about/base.pug @@ -0,0 +1,39 @@ +doctype html + +html(lang='ja', dir='ltr') + + head + meta(charset='utf-8') + meta(name='application-name', content='Misskey') + meta(name='theme-color', content='#f76d6c') + meta(name='referrer', content='origin') + meta(name='viewport', content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no') + link(rel='stylesheet', href='/resources/style.css') + title + block title + + body + nav + ul + li + p API + ul + li: a(href='/api/getting-started') Getting Started + li + p Entities + ul + li: a(href='/api/entities/post') Post + li: a(href='/api/entities/user') User + li: a(href='/api/library') ライブラリ + li: a(href='/license') ライセンス + + main + article + block content + footer + p.contribution + | 間違いを見つけた、またはドキュメントに貢献したいですか? + a(href='https://github.com/syuilo/misskey/blob/master/src/web/about/pages/' + path + '.pug', target='_blank') Github 上でこのページを編集する + | か、 + a(href='https://github.com/syuilo/misskey/fork', target='_blank') Github からこのサイトを Fork してプルリクエストしましょう! + p.copyright (c) syuilo 2016 diff --git a/src/web/about/pages/api/entities/post.pug b/src/web/about/pages/api/entities/post.pug new file mode 100644 index 0000000000..ad53be9540 --- /dev/null +++ b/src/web/about/pages/api/entities/post.pug @@ -0,0 +1,149 @@ +extend ../../../base + +block title + | Entity: Post + +block content + h1 Post + p 投稿を表します。 + + section + h2 Properties + table.entity + thead: tr + td Name + td Type + td Description + tbody + tr.nullable.optional + td app + td: a(href='./app', target='_blank') App + td 投稿したアプリ + tr.nullable + td app_id + td ID + td 投稿したアプリのID + tr + td created_at + td Date + td 投稿日時 + tr + td id + td ID + td 投稿ID + tr.optional + td is_liked + td Boolean + td いいね したかどうか + tr + td likes_count + td Number + td いいね数 + tr.nullable.optional + td media_ids + td ID[] + td 添付されたメディアのIDの配列 + tr.nullable.optional + td media + td: a(href='./drive-file', target='_blank') DriveFile[] + td 添付されたメディアの配列 + tr + td replies_count + td Number + td 返信数 + tr.optional + td reply_to + td: a(href='./post', target='_blank') Post + td 返信先の投稿 + tr.nullable + td reply_to_id + td ID + td 返信先の投稿のID + tr.optional + td repost + td: a(href='./post', target='_blank') Post + td Repostした投稿 + tr + td repost_count + td Number + td Repostされた数 + tr.nullable + td repost_id + td ID + td Repostした投稿のID + tr.nullable + td text + td String + td 本文 + tr.optional + td user + td: a(href='./user', target='_blank') User + td 投稿者 + tr + td user_id + td ID + td 投稿者のID + + section + h2 Example + pre: code. + { + "created_at": "2016-12-10T00:28:50.114Z", + "media_ids": null, + "reply_to_id": "584a16b15860fc52320137e3", + "repost_id": null, + "text": "小日向美穂だぞ!", + "user_id": "5848bf7764e572683f4402f8", + "app_id": null, + "likes_count": 1, + "replies_count": 1, + "id": "584b4c42d8e5186f8f755d0c", + "user": { + "birthday": null, + "created_at": "2016-12-08T02:03:35.332Z", + "bio": "女が嫌いです、女性は好きです", + "followers_count": 11, + "following_count": 11, + "links": null, + "location": "", + "name": "女が嫌い", + "posts_count": 26, + "likes_count": 2, + "liked_count": 20, + "username": "onnnagakirai", + "id": "5848bf7764e572683f4402f8", + "avatar_url": "https://file.himasaku.net/5848c0ec64e572683f4402fc", + "banner_url": "https://file.himasaku.net/5848c12864e572683f4402fd", + "is_following": true, + "is_followed": true + }, + "reply_to": { + "created_at": "2016-12-09T02:28:01.563Z", + "media_ids": null, + "reply_to_id": "5849d35e547e4249be329884", + "repost_id": null, + "text": "アイコン小日向美穂?", + "user_id": "57d01a501fdf2d07be417afe", + "app_id": null, + "replies_count": 1, + "id": "584a16b15860fc52320137e3", + "user": { + "birthday": null, + "created_at": "2016-09-07T13:46:56.605Z", + "bio": "どうすれば君だけのために生きていけるの", + "followers_count": 51, + "following_count": 97, + "links": null, + "location": "川崎", + "name": "きな子", + "posts_count": 4813, + "username": "syuilo", + "likes_count": 3141, + "liked_count": 750, + "id": "57d01a501fdf2d07be417afe", + "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a", + "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5" + } + }, + "is_liked": true + } diff --git a/src/web/about/pages/api/entities/user.pug b/src/web/about/pages/api/entities/user.pug new file mode 100644 index 0000000000..eef973fd63 --- /dev/null +++ b/src/web/about/pages/api/entities/user.pug @@ -0,0 +1,118 @@ +extend ../../../base + +block title + | Entity: User + +block content + h1 User + p ユーザーを表します。 + + section + h2 Properties + table.entity + thead: tr + td Name + td Type + td Description + tbody + tr.nullable.optional + td avatar_id + td ID + td アバターに設定しているドライブのファイルのID + tr.nullable + td avatar_url + td String + td アバターURL + tr.nullable.optional + td banner_id + td ID + td バナーに設定しているドライブのファイルのID + tr.nullable + td banner_url + td String + td バナーURL + tr.nullable + td bio + td String + td プロフィール + tr.nullable + td birthday + td String + td 誕生日 + tr + td created_at + td Date + td アカウント作成日時 + tr.optional + td drive_capacity + td Number + td ドライブの最大容量(byte単位) + tr + td followers_count + td Number + td フォロワー数 + tr + td following_count + td Number + td フォロー数 + tr + td id + td ID + td ユーザーID + tr.optional + td is_followed + td Boolean + td フォローされているか + tr.optional + td is_following + td Boolean + td フォローしているか + tr + td liked_count + td Number + td 投稿にいいねされた数 + tr + td likes_count + td Number + td 投稿にいいねした数 + tr.nullable + td location + td String + td 場所 + tr + td name + td String + td ニックネーム + tr + td posts_count + td Number + td 投稿数 + tr + td username + td String + td ユーザー名 + + section + h2 Example + pre: code. + { + "avatar_id": "583ddc6e64df272771f74c1a", + "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a", + "banner_id": "584bfc82d8e5186f8f755ec5", + "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5", + "bio": "どうすれば君だけのために生きていけるの", + "birthday": null, + "created_at": "2016-09-07T13:46:56.605Z", + "drive_capacity": 1073741824, + "email": null, + "followers_count": 51, + "following_count": 97, + "id": "57d01a501fdf2d07be417afe", + "liked_count": 750, + "likes_count": 3130, + "links": null, + "location": "川崎", + "name": "きな子", + "posts_count": 4811, + "username": "syuilo" + } diff --git a/src/web/about/pages/api/getting-started.pug b/src/web/about/pages/api/getting-started.pug new file mode 100644 index 0000000000..974964e3eb --- /dev/null +++ b/src/web/about/pages/api/getting-started.pug @@ -0,0 +1,74 @@ +extend ../../base + +block title + | Getting Started + +block content + h1 Getting Started + + p MisskeyはREST APIやStreaming APIを提供しており、プログラムからMisskeyの全ての機能を利用することができます。 + p それらのAPIを利用するには、まずAPIを利用したいアカウントのアクセストークンを取得する必要があります: + + section + h2 自分のアクセストークンを取得したい場合 + p 自分自身のアクセストークンは、設定 > API で確認できます。 + p.tip + | アカウントを乗っ取られてしまう可能性があるため、トークンは第三者に教えないでください(アプリなどにも入力しないでください)。 + br + | 万が一トークンが漏れたりその可能性がある場合は トークンを再生成できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します) + + section + h2 他人のアクセストークンを取得する + p + | 不特定多数のユーザーからAPIを利用したい場合、アプリケーションを作成します。 + br + | アプリケーションを作成すると、ユーザーが連携を許可した時に、そのユーザーのアクセストークンを取得することができます。 + p アプリケーションを作成しアクセストークンを取得するまでの流れを見ていきます。 + + section + h3 アプリケーションを作成する + p まずはあなたのアプリケーションを作成しましょう。 + p + | <a href=#{config.dev_url} target="_blank">デベロッパーセンター</a>にアクセスし、アプリ > アプリ作成 に進みます。 + br + | 次に、フォームに必要事項を記入します: + dl + dt アプリケーション名 + dd あなたのアプリケーションの名前。 + dt Named ID + dd アプリを識別する/a-z-/で構成されたID。 + dt アプリの概要 + dd アプリの簡単な説明を入力してください。 + dt コールバックURL + dd あなたのアプリケーションがWebアプリケーションである場合、ユーザーが後述するフォームで認証を終えた際にリダイレクトするURLを設定できます。 + dt 権限 + dd アプリケーションが要求する権限。ここで要求した機能だけがAPIからアクセスできます。 + p.tip + | 権限はアプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーはすべて無効になります。 + p + | アプリケーションを作成すると、作ったアプリの管理ページに進みます。 + br + | アプリのシークレットキー(App Secret)が表示されていますので、メモしておいてください。 + p.tip + | アプリに成りすまされる可能性があるため、極力このシークレットキーは公開しないようにしてください。 + + section + h3 ユーザーに認証させる + p あなたのアプリを使ってもらうには、ユーザーにアカウントへアクセスすることを許可してもらい、Misskeyにそのユーザーのアクセストークンを発行してもらう必要があります。 + p 認証セッションを開始するには、<code>#{config.api_url}/auth/session/generate</code>へパラメータに<code>app_secret</code>としてApp Secretを含めたリクエストを送信します。 + p + | そうすると、レスポンスとして認証セッションのトークンや認証フォームのURLが取得できます。 + br + | この認証フォームのURLをブラウザで表示し、ユーザーにフォームを表示してください。 + section + h4 あなたのアプリがコールバックURLを設定している場合 + p ユーザーがアプリの連携を許可すると設定しているコールバックURLに<code>token</code>という名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。 + section + h4 あなたのアプリがコールバックURLを設定していない場合 + p ユーザーがアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。 + p + | 次に、<code>#{config.api_url}/auth/session/userkey</code>へ<code>app_secret</code>としてApp Secretを、<code>token</code>としてセッションのトークンをパラメータとして付与したリクエストを送信してください。 + br + | 上手くいけば、認証したユーザーのアクセストークンがレスポンスとして取得できます。おめでとうございます! + + p アクセストークンを取得できたら、あとは簡単です。REST APIなら、リクエストにアクセストークンを<code>_userkey</code>(「自分のアクセストークンを取得したい場合」の方法で取得したアクセストークンの場合は<code>i</code>)としてパラメータに含めるだけです。 diff --git a/src/web/about/pages/api/library.pug b/src/web/about/pages/api/library.pug new file mode 100644 index 0000000000..b1ed16e71d --- /dev/null +++ b/src/web/about/pages/api/library.pug @@ -0,0 +1,14 @@ +extend ../../base + +block title + | ライブラリ + +block content + h1 ライブラリ + + p Misskey APIを便利に利用するためのライブラリ一覧です。 + + section + h2 .NET + ul + li: strong: a(href='https://github.com/syuilo/Misq') Misq (公式) diff --git a/src/web/about/pages/license.pug b/src/web/about/pages/license.pug new file mode 100644 index 0000000000..7adda0bff4 --- /dev/null +++ b/src/web/about/pages/license.pug @@ -0,0 +1,9 @@ +extend ../base + +block title + | ライセンス + +block content + h1 ライセンス + + include ../../../../LICENSE diff --git a/src/web/about/resources/style.css b/src/web/about/resources/style.css new file mode 100644 index 0000000000..53d658fa6b --- /dev/null +++ b/src/web/about/resources/style.css @@ -0,0 +1,199 @@ +html { + font-family: sans-serif; +} + +body { + margin: 0; + color: #34495e; +} + +nav { + display: block; + float: left; + width: 210px; +} +nav ul { + display: block; + margin: 0 0 16px 0; + padding: 0 0 0 16px; + list-style: none; +} +nav ul li { + margin: 0; + padding: 0; +} +nav ul li p { + margin: 16px 0 0 0; +} +@media screen and (max-width: 910px) { + nav { + display: none; + } +} + +main { + float: left; + box-sizing: border-box; + padding: 32px; + width: 100%; + max-width: 700px; +} +@media screen and (max-width: 700px) { + main { + font-size: 8px; + } +} + +footer { + padding: 32px 0 0 0; + margin: 32px 0 0 0; + border-top: solid 1px #eee; +} + +footer .contribution { + margin: 0 0 16px 0; +} + +footer .copyright { + margin: 16px 0 0 0; + color: #aaa; +} + +a { + text-decoration: none; + color: #f76d6c; +} + a:hover { + text-decoration: underline; + } + +section { + margin: 32px 0; +} + +h1 { + margin: 0 0 24px 0; + padding: 16px 0; + font-size: 1.5em; + border-bottom: solid 2px #eee; +} + +h2 { + margin: 0 0 24px 0; + padding: 0 0 16px 0; + font-size: 1.4em; + border-bottom: solid 1px #eee; +} + +h3 { + margin: 0; + padding: 0; + font-size: 1.25em; +} + +h4 { + margin: 0; +} + +p { + margin: 1em 0; + line-height: 1.6em; +} + +p.tip { + position: relative; + padding: 12px 24px 12px 30px; + margin: 1em 0; + font-size: 0.9em; + border-left: 4px solid #f66; + background-color: #f8f8f8; + border-bottom-right-radius: 2px; + border-top-right-radius: 2px; +} + p.tip:before { + position: absolute; + top: 14px; + left: -12px; + background-color: #f66; + color: #fff; + content: "!"; + width: 20px; + height: 20px; + border-radius: 100%; + text-align: center; + line-height: 20px; + font-weight: bold; + font-family: 'Dosis', 'Source Sans Pro', 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + } + +table { + width: 100%; + border-spacing: 0; + border-collapse: collapse; +} + +table thead { + font-weight: bold; + border-bottom: solid 2px #eee; +} + +table tbody tr { + border-bottom: dashed 1px #eee; +} + +table th, table td { + padding: 8px 16px; +} + +table.entity tbody tr td:nth-child(1) { + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; +} + +table.entity tbody tr td:nth-child(2) { + font-style: italic; +} + +table.entity tr td:nth-child(3):after { + margin-left: 8px; + opacity: 0.7; +} + +table.entity tr.nullable td:nth-child(2):after { + content: "?"; + opacity: 0.7; +} +table.entity tr.nullable td:nth-child(3):after { + content: "(Null許容)"; +} + +table.entity tr.optional { + opacity: 0.7; +} +table.entity tr.optional td:nth-child(3):after { + content: "(省略可能)"; +} + +table.entity tr.nullable.optional td:nth-child(3):after { + content: "(Null許容かつ省略可能)"; +} + +pre, code, var, samp, kbd { + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; +} + +code { + display: inline-block; + margin: 0 4px; + padding: 0 8px; + color: #525252; + background: #f8f8f8; + border-radius: 2px; +} + +pre code { + display: block; + overflow: auto; + margin: 0; + padding: 32px; +} diff --git a/src/web/app/auth/resources/logo.svg b/src/web/app/auth/resources/logo.svg new file mode 100644 index 0000000000..19b8a2737e --- /dev/null +++ b/src/web/app/auth/resources/logo.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="1024px" height="512px" viewBox="0 256 1024 512" enable-background="new 0 256 1024 512" xml:space="preserve">
+<polyline opacity="0.5" fill="none" stroke="#000000" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="
+ 896.5,608.5 800.5,416.5 704.5,608.5 608.5,416.5 512.5,608.5 416.5,416.5 320.5,608.5 224.5,416.5 128.5,608.5 "/>
+</svg>
diff --git a/src/web/app/auth/script.js b/src/web/app/auth/script.js new file mode 100644 index 0000000000..9743415b12 --- /dev/null +++ b/src/web/app/auth/script.js @@ -0,0 +1,19 @@ +/** + * Authorize Form + */ + +const riot = require('riot'); +document.title = 'Misskey | アプリの連携'; +require('./tags.ls'); +const boot = require('../boot.ls'); + +/** + * Boot + */ +boot(me => { + mount(document.createElement('mk-index')); +}); + +function mount(content) { + riot.mount(document.getElementById('app').appendChild(content)); +} diff --git a/src/web/app/auth/style.styl b/src/web/app/auth/style.styl new file mode 100644 index 0000000000..046a5ff6ee --- /dev/null +++ b/src/web/app/auth/style.styl @@ -0,0 +1,14 @@ +@import "../base" + +html + background #eee + + @media (max-width 600px) + background #fff + +body + margin 0 + padding 32px 0 + + @media (max-width 600px) + padding 0 diff --git a/src/web/app/auth/tags.ls b/src/web/app/auth/tags.ls new file mode 100644 index 0000000000..5f618aaadf --- /dev/null +++ b/src/web/app/auth/tags.ls @@ -0,0 +1,2 @@ +require './tags/index.tag' +require './tags/form.tag' diff --git a/src/web/app/auth/tags/form.tag b/src/web/app/auth/tags/form.tag new file mode 100644 index 0000000000..f5b1555554 --- /dev/null +++ b/src/web/app/auth/tags/form.tag @@ -0,0 +1,126 @@ +mk-form + header + h1 + i { app.name } + | があなたの + b アカウント + | に + b アクセス + | することを + b 許可 + | しますか? + img(src={ app.icon_url + '?thumbnail&size=64' }) + div.app + section + h2 { app.name } + p.nid { app.name_id } + p.description { app.description } + section + h2 このアプリは次の権限を要求しています: + ul + virtual(each={ p in app.permission }) + li(if={ p == 'account-read' }) アカウントの情報を見る。 + li(if={ p == 'account-write' }) アカウントの情報を操作する。 + li(if={ p == 'post-write' }) 投稿する。 + li(if={ p == 'like-write' }) いいねしたりいいね解除する。 + li(if={ p == 'following-write' }) フォローしたりフォロー解除する。 + li(if={ p == 'drive-read' }) ドライブを見る。 + li(if={ p == 'drive-write' }) ドライブを操作する。 + li(if={ p == 'notification-read' }) 通知を見る。 + li(if={ p == 'notification-write' }) 通知を操作する。 + + div.action + button(onclick={ cancel }) キャンセル + button(onclick={ accept }) アクセスを許可 + +style. + display block + + > header + > h1 + margin 0 + padding 32px 32px 20px 32px + font-size 24px + font-weight normal + color #777 + + i + color #77aeca + + &:before + content '「' + + &:after + content '」' + + b + color #666 + + > img + display block + z-index 1 + width 84px + height 84px + margin 0 auto -38px auto + border solid 5px #fff + border-radius 100% + box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) + + > .app + padding 44px 16px 0 16px + color #555 + background #eee + box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) inset + + &:after + content '' + display block + clear both + + > section + float left + width 50% + padding 8px + text-align left + + > h2 + margin 0 + font-size 16px + color #777 + + > .action + padding 16px + + > button + margin 0 8px + + @media (max-width 600px) + > header + > img + box-shadow none + + > .app + box-shadow none + + @media (max-width 500px) + > header + > h1 + font-size 16px + +script. + @mixin \api + + @session = @opts.session + @app = @session.app + + @cancel = ~> + @api \auth/deny do + token: @session.token + .then ~> + @trigger \denied + + @accept = ~> + @api \auth/accept do + token: @session.token + .then ~> + @trigger \accepted diff --git a/src/web/app/auth/tags/index.tag b/src/web/app/auth/tags/index.tag new file mode 100644 index 0000000000..b7017daec6 --- /dev/null +++ b/src/web/app/auth/tags/index.tag @@ -0,0 +1,129 @@ +mk-index + main(if={ SIGNIN }) + p.fetching(if={ fetching }) + | 読み込み中 + mk-ellipsis + mk-form@form(if={ state == null && !fetching }, session={ session }) + div.denied(if={ state == 'denied' }) + h1 アプリケーションの連携をキャンセルしました。 + p このアプリがあなたのアカウントにアクセスすることはありません。 + div.accepted(if={ state == 'accepted' }) + h1 { session.app.is_authorized ? 'このアプリは既に連携済みです' : 'アプリケーションの連携を許可しました'} + p(if={ session.app.callback_url }) + | アプリケーションに戻っています + mk-ellipsis + p(if={ !session.app.callback_url }) アプリケーションに戻って、やっていってください。 + div.error(if={ state == 'fetch-session-error' }) + p セッションが存在しません。 + main.signin(if={ !SIGNIN }) + h1 サインインしてください + mk-signin + footer + img(src='/_/resources/auth/logo.svg', alt='Misskey') + +style. + display block + + > main + width 100% + max-width 500px + margin 0 auto + text-align center + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > .fetching + margin 0 + padding 32px + color #555 + + > div + padding 64px + + > h1 + margin 0 0 8px 0 + padding 0 + font-size 20px + font-weight normal + + > p + margin 0 + color #555 + + &.denied > h1 + color #e65050 + + &.accepted > h1 + color #50bbe6 + + &.signin + padding 32px 32px 16px 32px + + > h1 + margin 0 0 22px 0 + padding 0 + font-size 20px + font-weight normal + color #555 + + @media (max-width 600px) + max-width none + box-shadow none + + @media (max-width 500px) + > div + > h1 + font-size 16px + + > footer + > img + display block + width 64px + height 64px + margin 0 auto + +script. + @mixin \i + @mixin \api + + @state = null + @fetching = true + + @token = window.location.href.split \/ .pop! + + @on \mount ~> + if not @SIGNIN then return + + # Fetch session + @api \auth/session/show do + token: @token + .then (session) ~> + @session = session + @fetching = false + + # 既に連携していた場合 + if @session.app.is_authorized + @api \auth/accept do + token: @session.token + .then ~> + @accepted! + else + @update! + + @refs.form.on \denied ~> + @state = \denied + @update! + + @refs.form.on \accepted @accepted + + .catch (error) ~> + @fetching = false + @state = \fetch-session-error + @update! + + @accepted = ~> + @state = \accepted + @update! + + if @session.app.callback_url + location.href = @session.app.callback_url + '?token=' + @session.token diff --git a/src/web/app/auth/view.pug b/src/web/app/auth/view.pug new file mode 100644 index 0000000000..a7b9f9263b --- /dev/null +++ b/src/web/app/auth/view.pug @@ -0,0 +1,6 @@ +extends ../base + +block head + meta(name='viewport', content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no') + link(rel='stylesheet', href='/_/resources/auth/style.css') + script(src='/_/resources/auth/script.js', async, defer) diff --git a/src/web/app/base.pug b/src/web/app/base.pug new file mode 100644 index 0000000000..805feaee6c --- /dev/null +++ b/src/web/app/base.pug @@ -0,0 +1,23 @@ +doctype html + +!= '\r\n<!-- Thank you for using Misskey! @syuilo -->\r\n' + +html(lang='ja', dir='ltr') + + head + meta(charset='utf-8') + meta(name='application-name', content='Misskey') + meta(name='theme-color', content= themeColor) + meta(name='referrer', content='origin') + title Misskey + style + include ./../../../built/web/resources/init.css + script(src='https://use.fontawesome.com/22aba0df4f.js', async) + block head + + body + noscript: div: p JavaScriptを有効にしてください + div#init: p + span . + span . + span . diff --git a/src/web/app/base.styl b/src/web/app/base.styl new file mode 100644 index 0000000000..5eab205480 --- /dev/null +++ b/src/web/app/base.styl @@ -0,0 +1,118 @@ +@charset 'utf-8' + +$theme-color = convert(themeColor) +$theme-color-foreground = convert(themeColorForeground) + +@import './reset' + +/* + ::selection + background $theme-color + color #fff +*/ + +* + tap-highlight-color rgba($theme-color, 0.7) + -webkit-tap-highlight-color rgba($theme-color, 0.7) + +html, body + margin 0 + padding 0 + scroll-behavior smooth + text-size-adjust 100% + font-family sans-serif + +html + &.progress + &, * + cursor progress !important + +#error + position fixed + z-index 32768 + top 0 + left 0 + width 100% + height 100% + background #00f + color #fff + + > p + text-align center + +#nprogress + pointer-events none + + position absolute + z-index 65536 + + .bar + background $theme-color + + position fixed + z-index 65537 + top 0 + left 0 + + width 100% + height 2px + + /* Fancy blur effect */ + .peg + display block + position absolute + right 0px + width 100px + height 100% + box-shadow 0 0 10px $theme-color, 0 0 5px $theme-color + opacity 1 + + transform rotate(3deg) translate(0px, -4px) + +#wait + display block + position fixed + z-index 65537 + top 15px + right 15px + + &:before + content "" + display block + width 18px + height 18px + box-sizing border-box + + border solid 2px transparent + border-top-color $theme-color + border-left-color $theme-color + border-radius 50% + + animation progress-spinner 400ms linear infinite + + @keyframes progress-spinner + 0% + transform rotate(0deg) + 100% + transform rotate(360deg) + +a + text-decoration none + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + * + cursor pointer + +mk-locker + display block + position fixed + top 0 + left 0 + z-index 65536 + width 100% + height 100% + cursor wait diff --git a/src/web/app/boot.ls b/src/web/app/boot.ls new file mode 100644 index 0000000000..d1230f8f0b --- /dev/null +++ b/src/web/app/boot.ls @@ -0,0 +1,154 @@ +#================================ +# MISSKEY BOOT LOADER +# +# Misskeyを起動します。 +# 1. 初期化 +# 2. ユーザー取得(ログインしていれば) +# 3. アプリケーションをマウント +#================================ + +# LOAD DEPENDENCIES +#-------------------------------- + +riot = require \riot +require \velocity +log = require './common/scripts/log.ls' +api = require './common/scripts/api.ls' +signout = require './common/scripts/signout.ls' +generate-default-userdata = require './common/scripts/generate-default-userdata.ls' +mixins = require './common/mixins.ls' +check-for-update = require './common/scripts/check-for-update.ls' +require './common/tags.ls' + +# MISSKEY ENTORY POINT +#-------------------------------- + +# for subdomains +document.domain = CONFIG.host + +# ↓ iOS待ちPolyfill (SEE: http://caniuse.com/#feat=fetch) +require \fetch + +# ↓ NodeList、HTMLCollectionで forEach を使えるようにする +if NodeList.prototype.for-each == undefined + NodeList.prototype.for-each = Array.prototype.for-each +if HTMLCollection.prototype.for-each == undefined + HTMLCollection.prototype.for-each = Array.prototype.for-each + +# ↓ iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする +try + local-storage.set-item \kyoppie \yuppie +catch e + Storage.prototype.set-item = ~> # noop + +# MAIN PROCESS +#-------------------------------- + +log "Misskey (aoi) v:#{VERSION}" + +# Check for Update +check-for-update! + +# Get token from cookie +i = ((document.cookie.match /i=(\w+)/) || [null null]).1 + +if i? then log "ME: #{i}" + +# ユーザーをフェッチしてコールバックする +module.exports = (callback) ~> + # Get cached account data + cached-me = JSON.parse local-storage.get-item \me + + if cached-me?.data?.cache + fetched cached-me + + # 後から新鮮なデータをフェッチ + fetchme i, true, (fresh-data) ~> + Object.assign cached-me, fresh-data + cached-me.trigger \updated + else + # キャッシュ無効なのにキャッシュが残ってたら掃除 + if cached-me? + local-storage.remove-item \me + + fetchme i, false, fetched + + function fetched me + + if me? + riot.observable me + + if me.data.cache + local-storage.set-item \me JSON.stringify me + + me.on \updated ~> + # キャッシュ更新 + local-storage.set-item \me JSON.stringify me + + log "Fetched! Hello #{me.username}." + + # activate mixins + mixins me + + # destroy loading screen + init = document.get-element-by-id \init + init.parent-node.remove-child init + + # set main element + document.create-element \div + ..set-attribute \id \app + .. |> document.body.append-child + + # Call main proccess + try + callback me + catch error + panic error + +# ユーザーをフェッチしてコールバックする +function fetchme token, silent, cb + me = null + + # Return when not signed in + if not token? then return done! + + # Fetch user + fetch "#{CONFIG.api.url}/i" do + method: \POST + headers: + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + body: "i=#token" + .then (res) ~> + # When failed to authenticate user + if res.status != 200 then signout! + + i <~ res.json!.then + me := i + me.token = token + + # initialize it if user data is empty + if me.data? then done! else init! + .catch ~> + if not silent + info = document.create-element \mk-core-error + |> document.body.append-child + riot.mount info, do + retry: ~> fetchme token, false, cb + else + # noop + + function done + if cb? then cb me + + function init + data = generate-default-userdata! + + api token, \i/appdata/set do + data: JSON.stringify data + .then ~> + me.data = data + done! + +function panic e + console.error e + document.body.innerHTML = '<div id="error"><p>致命的な問題が発生しました。</p></div>' diff --git a/src/web/app/client/script.js b/src/web/app/client/script.js new file mode 100644 index 0000000000..d8531e9cc8 --- /dev/null +++ b/src/web/app/client/script.js @@ -0,0 +1,40 @@ +const head = document.getElementsByTagName('head')[0]; +const ua = navigator.userAgent.toLowerCase(); +const isMobile = /mobile|iphone|ipad|android/.test(ua); + +if (isMobile) { + mountMobile(); +} else { + mountDesktop(); +} + +function mountDesktop() { + const style = document.createElement('link'); + style.setAttribute('href', '/_/resources/desktop/style.css'); + style.setAttribute('rel', 'stylesheet'); + head.appendChild(style); + + const script = document.createElement('script'); + script.setAttribute('src', '/_/resources/desktop/script.js'); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); +} + +function mountMobile() { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'viewport'); + meta.setAttribute('content', 'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no'); + head.appendChild(meta); + + const style = document.createElement('link'); + style.setAttribute('href', '/_/resources/mobile/style.css'); + style.setAttribute('rel', 'stylesheet'); + head.appendChild(style); + + const script = document.createElement('script'); + script.setAttribute('src', '/_/resources/mobile/script.js'); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); +} diff --git a/src/web/app/client/view.pug b/src/web/app/client/view.pug new file mode 100644 index 0000000000..6f0ff3cd5c --- /dev/null +++ b/src/web/app/client/view.pug @@ -0,0 +1,5 @@ +extends ../base + +block head + script + include ./../../../../built/web/resources/client/script.js diff --git a/src/web/app/common/mixins.ls b/src/web/app/common/mixins.ls new file mode 100644 index 0000000000..1320cacd1e --- /dev/null +++ b/src/web/app/common/mixins.ls @@ -0,0 +1,40 @@ +riot = require \riot + +module.exports = (me) ~> + i = if me? then me.token else null + + (require './scripts/i.ls') me + + riot.mixin \api do + api: (require './scripts/api.ls').bind null i + + riot.mixin \cropper do + Cropper: require \cropper + + riot.mixin \signout do + signout: require './scripts/signout.ls' + + riot.mixin \messaging-stream do + MessagingStreamConnection: require './scripts/messaging-stream.ls' + + riot.mixin \is-promise do + is-promise: require './scripts/is-promise.ls' + + riot.mixin \get-post-summary do + get-post-summary: require './scripts/get-post-summary.ls' + + riot.mixin \date-stringify do + date-stringify: require './scripts/date-stringify.ls' + + riot.mixin \text do + analyze: require 'misskey-text' + compile: require './scripts/text-compiler.js' + + riot.mixin \get-password-strength do + get-password-strength: require 'strength.js' + + riot.mixin \ui-progress do + Progress: require './scripts/loading.ls' + + riot.mixin \bytes-to-size do + bytes-to-size: require './scripts/bytes-to-size.js' diff --git a/src/web/app/common/pages/about/base.pug b/src/web/app/common/pages/about/base.pug new file mode 100644 index 0000000000..0bac19ee2b --- /dev/null +++ b/src/web/app/common/pages/about/base.pug @@ -0,0 +1,13 @@ +extends ../../../base + +block head + link(rel='stylesheet', href='/_/resources/common/pages/about/style.css') + script(src='/_/resources/common/pages/about/script.js', async, defer) + +block body + article + header + h1 + block header + div.body + block content diff --git a/src/web/app/common/pages/about/pages/staff.pug b/src/web/app/common/pages/about/pages/staff.pug new file mode 100644 index 0000000000..dfdf015a3d --- /dev/null +++ b/src/web/app/common/pages/about/pages/staff.pug @@ -0,0 +1,13 @@ +extends ../base + +block title + | スタッフ | Misskey + +block header + | スタッフ + +block content + div.members + div.member + p しゅいろ + p 統括、設計、グラフィックデザイン、プログラム
\ No newline at end of file diff --git a/src/web/app/common/scripts/api.ls b/src/web/app/common/scripts/api.ls new file mode 100644 index 0000000000..0656a56168 --- /dev/null +++ b/src/web/app/common/scripts/api.ls @@ -0,0 +1,67 @@ +riot = require \riot + +spinner = null +pending = 0 + +net = riot.observable! + +riot.mixin \net do + net: net + +log = (riot.mixin \log).log + +module.exports = (i, endpoint, data) -> + pending++ + + if i? and typeof i == \object then i = i.token + + body = [] + + # append user token when signed in + if i? then body.push "i=#i" + + for k, v of data + if v != undefined + v = encodeURIComponent v + body.push "#k=#v" + + opts = + method: \POST + headers: + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + body: body.join \& + + if endpoint == \signin + opts.credentials = \include + + ep = if (endpoint.index-of '://') > -1 + then endpoint + else "#{CONFIG.api.url}/#{endpoint}" + + if pending == 1 + spinner := document.create-element \div + ..set-attribute \id \wait + document.body.append-child spinner + + new Promise (resolve, reject) -> + timer = set-timeout -> + net.trigger \detected-slow-network + , 5000ms + + log "API: #{ep}" + + fetch ep, opts + .then (res) -> + pending-- + clear-timeout timer + if pending == 0 + spinner.parent-node.remove-child spinner + + if res.status == 200 + res.json!.then resolve + else if res.status == 204 + resolve! + else + res.json!.then (err) -> + reject err.error + .catch reject diff --git a/src/web/app/common/scripts/bytes-to-size.js b/src/web/app/common/scripts/bytes-to-size.js new file mode 100644 index 0000000000..717f9ad507 --- /dev/null +++ b/src/web/app/common/scripts/bytes-to-size.js @@ -0,0 +1,6 @@ +module.exports = function(bytes) { + var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (bytes == 0) return '0Byte'; + var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); + return Math.round(bytes / Math.pow(1024, i), 2) + sizes[i]; +} diff --git a/src/web/app/common/scripts/check-for-update.ls b/src/web/app/common/scripts/check-for-update.ls new file mode 100644 index 0000000000..48e250a4c7 --- /dev/null +++ b/src/web/app/common/scripts/check-for-update.ls @@ -0,0 +1,9 @@ +module.exports = -> + fetch \/api:meta + .then (res) ~> + meta <~ res.json!.then + if meta.commit.hash != VERSION + if window.confirm '新しいMisskeyのバージョンがあります。更新しますか?\r\n(このメッセージが繰り返し表示される場合は、サーバーにデータがまだ届いていない可能性があるので、少し時間を置いてから再度お試しください)' + location.reload true + .catch ~> + # ignore diff --git a/src/web/app/common/scripts/date-stringify.ls b/src/web/app/common/scripts/date-stringify.ls new file mode 100644 index 0000000000..9aa8b3e6c5 --- /dev/null +++ b/src/web/app/common/scripts/date-stringify.ls @@ -0,0 +1,14 @@ +module.exports = (date) -> + if typeof date == \string then date = new Date date + + text = + date.get-full-year! + \年 + + date.get-month! + \月 + + date.get-date! + \日 + + ' ' + + date.get-hours! + \時 + + date.get-minutes! + \分 + + ' ' + + "(#{[\日 \月 \火 \水 \木 \金 \土][date.get-day!]})" + + return text diff --git a/src/web/app/common/scripts/generate-default-userdata.ls b/src/web/app/common/scripts/generate-default-userdata.ls new file mode 100644 index 0000000000..de03e96151 --- /dev/null +++ b/src/web/app/common/scripts/generate-default-userdata.ls @@ -0,0 +1,27 @@ +uuid = require './uuid.js' + +home = + left: [ \profile \calendar \rss-reader \photo-stream ] + right: [ \broadcast \notifications \user-recommendation \donation \nav \tips ] + +module.exports = ~> + home-data = [] + + home.left.for-each (widget) ~> + home-data.push do + name: widget + id: uuid! + place: \left + + home.right.for-each (widget) ~> + home-data.push do + name: widget + id: uuid! + place: \right + + data = + cache: true + debug: false + home: home-data + + return data diff --git a/src/web/app/common/scripts/get-post-summary.ls b/src/web/app/common/scripts/get-post-summary.ls new file mode 100644 index 0000000000..0150d53004 --- /dev/null +++ b/src/web/app/common/scripts/get-post-summary.ls @@ -0,0 +1,26 @@ +get-post-summary = (post) ~> + summary = if post.text? then post.text else '' + + # メディアが添付されているとき + if post.media? + summary += " (#{post.media.length}枚の画像)" + + # 返信のとき + if post.reply_to_id? + if post.reply_to? + reply-summary = get-post-summary post.reply_to + summary += " RE: #{reply-summary}" + else + summary += " RE: ..." + + # Repostのとき + if post.repost_id? + if post.repost? + repost-summary = get-post-summary post.repost + summary += " RP: #{repost-summary}" + else + summary += " RP: ..." + + return summary.trim! + +module.exports = get-post-summary diff --git a/src/web/app/common/scripts/i.ls b/src/web/app/common/scripts/i.ls new file mode 100644 index 0000000000..5f3c016f8a --- /dev/null +++ b/src/web/app/common/scripts/i.ls @@ -0,0 +1,16 @@ +riot = require \riot + +module.exports = (me) -> + riot.mixin \i do + init: -> + @I = me + @SIGNIN = me? + + if @SIGNIN + @on \mount ~> me.on \updated @update + @on \unmount ~> me.off \updated @update + + update-i: (data) -> + if data? + Object.assign me, data + me.trigger \updated diff --git a/src/web/app/common/scripts/is-promise.ls b/src/web/app/common/scripts/is-promise.ls new file mode 100644 index 0000000000..e3c7adff85 --- /dev/null +++ b/src/web/app/common/scripts/is-promise.ls @@ -0,0 +1 @@ +module.exports = (x) -> typeof x.then == \function diff --git a/src/web/app/common/scripts/loading.ls b/src/web/app/common/scripts/loading.ls new file mode 100644 index 0000000000..ed791b21ac --- /dev/null +++ b/src/web/app/common/scripts/loading.ls @@ -0,0 +1,16 @@ +NProgress = require 'NProgress' +NProgress.configure do + trickle-speed: 500ms + show-spinner: false + +root = document.get-elements-by-tag-name \html .0 + +module.exports = + start: ~> + root.class-list.add \progress + NProgress.start! + done: ~> + root.class-list.remove \progress + NProgress.done! + set: (val) ~> + NProgress.set val diff --git a/src/web/app/common/scripts/log.ls b/src/web/app/common/scripts/log.ls new file mode 100644 index 0000000000..6e1e3735d8 --- /dev/null +++ b/src/web/app/common/scripts/log.ls @@ -0,0 +1,18 @@ +riot = require \riot + +logs = [] + +ev = riot.observable! + +function log(msg) + logs.push do + date: new Date! + message: msg + ev.trigger \log + +riot.mixin \log do + logs: logs + log: log + log-event: ev + +module.exports = log diff --git a/src/web/app/common/scripts/messaging-stream.ls b/src/web/app/common/scripts/messaging-stream.ls new file mode 100644 index 0000000000..298285dc93 --- /dev/null +++ b/src/web/app/common/scripts/messaging-stream.ls @@ -0,0 +1,34 @@ +# Stream +#================================ + +ReconnectingWebSocket = require 'reconnecting-websocket' +riot = require 'riot' + +class Connection + (me, otherparty) ~> + @event = riot.observable! + @me = me + host = CONFIG.api.url.replace \http \ws + @socket = new ReconnectingWebSocket "#{host}/messaging?otherparty=#{otherparty}" + + @socket.add-event-listener \open @on-open + @socket.add-event-listener \message @on-message + + on-open: ~> + @socket.send JSON.stringify do + i: @me.token + + on-message: (message) ~> + try + message = JSON.parse message.data + if message.type? + @event.trigger message.type, message.body + catch + # ignore + + close: ~> + @socket.remove-event-listener \open @on-open + @socket.remove-event-listener \message @on-message + @socket.close! + +module.exports = Connection diff --git a/src/web/app/common/scripts/signout.ls b/src/web/app/common/scripts/signout.ls new file mode 100644 index 0000000000..a647922678 --- /dev/null +++ b/src/web/app/common/scripts/signout.ls @@ -0,0 +1,4 @@ +module.exports = -> + local-storage.remove-item \me + document.cookie = "i=; domain=.#{CONFIG.host}; expires=Thu, 01 Jan 1970 00:00:01 GMT;" + location.href = \/ diff --git a/src/web/app/common/scripts/stream.ls b/src/web/app/common/scripts/stream.ls new file mode 100644 index 0000000000..534048248f --- /dev/null +++ b/src/web/app/common/scripts/stream.ls @@ -0,0 +1,42 @@ +# Stream +#================================ + +ReconnectingWebSocket = require \reconnecting-websocket +riot = require \riot + +module.exports = (me) ~> + state = \initializing + state-ev = riot.observable! + event = riot.observable! + + socket = new ReconnectingWebSocket CONFIG.api.url.replace \http \ws + + socket.onopen = ~> + state := \connected + state-ev.trigger \connected + socket.send JSON.stringify do + i: me.token + + socket.onclose = ~> + state := \reconnecting + state-ev.trigger \closed + + socket.onmessage = (message) ~> + try + message = JSON.parse message.data + if message.type? + event.trigger message.type, message.body + catch + # ignore + + get-state = ~> state + + event.on \i_updated (data) ~> + Object.assign me, data + me.trigger \updated + + { + state-ev + get-state + event + } diff --git a/src/web/app/common/scripts/text-compiler.js b/src/web/app/common/scripts/text-compiler.js new file mode 100644 index 0000000000..9915e3335f --- /dev/null +++ b/src/web/app/common/scripts/text-compiler.js @@ -0,0 +1,30 @@ +module.exports = function(tokens, canBreak, escape) { + if (canBreak == null) { + canBreak = true; + } + if (escape == null) { + escape = true; + } + return tokens.map(function(token) { + switch (token.type) { + case 'text': + if (escape) { + return token.content + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/(\r\n|\n|\r)/g, canBreak ? '<br>' : ' '); + } else { + return token.content + .replace(/(\r\n|\n|\r)/g, canBreak ? '<br>' : ' '); + } + case 'bold': + return '<strong>' + token.bold + '</strong>'; + case 'link': + return '<mk-url href="' + token.content + '" target="_blank"></mk-url>'; + case 'mention': + return '<a href="' + CONFIG.url + '/' + token.username + '" target="_blank" data-user-preview="' + token.content + '">' + token.content + '</a>'; + case 'hashtag': // TODO + return '<a>' + token.content + '</a>'; + } + }).join(''); +} diff --git a/src/web/app/common/scripts/uuid.js b/src/web/app/common/scripts/uuid.js new file mode 100644 index 0000000000..6161190d63 --- /dev/null +++ b/src/web/app/common/scripts/uuid.js @@ -0,0 +1,12 @@ +module.exports = function () { + var uuid = '', i, random; + for (i = 0; i < 32; i++) { + random = Math.random() * 16 | 0; + + if (i == 8 || i == 12 || i == 16 || i == 20) { + uuid += '-' + } + uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16); + } + return uuid; +} diff --git a/src/web/app/common/tags.ls b/src/web/app/common/tags.ls new file mode 100644 index 0000000000..fe71a7bb37 --- /dev/null +++ b/src/web/app/common/tags.ls @@ -0,0 +1,16 @@ +require './tags/core-error.tag' +require './tags/url.tag' +require './tags/url-preview.tag' +require './tags/ripple-string.tag' +require './tags/time.tag' +require './tags/file-type-icon.tag' +require './tags/uploader.tag' +require './tags/ellipsis.tag' +require './tags/raw.tag' +require './tags/number.tag' +require './tags/special-message.tag' +require './tags/signin.tag' +require './tags/signup.tag' +require './tags/forkit.tag' +require './tags/introduction.tag' +require './tags/copyright.tag' diff --git a/src/web/app/common/tags/copyright.tag b/src/web/app/common/tags/copyright.tag new file mode 100644 index 0000000000..74acae4df7 --- /dev/null +++ b/src/web/app/common/tags/copyright.tag @@ -0,0 +1,5 @@ +mk-copyright + span (c) syuilo 2014-2016 + +style. + display block diff --git a/src/web/app/common/tags/core-error.tag b/src/web/app/common/tags/core-error.tag new file mode 100644 index 0000000000..19ef68bea6 --- /dev/null +++ b/src/web/app/common/tags/core-error.tag @@ -0,0 +1,63 @@ +mk-core-error + //i: i.fa.fa-times-circle + img(src='/_/resources/error.jpg', alt='') + h1: mk-ripple-string サーバーに接続できません + p.text + | インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから + a(onclick={ retry }) 再度お試し + | ください。 + p.thanks いつもMisskeyをご利用いただきありがとうございます。 + +style. + position fixed + z-index 16385 + top 0 + left 0 + width 100% + height 100% + text-align center + background #f8f8f8 + + > i + display block + margin-top 64px + font-size 5em + color #6998a0 + + > img + display block + height 200px + margin 64px auto 0 auto + pointer-events none + -ms-user-select none + -moz-user-select none + -webkit-user-select none + user-select none + + > h1 + display block + margin 32px auto 16px auto + font-size 1.5em + color #555 + + > .text + display block + margin 0 auto + max-width 600px + font-size 1em + color #666 + + > .thanks + display block + margin 32px auto 0 auto + padding 32px 0 32px 0 + max-width 600px + font-size 0.9em + font-style oblique + color #aaa + border-top solid 1px #eee + +script. + @retry = ~> + @unmount! + @opts.retry! diff --git a/src/web/app/common/tags/ellipsis.tag b/src/web/app/common/tags/ellipsis.tag new file mode 100644 index 0000000000..47eca62acd --- /dev/null +++ b/src/web/app/common/tags/ellipsis.tag @@ -0,0 +1,25 @@ +mk-ellipsis + span . + span . + span . + +style. + display inline + + > span + animation ellipsis 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes ellipsis + 0%, 80%, 100% + opacity 1 + 40% + opacity 0 diff --git a/src/web/app/common/tags/file-type-icon.tag b/src/web/app/common/tags/file-type-icon.tag new file mode 100644 index 0000000000..68b8f95ad7 --- /dev/null +++ b/src/web/app/common/tags/file-type-icon.tag @@ -0,0 +1,9 @@ +mk-file-type-icon + i.fa.fa-file-image-o(if={ kind == 'image' }) + +style. + display inline + +script. + @file = @opts.file + @kind = @file.type.split \/ .0 diff --git a/src/web/app/common/tags/forkit.tag b/src/web/app/common/tags/forkit.tag new file mode 100644 index 0000000000..7205fbe76b --- /dev/null +++ b/src/web/app/common/tags/forkit.tag @@ -0,0 +1,37 @@ +mk-forkit + a(href='https://github.com/syuilo/misskey', target='_blank', title='View source on Github', aria-label='View source on Github') + svg(width='80', height='80', viewBox='0 0 250 250', aria-hidden) + path(d='M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z') + path.octo-arm(d='M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2', fill='currentColor') + path(d='M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z', fill='currentColor') + +style. + display block + position absolute + top 0 + right 0 + + > a + display block + + > svg + display block + //fill #151513 + //color #fff + fill $theme-color + color $theme-color-foreground + + .octo-arm + transform-origin 130px 106px + + &:hover + .octo-arm + animation octocat-wave 560ms ease-in-out + + @keyframes octocat-wave + 0%, 100% + transform rotate(0) + 20%, 60% + transform rotate(-25deg) + 40%, 80% + transform rotate(10deg) diff --git a/src/web/app/common/tags/introduction.tag b/src/web/app/common/tags/introduction.tag new file mode 100644 index 0000000000..962f195cca --- /dev/null +++ b/src/web/app/common/tags/introduction.tag @@ -0,0 +1,22 @@ +mk-introduction + article + h1 Misskeyとは? + <p><ruby>Misskey<rt>みすきー</rt></ruby>は、<a href="http://syuilo.com" target="_blank">syuilo</a>が2014年くらいから<a href="https://github.com/syuilo" target="_blank">オープンソースで</a>開発・運営を行っている、ミニブログベースのSNSです。</p> + <p>Twitter, Facebook, LINE, Google+ などを<del>パクって</del><i>参考にして</i>います。</p> + <p>無料で誰でも利用でき、広告なども一切ありません。</p> + <p><a href={ CONFIG.urls.about } target="_blank">もっと知りたい方はこちら</a></p> + +style. + display block + + h1 + margin 0 + text-align center + font-size 1.2em + + p + margin 16px 0 + + &:last-child + margin 0 + text-align center diff --git a/src/web/app/common/tags/number.tag b/src/web/app/common/tags/number.tag new file mode 100644 index 0000000000..589c747b35 --- /dev/null +++ b/src/web/app/common/tags/number.tag @@ -0,0 +1,15 @@ +mk-number + +style. + display inline + +script. + @on \mount ~> + # バグ? https://github.com/riot/riot/issues/2103 + #value = @opts.value + value = @opts.riot-value + max = @opts.max + + if max? then if value > max then value = max + + @root.innerHTML = value.to-locale-string! diff --git a/src/web/app/common/tags/raw.tag b/src/web/app/common/tags/raw.tag new file mode 100644 index 0000000000..131826e597 --- /dev/null +++ b/src/web/app/common/tags/raw.tag @@ -0,0 +1,7 @@ +mk-raw + +style. + display inline + +script. + @root.innerHTML = @opts.content diff --git a/src/web/app/common/tags/ripple-string.tag b/src/web/app/common/tags/ripple-string.tag new file mode 100644 index 0000000000..3be6903369 --- /dev/null +++ b/src/web/app/common/tags/ripple-string.tag @@ -0,0 +1,24 @@ +mk-ripple-string + <yield/> + +style. + display inline + + > span + animation ripple-string 5s infinite ease-in-out both + + @keyframes ripple-string + 0%, 50%, 100% + opacity 1 + 25% + opacity 0.5 + +script. + @on \mount ~> + text = @root.innerHTML + @root.innerHTML = '' + (text.split '').for-each (c, i) ~> + ce = document.create-element \span + ce.innerHTML = c + ce.style.animation-delay = (i / 10) + 's' + @root.append-child ce diff --git a/src/web/app/common/tags/signin.tag b/src/web/app/common/tags/signin.tag new file mode 100644 index 0000000000..6f4013b1cb --- /dev/null +++ b/src/web/app/common/tags/signin.tag @@ -0,0 +1,136 @@ +mk-signin + form(onsubmit={ onsubmit }, class={ signing: signing }) + label.user-name + input@username( + type='text' + pattern='^[a-zA-Z0-9\-]+$' + placeholder='ユーザー名' + autofocus + required + oninput={ oninput }) + i.fa.fa-at + label.password + input@password( + type='password' + placeholder='パスワード' + required) + i.fa.fa-lock + button(type='submit', disabled={ signing }) { signing ? 'やっています...' : 'サインイン' } + +style. + display block + + > form + display block + z-index 2 + + &.signing + &, * + cursor wait !important + + label + display block + margin 12px 0 + + i + display block + pointer-events none + position absolute + bottom 0 + top 0 + left 0 + z-index 1 + margin auto + padding 0 16px + height 1em + color #898786 + + input[type=text] + input[type=password] + user-select text + display inline-block + cursor auto + padding 0 0 0 38px + margin 0 + width 100% + line-height 44px + font-size 1em + color rgba(0, 0, 0, 0.7) + background #fff + outline none + border solid 1px #eee + border-radius 4px + + &:hover + background rgba(255, 255, 255, 0.7) + border-color #ddd + + & + i + color #797776 + + &:focus + background #fff + border-color #ccc + + & + i + color #797776 + + [type=submit] + cursor pointer + padding 16px + margin -6px 0 0 0 + width 100% + font-size 1.2em + color rgba(0, 0, 0, 0.5) + outline none + border none + border-radius 0 + background transparent + transition all .5s ease + + &:hover + color $theme-color + transition all .2s ease + + &:focus + color $theme-color + transition all .2s ease + + &:active + color darken($theme-color, 30%) + transition all .2s ease + + &:disabled + opacity 0.7 + +script. + @mixin \api + + @user = null + @signing = false + + @oninput = ~> + @api \users/show do + username: @refs.username.value + .then (user) ~> + @user = user + @trigger \user user + @update! + + @onsubmit = (e) ~> + e.prevent-default! + + @signing = true + @update! + + @api \signin do + username: @refs.username.value + password: @refs.password.value + .then ~> + location.reload! + .catch ~> + alert 'something happened' + @signing = false + @update! + + false diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag new file mode 100644 index 0000000000..730f00fb4e --- /dev/null +++ b/src/web/app/common/tags/signup.tag @@ -0,0 +1,352 @@ +mk-signup + form(onsubmit={ onsubmit }, autocomplete='off') + label.username + p.caption + i.fa.fa-at + | ユーザー名 + input@username( + type='text' + pattern='^[a-zA-Z0-9\-]{3,20}$' + placeholder='a~z、A~Z、0~9、-' + autocomplete='off' + required + onkeyup={ on-change-username }) + + p.profile-page-url-preview(if={ refs.username.value != '' && username-state != 'invalid-format' && username-state != 'min-range' && username-state != 'max-range' }) { CONFIG.url + '/' + refs.username.value } + + p.info(if={ username-state == 'wait' }, style='color:#999') + i.fa.fa-fw.fa-spinner.fa-pulse + | 確認しています... + p.info(if={ username-state == 'ok' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 利用できます + p.info(if={ username-state == 'unavailable' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 既に利用されています + p.info(if={ username-state == 'error' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 通信エラー + p.info(if={ username-state == 'invalid-format' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | a~z、A~Z、0~9、-(ハイフン)が使えます + p.info(if={ username-state == 'min-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 3文字以上でお願いします! + p.info(if={ username-state == 'max-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 20文字以内でお願いします + + label.password + p.caption + i.fa.fa-lock + | パスワード + input@password( + type='password' + placeholder='8文字以上を推奨します' + autocomplete='off' + required + onkeyup={ on-change-password }) + + div.meter(if={ password-strength != '' }, data-strength={ password-strength }) + div.value@password-metar + + p.info(if={ password-strength == 'low' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 弱いパスワード + p.info(if={ password-strength == 'medium' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | まあまあのパスワード + p.info(if={ password-strength == 'high' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 強いパスワード + + label.retype-password + p.caption + i.fa.fa-lock + | パスワード(再入力) + input@password-retype( + type='password' + placeholder='確認のため再入力してください' + autocomplete='off' + required + onkeyup={ on-change-password-retype }) + + p.info(if={ password-retype-state == 'match' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 確認されました + p.info(if={ password-retype-state == 'not-match' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 一致していません + + label.recaptcha + p.caption + i.fa.fa-toggle-on(if={ recaptchaed }) + i.fa.fa-toggle-off(if={ !recaptchaed }) + | 認証 + div.g-recaptcha( + data-callback='onRecaptchaed' + data-expired-callback='onRecaptchaExpired' + data-sitekey={ CONFIG.recaptcha.site-key }) + + label.agree-tou + input( + name='agree-tou', + type='checkbox', + autocomplete='off', + required) + p + a() 利用規約 + | に同意する + + button(onclick={ onsubmit }) + | アカウント作成 + +style. + display block + min-width 302px + overflow hidden + + > form + + label + display block + margin 16px 0 + + > .caption + margin 0 0 4px 0 + color #828888 + font-size 0.95em + + > i + margin-right 0.25em + color #96adac + + > .info + display block + margin 4px 0 + font-size 0.8em + + > i + margin-right 0.3em + + &.username + .profile-page-url-preview + display block + margin 4px 8px 0 4px + font-size 0.8em + color #888 + + &:empty + display none + + &:not(:empty) + .info + margin-top 0 + + &.password + .meter + display block + margin-top 8px + width 100% + height 8px + + &[data-strength=''] + display none + + &[data-strength='low'] + > .value + background #d73612 + + &[data-strength='medium'] + > .value + background #d7ca12 + + &[data-strength='high'] + > .value + background #61bb22 + + > .value + display block + width 0% + height 100% + background transparent + border-radius 4px + transition all 0.1s ease + + [type=text], [type=password] + user-select text + display inline-block + cursor auto + padding 0 12px + margin 0 + width 100% + line-height 44px + font-size 1em + color #333 !important + background #fff !important + outline none + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + box-shadow 0 0 0 114514px #fff inset + transition all .3s ease + + &:hover + border-color rgba(0, 0, 0, 0.2) + transition all .1s ease + + &:focus + color $theme-color !important + border-color $theme-color + box-shadow 0 0 0 1024px #fff inset, 0 0 0 4px rgba($theme-color, 10%) + transition all 0s ease + + &:disabled + opacity 0.5 + + .agree-tou + padding 4px + border-radius 4px + + &:hover + background #f4f4f4 + + &:active + background #eee + + &, * + cursor pointer + + p + display inline + color #555 + + button + margin 0 0 32px 0 + padding 16px + width 100% + font-size 1em + color #fff + background $theme-color + border-radius 3px + + &:hover + background lighten($theme-color, 5%) + + &:active + background darken($theme-color, 5%) + +script. + @mixin \api + @mixin \get-password-strength + + @username-state = null + @password-strength = '' + @password-retype-state = null + @recaptchaed = false + + window.on-recaptchaed = ~> + @recaptchaed = true + @update! + + window.on-recaptcha-expired = ~> + @recaptchaed = false + @update! + + @on \mount ~> + head = (document.get-elements-by-tag-name \head).0 + script = document.create-element \script + ..set-attribute \src \https://www.google.com/recaptcha/api.js + head.append-child script + + @on-change-username = ~> + username = @refs.username.value + + if username == '' + @username-state = null + @update! + return + + err = switch + | not username.match /^[a-zA-Z0-9\-]+$/ => \invalid-format + | username.length < 3chars => \min-range + | username.length > 20chars => \max-range + | _ => null + + if err? + @username-state = err + @update! + else + @username-state = \wait + @update! + + @api \username/available do + username: username + .then (result) ~> + if result.available + @username-state = \ok + else + @username-state = \unavailable + @update! + .catch (err) ~> + @username-state = \error + @update! + + @on-change-password = ~> + password = @refs.password.value + + if password == '' + @password-strength = '' + return + + strength = @get-password-strength password + + if strength > 0.3 + @password-strength = \medium + if strength > 0.7 + @password-strength = \high + else + @password-strength = \low + + @update! + + @refs.password-metar.style.width = (strength * 100) + \% + + @on-change-password-retype = ~> + password = @refs.password.value + retyped-password = @refs.password-retype.value + + if retyped-password == '' + @password-retype-state = null + return + + if password == retyped-password + @password-retype-state = \match + else + @password-retype-state = \not-match + + @onsubmit = (e) ~> + e.prevent-default! + + username = @refs.username.value + password = @refs.password.value + + locker = document.body.append-child document.create-element \mk-locker + + @api \signup do + username: username + password: password + 'g-recaptcha-response': grecaptcha.get-response! + .then ~> + @api \signin do + username: username + password: password + .then ~> + location.href = CONFIG.url + .catch ~> + alert '何らかの原因によりアカウントの作成に失敗しました。再度お試しください。' + + grecaptcha.reset! + @recaptchaed = false + + locker.parent-node.remove-child locker + + false diff --git a/src/web/app/common/tags/special-message.tag b/src/web/app/common/tags/special-message.tag new file mode 100644 index 0000000000..5a6d5787ea --- /dev/null +++ b/src/web/app/common/tags/special-message.tag @@ -0,0 +1,24 @@ +mk-special-message + p(if={ m == 1 && d == 1 }) Happy New Year! + p(if={ m == 12 && d == 25 }) Merry Christmas! + +style. + display block + + &:empty + display none + + > p + margin 0 + padding 4px + text-align center + font-size 14px + font-weight bold + text-transform uppercase + color #fff + background #ff1036 + +script. + now = new Date! + @d = now.get-date! + @m = now.get-month! + 1 diff --git a/src/web/app/common/tags/time.tag b/src/web/app/common/tags/time.tag new file mode 100644 index 0000000000..56c3b8ecc3 --- /dev/null +++ b/src/web/app/common/tags/time.tag @@ -0,0 +1,43 @@ +mk-time + time(datetime={ opts.time }) + span(if={ mode == 'relative' }) { relative } + span(if={ mode == 'absolute' }) { absolute } + span(if={ mode == 'detail' }) { absolute } ({ relative }) + +script. + @time = new Date @opts.time + @mode = @opts.mode || \relative + @tickid = null + + @absolute = + @time.get-full-year! + \年 + + @time.get-month! + \月 + + @time.get-date! + \日 + + ' ' + + @time.get-hours! + \時 + + @time.get-minutes! + \分 + + @on \mount ~> + if @mode == \relative or @mode == \detail + @tick! + @tickid = set-interval @tick, 1000ms + + @on \unmount ~> + if @mode == \relative or @mode == \detail + clear-interval @tickid + + @tick = ~> + now = new Date! + ago = (now - @time) / 1000ms + @relative = switch + | ago >= 31536000s => ~~(ago / 31536000s) + '年前' + | ago >= 2592000s => ~~(ago / 2592000s) + 'ヶ月前' + | ago >= 604800s => ~~(ago / 604800s) + '週間前' + | ago >= 86400s => ~~(ago / 86400s) + '日前' + | ago >= 3600s => ~~(ago / 3600s) + '時間前' + | ago >= 60s => ~~(ago / 60s) + '分前' + | ago >= 10s => ~~(ago % 60s) + '秒前' + | ago >= 0s => 'たった今' + | ago < 0s => '未来' + | _ => 'なぞのじかん' + @update! diff --git a/src/web/app/common/tags/uploader.tag b/src/web/app/common/tags/uploader.tag new file mode 100644 index 0000000000..6d4e9b6363 --- /dev/null +++ b/src/web/app/common/tags/uploader.tag @@ -0,0 +1,201 @@ +mk-uploader + ol(if={ uploads.length > 0 }) + li(each={ uploads }) + div.img(style='background-image: url({ img })') + p.name + i.fa.fa-spinner.fa-pulse + | { name } + p.status + span.initing(if={ progress == undefined }) + | 待機中 + mk-ellipsis + span.kb(if={ progress != undefined }) + | { String(Math.floor(progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') } + i KB + = ' / ' + | { String(Math.floor(progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') } + i KB + span.percentage(if={ progress != undefined }) { Math.floor((progress.value / progress.max) * 100) } + progress(if={ progress != undefined && progress.value != progress.max }, value={ progress.value }, max={ progress.max }) + div.progress.initing(if={ progress == undefined }) + div.progress.waiting(if={ progress != undefined && progress.value == progress.max }) + +style. + display block + overflow auto + + &:empty + display none + + > ol + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 8px 0 0 0 + padding 0 + height 36px + box-shadow 0 -1px 0 rgba($theme-color, 0.1) + border-top solid 8px transparent + + &:first-child + margin 0 + box-shadow none + border-top none + + > .img + display block + position absolute + top 0 + left 0 + width 36px + height 36px + background-size cover + background-position center center + + > .name + display block + position absolute + top 0 + left 44px + margin 0 + padding 0 + max-width 256px + font-size 0.8em + color rgba($theme-color, 0.7) + white-space nowrap + text-overflow ellipsis + overflow hidden + + > i + margin-right 4px + + > .status + display block + position absolute + top 0 + right 0 + margin 0 + padding 0 + font-size 0.8em + + > .initing + color rgba($theme-color, 0.5) + + > .kb + color rgba($theme-color, 0.5) + + > .percentage + display inline-block + width 48px + text-align right + + color rgba($theme-color, 0.7) + + &:after + content '%' + + > progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + + > .progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + border none + border-radius 4px + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation bg 1.5s linear infinite + + &.initing + opacity 0.3 + + @keyframes bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +script. + @mixin \i + + @uploads = [] + + + @upload = (file, folder) ~> + id = Math.random! + + ctx = + id: id + name: file.name || \untitled + progress: undefined + + @uploads.push ctx + @trigger \change-uploads @uploads + @update! + + reader = new FileReader! + reader.onload = (e) ~> + ctx.img = e.target.result + @update! + reader.read-as-data-URL file + + data = new FormData! + data.append \i @I.token + data.append \file file + + if folder? + data.append \folder_id folder + + xhr = new XMLHttpRequest! + xhr.open \POST CONFIG.api.url + '/drive/files/create' true + xhr.onload = (e) ~> + drive-file = JSON.parse e.target.response + + @trigger \uploaded drive-file + + @uploads = @uploads.filter (x) -> x.id != id + @trigger \change-uploads @uploads + + @update! + + xhr.upload.onprogress = (e) ~> + if e.length-computable + if ctx.progress == undefined + ctx.progress = {} + ctx.progress.max = e.total + ctx.progress.value = e.loaded + @update! + + xhr.send data diff --git a/src/web/app/common/tags/url-preview.tag b/src/web/app/common/tags/url-preview.tag new file mode 100644 index 0000000000..605d26bc67 --- /dev/null +++ b/src/web/app/common/tags/url-preview.tag @@ -0,0 +1,105 @@ +mk-url-preview + a(href={ url }, target='_blank', title={ url }, if={ !loading }) + div.thumbnail(if={ thumbnail }, style={ 'background-image: url(' + thumbnail + ')' }) + article + header: h1 { title } + p { description } + footer + img.icon(if={ icon }, src={ icon }) + p { sitename } + +style. + display block + font-size 16px + + > a + display block + border solid 1px #eee + border-radius 4px + overflow hidden + + &:hover + text-decoration none + border-color #ddd + + > article > header > h1 + text-decoration underline + + > .thumbnail + position absolute + width 100px + height 100% + background-position center + background-size cover + + & + article + left 100px + width calc(100% - 100px) + + > article + padding 16px + + > header + margin-bottom 8px + + > h1 + margin 0 + font-size 1em + color #555 + + > p + margin 0 + color #777 + font-size 0.8em + + > footer + margin-top 8px + + > img + display inline-block + width 16px + heigth 16px + margin-right 4px + vertical-align bottom + + > p + display inline-block + margin 0 + color #666 + font-size 0.8em + line-height 16px + + @media (max-width 500px) + font-size 8px + + > a + border none + + > .thumbnail + width 70px + + & + article + left 70px + width calc(100% - 70px) + + > article + padding 8px + +script. + @mixin \api + + @url = @opts.url + @loading = true + + @on \mount ~> + fetch CONFIG.url + '/api:url?url=' + @url + .then (res) ~> + info <~ res.json!.then + @title = info.title + @description = info.description + @thumbnail = info.thumbnail + @icon = info.icon + @sitename = info.sitename + + @loading = false + @update! diff --git a/src/web/app/common/tags/url.tag b/src/web/app/common/tags/url.tag new file mode 100644 index 0000000000..18892e8108 --- /dev/null +++ b/src/web/app/common/tags/url.tag @@ -0,0 +1,50 @@ +mk-url + a(href={ url }, target={ opts.target }) + span.schema { schema }// + span.hostname { hostname } + span.port(if={ port != '' }) :{ port } + span.pathname(if={ pathname != '' }) { pathname } + span.query { query } + span.hash { hash } + +style. + > a + &:after + content "\f14c" + display inline-block + padding-left 2px + font-family FontAwesome + font-size .9em + font-weight 400 + font-style normal + + > .schema + opacity 0.5 + + > .hostname + font-weight bold + + > .pathname + opacity 0.8 + + > .query + opacity 0.5 + + > .hash + font-style italic + +script. + @url = @opts.href + + @on \before-mount ~> + parser = document.create-element \a + parser.href = @url + + @schema = parser.protocol + @hostname = parser.hostname + @port = parser.port + @pathname = parser.pathname + @query = parser.search + @hash = parser.hash + + @update! diff --git a/src/web/app/desktop/mixins.ls b/src/web/app/desktop/mixins.ls new file mode 100644 index 0000000000..debd89fbd4 --- /dev/null +++ b/src/web/app/desktop/mixins.ls @@ -0,0 +1,47 @@ +riot = require \riot + +module.exports = (me) ~> + riot.mixin \sortable do + Sortable: require \Sortable + + if me? + (require './scripts/stream.ls') me + + require './scripts/user-preview.ls' + + require './scripts/open-window.ls' + + riot.mixin \notify do + notify: require './scripts/notify.ls' + + dialog = require './scripts/dialog.ls' + + riot.mixin \dialog do + dialog: dialog + + riot.mixin \NotImplementedException do + NotImplementedException: ~> + dialog do + '<i class="fa fa-exclamation-triangle"></i>Not implemented yet' + '要求された操作は実装されていません。<br>→<a href="https://github.com/syuilo/misskey" target="_blank">Misskeyの開発に参加する</a>' + [ + text: \OK + ] + + riot.mixin \input-dialog do + input-dialog: require './scripts/input-dialog.ls' + + riot.mixin \update-avatar do + update-avatar: require './scripts/update-avatar.ls' + + riot.mixin \update-banner do + update-banner: require './scripts/update-banner.ls' + + riot.mixin \update-wallpaper do + update-wallpaper: require './scripts/update-wallpaper.ls' + + riot.mixin \autocomplete do + Autocomplete: require './scripts/autocomplete.ls' + + riot.mixin \follow-scroll do + Follower: require './scripts/follow-scroll.ls' diff --git a/src/web/app/desktop/resources/header-logo.svg b/src/web/app/desktop/resources/header-logo.svg new file mode 100644 index 0000000000..19b8a2737e --- /dev/null +++ b/src/web/app/desktop/resources/header-logo.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="1024px" height="512px" viewBox="0 256 1024 512" enable-background="new 0 256 1024 512" xml:space="preserve">
+<polyline opacity="0.5" fill="none" stroke="#000000" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="
+ 896.5,608.5 800.5,416.5 704.5,608.5 608.5,416.5 512.5,608.5 416.5,416.5 320.5,608.5 224.5,416.5 128.5,608.5 "/>
+</svg>
diff --git a/src/web/app/desktop/resources/remove.png b/src/web/app/desktop/resources/remove.png Binary files differnew file mode 100644 index 0000000000..8b1f4c06c9 --- /dev/null +++ b/src/web/app/desktop/resources/remove.png diff --git a/src/web/app/desktop/router.ls b/src/web/app/desktop/router.ls new file mode 100644 index 0000000000..02a7e11816 --- /dev/null +++ b/src/web/app/desktop/router.ls @@ -0,0 +1,77 @@ +# Router +#================================ + +riot = require \riot +route = require \page +page = null + +module.exports = (me) ~> + + # Routing + #-------------------------------- + + route \/ index + route \/i>mentions mentions + route \/post::post post + route \/search::query search + route \/:user user.bind null \home + route \/:user/graphs user.bind null \graphs + route \/:user/:post post + route \* not-found + + # Handlers + #-------------------------------- + + function index + if me? then home! else entrance! + + function home + mount document.create-element \mk-home-page + + function entrance + mount document.create-element \mk-entrance + document.document-element.set-attribute \data-page \entrance + + function mentions + document.create-element \mk-home-page + ..set-attribute \mode \mentions + .. |> mount + + function search ctx + document.create-element \mk-search-page + ..set-attribute \query ctx.params.query + .. |> mount + + function user page, ctx + document.create-element \mk-user-page + ..set-attribute \user ctx.params.user + ..set-attribute \page page + .. |> mount + + function post ctx + document.create-element \mk-post-page + ..set-attribute \post ctx.params.post + .. |> mount + + function not-found + mount document.create-element \mk-not-found + + # Register mixin + #-------------------------------- + + riot.mixin \page do + page: route + + # Exec + #-------------------------------- + + route! + +# Mount +#================================ + +function mount content + document.document-element.remove-attribute \data-page + if page? then page.unmount! + body = document.get-element-by-id \app + page := riot.mount body.append-child content .0 diff --git a/src/web/app/desktop/script.js b/src/web/app/desktop/script.js new file mode 100644 index 0000000000..473797334f --- /dev/null +++ b/src/web/app/desktop/script.js @@ -0,0 +1,42 @@ +/** + * Desktop Client + */ + +require('chart.js'); +require('./tags.ls'); +const riot = require('riot'); +const boot = require('../boot.ls'); +const mixins = require('./mixins.ls'); +const route = require('./router.ls'); +const fuckAdBlock = require('./scripts/fuck-ad-block.ls'); + +/** + * Boot + */ +boot(me => { + /** + * Fuck AD Block + */ + fuckAdBlock(); + + /** + * Init Notification + */ + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if (Notification.permission == 'default') { + Notification.requestPermission(); + } + } + + // Register mixins + mixins(me); + + // Debug + if (me != null && me.data.debug) { + riot.mount(document.body.appendChild(document.createElement('mk-log-window'))); + } + + // Start routing + route(me); +}); diff --git a/src/web/app/desktop/scripts/autocomplete.ls b/src/web/app/desktop/scripts/autocomplete.ls new file mode 100644 index 0000000000..636bb7f277 --- /dev/null +++ b/src/web/app/desktop/scripts/autocomplete.ls @@ -0,0 +1,108 @@ +# Autocomplete +#================================ + +get-caret-coordinates = require 'textarea-caret-position' +riot = require 'riot' + +# オートコンプリートを管理するクラスです。 +class Autocomplete + + @textarea = null + @suggestion = null + + # 対象のテキストエリアを与えてインスタンスを初期化します。 + (textarea) ~> + @textarea = textarea + + # このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + attach: ~> + @textarea.add-event-listener \input @on-input + + # このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + detach: ~> + @textarea.remove-event-listener \input @on-input + @close! + + # テキスト入力時 + on-input: ~> + @close! + + caret = @textarea.selection-start + text = @textarea.value.substr 0 caret + + mention-index = text.last-index-of \@ + + if mention-index == -1 + return + + username = text.substr mention-index + 1 + + if not username.match /^[a-zA-Z0-9-]+$/ + return + + @open \user username + + # サジェストを提示します。 + open: (type, q) ~> + # 既に開いているサジェストは閉じる + @close! + + # サジェスト要素作成 + suggestion = document.create-element \mk-autocomplete-suggestion + + # ~ サジェストを表示すべき位置を計算 ~ + + caret-position = get-caret-coordinates @textarea, @textarea.selection-start + + rect = @textarea.get-bounding-client-rect! + + x = rect.left + window.page-x-offset + caret-position.left + y = rect.top + window.page-y-offset + caret-position.top + + suggestion.style.left = x + \px + suggestion.style.top = y + \px + + # 要素追加 + el = document.body.append-child suggestion + + # マウント + mounted = riot.mount el, do + textarea: @textarea + complete: @complete + close: @close + type: type + q: q + + @suggestion = mounted.0 + + # サジェストを閉じます。 + close: ~> + if !@suggestion? + return + + @suggestion.unmount! + @suggestion = null + + @textarea.focus! + + # オートコンプリートする + complete: (user) ~> + @close! + value = user.username + + caret = @textarea.selection-start + source = @textarea.value + + before = source.substr 0 caret + trimed-before = before.substring 0 before.last-index-of \@ + after = source.substr caret + + # 結果を挿入する + @textarea.value = trimed-before + \@ + value + ' ' + after + + # キャレットを戻す + @textarea.focus! + pos = caret + value.length + @textarea.set-selection-range pos, pos + +module.exports = Autocomplete diff --git a/src/web/app/desktop/scripts/dialog.ls b/src/web/app/desktop/scripts/dialog.ls new file mode 100644 index 0000000000..f3dd6cea1b --- /dev/null +++ b/src/web/app/desktop/scripts/dialog.ls @@ -0,0 +1,17 @@ +# Dialog +#================================ + +riot = require 'riot' + +module.exports = (title, text, buttons, can-through, on-through) ~> + dialog = document.body.append-child document.create-element \mk-dialog + controller = riot.observable! + riot.mount dialog, do + controller: controller + title: title + text: text + buttons: buttons + can-through: can-through + on-through: on-through + controller.trigger \open + return controller diff --git a/src/web/app/desktop/scripts/follow-scroll.ls b/src/web/app/desktop/scripts/follow-scroll.ls new file mode 100644 index 0000000000..5072e9c583 --- /dev/null +++ b/src/web/app/desktop/scripts/follow-scroll.ls @@ -0,0 +1,56 @@ +class Follower + (el) -> + @follower = el + @last-scroll-top = window.scroll-y + @initial-follower-top = @follower.get-bounding-client-rect!.top + @page-top = 48 + + follow: -> + window-height = window.inner-height + follower-height = @follower.offset-height + + scroll-top = window.scroll-y + scroll-bottom = scroll-top + window-height + + follower-top = @follower.get-bounding-client-rect!.top + scroll-top + follower-bottom = follower-top + follower-height + + height-delta = Math.abs window-height - follower-height + scroll-delta = @last-scroll-top - scroll-top + + is-scrolling-down = (scroll-top > @last-scroll-top) + is-window-larger = (window-height > follower-height) + + console.log @initial-follower-top + + if (is-window-larger && scroll-top > @initial-follower-top) || (!is-window-larger && scroll-top > @initial-follower-top + height-delta) + @follower.class-list.add \fixed + else if !is-scrolling-down && scroll-top + @page-top <= @initial-follower-top + @follower.class-list.remove \fixed + @follower.style.top = 0 + return + + drag-bottom-down = (follower-bottom <= scroll-bottom && is-scrolling-down) + drag-top-up = (follower-top >= scroll-top + @page-top && !is-scrolling-down) + + if drag-bottom-down + console.log \down + @follower.style.top = if is-window-larger then 0 else -height-delta + \px + else if drag-top-up + console.log \up + @follower.style.top = @page-top + \px + else if @follower.class-list.contains \fixed + console.log \- + current-top = parse-int @follower.style.top, 10 + + min-top = -height-delta + scrolled-top = current-top + scroll-delta + + is-page-at-bottom = (scroll-top + window-height >= document.body.offset-height) + new-top = if is-page-at-bottom then min-top else scrolled-top + + @follower.style.top = new-top + \px + + @last-scroll-top = scroll-top + +module.exports = Follower diff --git a/src/web/app/desktop/scripts/fuck-ad-block.ls b/src/web/app/desktop/scripts/fuck-ad-block.ls new file mode 100644 index 0000000000..55431fcd00 --- /dev/null +++ b/src/web/app/desktop/scripts/fuck-ad-block.ls @@ -0,0 +1,19 @@ +# FUCK AD BLOCK +#================================ + +require 'fuck-adblock' +dialog = require './dialog.ls' + +module.exports = ~> + if fuck-ad-block == undefined + ad-block-detected! + else + fuck-ad-block.on-detected ad-block-detected + +function ad-block-detected + dialog do + '<i class="fa fa-exclamation-triangle"></i>広告ブロッカーを無効にしてください' + '<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。' + [ + text: \OK + ] diff --git a/src/web/app/desktop/scripts/input-dialog.ls b/src/web/app/desktop/scripts/input-dialog.ls new file mode 100644 index 0000000000..f75b12dd01 --- /dev/null +++ b/src/web/app/desktop/scripts/input-dialog.ls @@ -0,0 +1,13 @@ +# Input Dialog +#================================ + +riot = require 'riot' + +module.exports = (title, placeholder, default-value, on-ok, on-cancel) ~> + dialog = document.body.append-child document.create-element \mk-input-dialog + riot.mount dialog, do + title: title + placeholder: placeholder + default: default-value + on-ok: on-ok + on-cancel: on-cancel diff --git a/src/web/app/desktop/scripts/notify.ls b/src/web/app/desktop/scripts/notify.ls new file mode 100644 index 0000000000..919bbc3dcf --- /dev/null +++ b/src/web/app/desktop/scripts/notify.ls @@ -0,0 +1,6 @@ +riot = require \riot + +module.exports = (message) ~> + notification = document.body.append-child document.create-element \mk-ui-notification + riot.mount notification, do + message: message diff --git a/src/web/app/desktop/scripts/open-window.ls b/src/web/app/desktop/scripts/open-window.ls new file mode 100644 index 0000000000..4388272ecf --- /dev/null +++ b/src/web/app/desktop/scripts/open-window.ls @@ -0,0 +1,8 @@ +riot = require \riot + +function open(name, opts) + window = document.body.append-child document.create-element name + riot.mount window, opts + +riot.mixin \open-window do + open-window: open diff --git a/src/web/app/desktop/scripts/stream.ls b/src/web/app/desktop/scripts/stream.ls new file mode 100644 index 0000000000..f84d6097a7 --- /dev/null +++ b/src/web/app/desktop/scripts/stream.ls @@ -0,0 +1,38 @@ +# Stream +#================================ + +stream = require '../../common/scripts/stream.ls' +get-post-summary = require '../../common/scripts/get-post-summary.ls' +riot = require \riot + +module.exports = (me) ~> + s = stream me + + s.event.on \drive_file_created (file) ~> + n = new Notification 'ファイルがアップロードされました' do + body: file.name + icon: file.url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 5000ms + + s.event.on \mention (post) ~> + n = new Notification "#{post.user.name}さんから:" do + body: get-post-summary post + icon: post.user.avatar_url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 6000ms + + s.event.on \reply (post) ~> + n = new Notification "#{post.user.name}さんから返信:" do + body: get-post-summary post + icon: post.user.avatar_url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 6000ms + + s.event.on \quote (post) ~> + n = new Notification "#{post.user.name}さんが引用:" do + body: get-post-summary post + icon: post.user.avatar_url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 6000ms + + riot.mixin \stream do + stream: s.event + get-stream-state: s.get-state + stream-state-ev: s.state-ev diff --git a/src/web/app/desktop/scripts/update-avatar.ls b/src/web/app/desktop/scripts/update-avatar.ls new file mode 100644 index 0000000000..513a59074c --- /dev/null +++ b/src/web/app/desktop/scripts/update-avatar.ls @@ -0,0 +1,81 @@ +# Update Avatar +#================================ + +riot = require 'riot' +dialog = require './dialog.ls' +api = require '../../common/scripts/api.ls' + +module.exports = (I, cb, file = null) ~> + + @file-selected = (file) ~> + cropper = document.body.append-child document.create-element \mk-crop-window + cropper = riot.mount cropper, do + file: file + title: 'アバターとして表示する部分を選択' + aspect-ratio: 1 / 1 + .0 + cropper.on \cropped (blob) ~> + data = new FormData! + data.append \i I.token + data.append \file blob, file.name + '.cropped.png' + api I, \drive/folders/find do + name: 'アイコン' + .then (icon-folder) ~> + if icon-folder.length == 0 + api I, \drive/folders/create do + name: 'アイコン' + .then (icon-folder) ~> + @uplaod data, icon-folder + else + @uplaod data, icon-folder.0 + cropper.on \skiped ~> + @set file + + @uplaod = (data, folder) ~> + + progress = document.body.append-child document.create-element \mk-progress-dialog + progress = riot.mount progress, do + title: '新しいアバターをアップロードしています' + .0 + + if folder? + data.append \folder_id folder.id + + xhr = new XMLHttpRequest! + xhr.open \POST CONFIG.api.url + \/drive/files/create true + xhr.onload = (e) ~> + file = JSON.parse e.target.response + progress.close! + @set file + + xhr.upload.onprogress = (e) ~> + if e.length-computable + progress.update-progress e.loaded, e.total + + xhr.send data + + @set = (file) ~> + api I, \i/update do + avatar_id: file.id + .then (i) ~> + dialog do + '<i class="fa fa-info-circle"></i>アバターを更新しました' + '新しいアバターが反映されるまで時間がかかる場合があります。' + [ + text: \わかった + ] + if cb? then cb i + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + + if file? + @file-selected file + else + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + browser = riot.mount browser, do + multiple: false + title: '<i class="fa fa-picture-o"></i>アバターにする画像を選択' + .0 + browser.one \selected (file) ~> + @file-selected file diff --git a/src/web/app/desktop/scripts/update-banner.ls b/src/web/app/desktop/scripts/update-banner.ls new file mode 100644 index 0000000000..5754cdcdb1 --- /dev/null +++ b/src/web/app/desktop/scripts/update-banner.ls @@ -0,0 +1,81 @@ +# Update Banner +#================================ + +riot = require 'riot' +dialog = require './dialog.ls' +api = require '../../common/scripts/api.ls' + +module.exports = (I, cb, file = null) ~> + + @file-selected = (file) ~> + cropper = document.body.append-child document.create-element \mk-crop-window + cropper = riot.mount cropper, do + file: file + title: 'バナーとして表示する部分を選択' + aspect-ratio: 16 / 9 + .0 + cropper.on \cropped (blob) ~> + data = new FormData! + data.append \i I.token + data.append \file blob, file.name + '.cropped.png' + api I, \drive/folders/find do + name: 'バナー' + .then (banner-folder) ~> + if banner-folder.length == 0 + api I, \drive/folders/create do + name: 'バナー' + .then (banner-folder) ~> + @uplaod data, banner-folder + else + @uplaod data, banner-folder.0 + cropper.on \skiped ~> + @set file + + @uplaod = (data, folder) ~> + + progress = document.body.append-child document.create-element \mk-progress-dialog + progress = riot.mount progress, do + title: '新しいバナーをアップロードしています' + .0 + + if folder? + data.append \folder_id folder.id + + xhr = new XMLHttpRequest! + xhr.open \POST CONFIG.api.url + \/drive/files/create true + xhr.onload = (e) ~> + file = JSON.parse e.target.response + progress.close! + @set file + + xhr.upload.onprogress = (e) ~> + if e.length-computable + progress.update-progress e.loaded, e.total + + xhr.send data + + @set = (file) ~> + api I, \i/update do + banner_id: file.id + .then (i) ~> + dialog do + '<i class="fa fa-info-circle"></i>バナーを更新しました' + '新しいバナーが反映されるまで時間がかかる場合があります。' + [ + text: \わかりました。 + ] + if cb? then cb i + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + + if file? + @file-selected file + else + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + browser = riot.mount browser, do + multiple: false + title: '<i class="fa fa-picture-o"></i>バナーにする画像を選択' + .0 + browser.one \selected (file) ~> + @file-selected file diff --git a/src/web/app/desktop/scripts/update-wallpaper.ls b/src/web/app/desktop/scripts/update-wallpaper.ls new file mode 100644 index 0000000000..49632400c0 --- /dev/null +++ b/src/web/app/desktop/scripts/update-wallpaper.ls @@ -0,0 +1,35 @@ +# Update Wallpaper +#================================ + +riot = require 'riot' +dialog = require './dialog.ls' +api = require '../../common/scripts/api.ls' + +module.exports = (I, cb, file = null) ~> + + @set = (file) ~> + api I, \i/appdata/set do + data: JSON.stringify do + wallpaper: file.id + .then (i) ~> + dialog do + '<i class="fa fa-info-circle"></i>壁紙を更新しました' + '新しい壁紙が反映されるまで時間がかかる場合があります。' + [ + text: \はい + ] + if cb? then cb i + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + + if file? + @set file + else + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + browser = riot.mount browser, do + multiple: false + title: '<i class="fa fa-picture-o"></i>壁紙にする画像を選択' + .0 + browser.one \selected (file) ~> + @set file diff --git a/src/web/app/desktop/scripts/user-preview.ls b/src/web/app/desktop/scripts/user-preview.ls new file mode 100644 index 0000000000..0c5a67aedb --- /dev/null +++ b/src/web/app/desktop/scripts/user-preview.ls @@ -0,0 +1,74 @@ +# User Preview +#================================ + +riot = require \riot + +riot.mixin \user-preview do + init: -> + @on \mount ~> + scan.call @ + @on \updated ~> + scan.call @ + + function scan + elems = @root.query-selector-all '[data-user-preview]:not([data-user-preview-attached])' + elems.for-each attach.bind @ + +function attach el + el.set-attribute \data-user-preview-attached true + user = el.get-attribute \data-user-preview + + tag = null + + show-timer = null + hide-timer = null + + el.add-event-listener \mouseover ~> + clear-timeout show-timer + clear-timeout hide-timer + show-timer := set-timeout ~> + show! + , 500ms + + el.add-event-listener \mouseleave ~> + clear-timeout show-timer + clear-timeout hide-timer + hide-timer := set-timeout ~> + close! + , 500ms + + @on \unmount ~> + clear-timeout show-timer + clear-timeout hide-timer + close! + + function show + if tag? + return + + preview = document.create-element \mk-user-preview + + rect = el.get-bounding-client-rect! + x = rect.left + el.offset-width + window.page-x-offset + y = rect.top + window.page-y-offset + + preview.style.top = y + \px + preview.style.left = x + \px + + preview.add-event-listener \mouseover ~> + clear-timeout hide-timer + + preview.add-event-listener \mouseleave ~> + clear-timeout show-timer + hide-timer := set-timeout ~> + close! + , 500ms + + tag := riot.mount (document.body.append-child preview), do + user: user + .0 + + function close + if tag? + tag.close! + tag := null diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl new file mode 100644 index 0000000000..fa50f6ce31 --- /dev/null +++ b/src/web/app/desktop/style.styl @@ -0,0 +1,114 @@ +@import "../base" +@import "../../../../node_modules/cropperjs/dist/cropper.css" + +*::input-placeholder + color #D8CBC5 + +* + &:focus + outline none + + &::scrollbar + width 5px + background transparent + + &:horizontal + height 5px + + &::scrollbar-button + width 0 + height 0 + background rgba(0, 0, 0, 0.2) + + &::scrollbar-piece + background transparent + + &:start + background transparent + + &::scrollbar-thumb + background rgba(0, 0, 0, 0.2) + + &:hover + background rgba(0, 0, 0, 0.4) + + &:active + background $theme-color + + &::scrollbar-corner + background rgba(0, 0, 0, 0.2) + +html + background #fdfdfd + + // ↓ workaround of https://github.com/riot/riot/issues/2134 + &[data-page='entrance'] + #wait + right auto + left 15px + +html[theme='dark'] + background #100f0f + +button + font-family sans-serif + + * + pointer-events none + + &.style-normal + &.style-primary + display block + cursor pointer + padding 0 16px + margin 0 + min-width 100px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &.style-normal + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.style-primary + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color diff --git a/src/web/app/desktop/tags.ls b/src/web/app/desktop/tags.ls new file mode 100644 index 0000000000..f78d367341 --- /dev/null +++ b/src/web/app/desktop/tags.ls @@ -0,0 +1,103 @@ +require './tags/contextmenu.tag' +require './tags/dialog.tag' +require './tags/window.tag' +require './tags/input-dialog.tag' +require './tags/follow-button.tag' +require './tags/drive/base-contextmenu.tag' +require './tags/drive/file-contextmenu.tag' +require './tags/drive/folder-contextmenu.tag' +require './tags/drive/file.tag' +require './tags/drive/folder.tag' +require './tags/drive/nav-folder.tag' +require './tags/drive/browser-window.tag' +require './tags/drive/browser.tag' +require './tags/select-file-from-drive-window.tag' +require './tags/crop-window.tag' +require './tags/settings.tag' +require './tags/settings-window.tag' +require './tags/analog-clock.tag' +require './tags/go-top.tag' +require './tags/ui-header.tag' +require './tags/ui-header-account.tag' +require './tags/ui-header-notifications.tag' +require './tags/ui-header-clock.tag' +require './tags/ui-header-nav.tag' +require './tags/ui-header-post-button.tag' +require './tags/ui-header-search.tag' +require './tags/notifications.tag' +require './tags/post-form-window.tag' +require './tags/post-form.tag' +require './tags/timeline-post.tag' +require './tags/post-preview.tag' +require './tags/repost-form-window.tag' +require './tags/home-widgets/user-recommendation.tag' +require './tags/home-widgets/timeline.tag' +require './tags/home-widgets/mentions.tag' +require './tags/home-widgets/calendar.tag' +require './tags/home-widgets/donation.tag' +require './tags/home-widgets/tips.tag' +require './tags/home-widgets/nav.tag' +require './tags/home-widgets/profile.tag' +require './tags/home-widgets/notifications.tag' +require './tags/home-widgets/rss-reader.tag' +require './tags/home-widgets/photo-stream.tag' +require './tags/home-widgets/broadcast.tag' +require './tags/stream-indicator.tag' +require './tags/timeline.tag' +require './tags/messaging/window.tag' +require './tags/messaging/room.tag' +require './tags/messaging/room-window.tag' +require './tags/messaging/message.tag' +require './tags/messaging/index.tag' +require './tags/messaging/form.tag' +require './tags/following-setuper.tag' +require './tags/ellipsis-icon.tag' +require './tags/ui.tag' +require './tags/home.tag' +require './tags/detect-slow-internet-connection-notice.tag' +require './tags/user-header.tag' +require './tags/user-profile.tag' +require './tags/user-timeline.tag' +require './tags/user.tag' +require './tags/user-home.tag' +require './tags/user-graphs.tag' +require './tags/user-photos.tag' +require './tags/big-follow-button.tag' +require './tags/pages/entrance.tag' +require './tags/pages/entrance/signin.tag' +require './tags/pages/entrance/signup.tag' +require './tags/pages/home.tag' +require './tags/pages/user.tag' +require './tags/pages/post.tag' +require './tags/pages/search.tag' +require './tags/pages/not-found.tag' +require './tags/autocomplete-suggestion.tag' +require './tags/progress-dialog.tag' +require './tags/user-preview.tag' +require './tags/post-detail.tag' +require './tags/post-detail-sub.tag' +require './tags/search.tag' +require './tags/search-posts.tag' +require './tags/set-avatar-suggestion.tag' +require './tags/set-banner-suggestion.tag' +require './tags/repost-form.tag' +require './tags/timeline-post-sub.tag' +require './tags/sub-post-content.tag' +require './tags/images-viewer.tag' +require './tags/image-dialog.tag' +require './tags/donation.tag' +require './tags/user-posts-graph.tag' +require './tags/user-friends-graph.tag' +require './tags/user-likes-graph.tag' +require './tags/post-status-graph.tag' +require './tags/debugger.tag' +require './tags/users-list.tag' +require './tags/user-following.tag' +require './tags/user-followers.tag' +require './tags/user-following-window.tag' +require './tags/user-followers-window.tag' +require './tags/list-user.tag' +require './tags/ui-notification.tag' +require './tags/signin-history.tag' +require './tags/log.tag' +require './tags/log-window.tag' diff --git a/src/web/app/desktop/tags/analog-clock.tag b/src/web/app/desktop/tags/analog-clock.tag new file mode 100644 index 0000000000..a4cfe5726c --- /dev/null +++ b/src/web/app/desktop/tags/analog-clock.tag @@ -0,0 +1,102 @@ +mk-analog-clock + canvas@canvas(width='256', height='256') + +style. + > canvas + display block + width 256px + height 256px + +script. + @on \mount ~> + @draw! + @clock = set-interval @draw, 1000ms + + @on \unmount ~> + clear-interval @clock + + @draw = ~> + now = new Date! + s = now.get-seconds! + m = now.get-minutes! + h = now.get-hours! + + vec2 = (x, y) -> + @x = x + @y = y + + ctx = @refs.canvas.get-context \2d + canv-w = @refs.canvas.width + canv-h = @refs.canvas.height + ctx.clear-rect 0, 0, canv-w, canv-h + + # 背景 + center = (Math.min (canv-w / 2), (canv-h / 2)) + line-start = center * 0.90 + line-end-short = center * 0.87 + line-end-long = center * 0.84 + for i from 0 to 59 by 1 + angle = Math.PI * i / 30 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + ctx.line-width = 1 + ctx.move-to do + (canv-w / 2) + uv.x * line-start + (canv-h / 2) + uv.y * line-start + if i % 5 == 0 + ctx.stroke-style = 'rgba(255, 255, 255, 0.2)' + ctx.line-to do + (canv-w / 2) + uv.x * line-end-long + (canv-h / 2) + uv.y * line-end-long + else + ctx.stroke-style = 'rgba(255, 255, 255, 0.1)' + ctx.line-to do + (canv-w / 2) + uv.x * line-end-short + (canv-h / 2) + uv.y * line-end-short + ctx.stroke! + + # 分 + angle = Math.PI * (m + s / 60) / 30 + length = (Math.min canv-w, canv-h) / 2.6 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + ctx.stroke-style = \#ffffff + ctx.line-width = 2 + ctx.move-to do + (canv-w / 2) - uv.x * length / 5 + (canv-h / 2) - uv.y * length / 5 + ctx.line-to do + (canv-w / 2) + uv.x * length + (canv-h / 2) + uv.y * length + ctx.stroke! + + # 時 + angle = Math.PI * (h % 12 + m / 60) / 6 + length = (Math.min canv-w, canv-h) / 4 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + #ctx.stroke-style = \#ffffff + ctx.stroke-style = CONFIG.theme-color + ctx.line-width = 2 + ctx.move-to do + (canv-w / 2) - uv.x * length / 5 + (canv-h / 2) - uv.y * length / 5 + ctx.line-to do + (canv-w / 2) + uv.x * length + (canv-h / 2) + uv.y * length + ctx.stroke! + + # 秒 + angle = Math.PI * s / 30 + length = (Math.min canv-w, canv-h) / 2.6 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + ctx.stroke-style = 'rgba(255, 255, 255, 0.5)' + ctx.line-width = 1 + ctx.move-to do + (canv-w / 2) - uv.x * length / 5 + (canv-h / 2) - uv.y * length / 5 + ctx.line-to do + (canv-w / 2) + uv.x * length + (canv-h / 2) + uv.y * length + ctx.stroke! diff --git a/src/web/app/desktop/tags/autocomplete-suggestion.tag b/src/web/app/desktop/tags/autocomplete-suggestion.tag new file mode 100644 index 0000000000..13d9df6914 --- /dev/null +++ b/src/web/app/desktop/tags/autocomplete-suggestion.tag @@ -0,0 +1,182 @@ +mk-autocomplete-suggestion + ol.users@users(if={ users.length > 0 }) + li(each={ users }, onclick={ parent.on-click }, onkeydown={ parent.on-keydown }, tabindex='-1') + img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='') + span.name { name } + span.username @{ username } + +style. + display block + position absolute + z-index 65535 + margin-top calc(1em + 8px) + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + + > .users + display block + margin 0 + padding 4px 0 + max-height 190px + max-width 500px + overflow auto + list-style none + + > li + display block + padding 4px 12px + white-space nowrap + overflow hidden + font-size 0.9em + color rgba(0, 0, 0, 0.8) + cursor default + + &, * + user-select none + + &:hover + &[data-selected='true'] + color #fff + background $theme-color + + .name + color #fff + + .username + color #fff + + &:active + color #fff + background darken($theme-color, 10%) + + .name + color #fff + + .username + color #fff + + .avatar + vertical-align middle + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 100% + + .name + margin 0 8px 0 0 + /*font-weight bold*/ + font-weight normal + color rgba(0, 0, 0, 0.8) + + .username + font-weight normal + color rgba(0, 0, 0, 0.3) + +script. + @mixin \api + + @q = @opts.q + @textarea = @opts.textarea + @loading = true + @users = [] + @select = -1 + + @on \mount ~> + @textarea.add-event-listener \keydown @on-keydown + + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + + @api \users/search_by_username do + query: @q + limit: 30users + .then (users) ~> + @users = users + @loading = false + @update! + .catch (err) ~> + console.error err + + @on \unmount ~> + @textarea.remove-event-listener \keydown @on-keydown + + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + + @mousedown = (e) ~> + if (!contains @root, e.target) and (@root != e.target) + @close! + + @on-click = (e) ~> + @complete e.item + + @on-keydown = (e) ~> + key = e.which + switch (key) + | 10, 13 => # Key[ENTER] + if @select != -1 + e.prevent-default! + e.stop-propagation! + @complete @users[@select] + else + @close! + | 27 => # Key[ESC] + e.prevent-default! + e.stop-propagation! + @close! + | 38 => # Key[↑] + if @select != -1 + e.prevent-default! + e.stop-propagation! + @select-prev! + else + @close! + | 9, 40 => # Key[TAB] or Key[↓] + e.prevent-default! + e.stop-propagation! + @select-next! + | _ => + @close! + + @select-next = ~> + @select++ + + if @select >= @users.length + @select = 0 + + @apply-select! + + @select-prev = ~> + @select-- + + if @select < 0 + @select = @users.length - 1 + + @apply-select! + + @apply-select = ~> + @refs.users.children.for-each (el) ~> + el.remove-attribute \data-selected + + @refs.users.children[@select].set-attribute \data-selected \true + @refs.users.children[@select].focus! + + @complete = (user) ~> + @opts.complete user + + @close = ~> + @opts.close! + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/big-follow-button.tag b/src/web/app/desktop/tags/big-follow-button.tag new file mode 100644 index 0000000000..636853407c --- /dev/null +++ b/src/web/app/desktop/tags/big-follow-button.tag @@ -0,0 +1,134 @@ +mk-big-follow-button + button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following }, + onclick={ onclick }, + disabled={ wait }, + title={ user.is_following ? 'フォロー解除' : 'フォローする' }) + span(if={ !wait && user.is_following }) + i.fa.fa-minus + | フォロー解除 + span(if={ !wait && !user.is_following }) + i.fa.fa-plus + | フォロー + i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait }) + div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw + +style. + display block + + > button + > .init + display block + cursor pointer + padding 0 + margin 0 + width 100% + line-height 38px + font-size 1em + outline none + border-radius 4px + + * + pointer-events none + + i + margin-right 8px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &.follow + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.unfollow + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &.wait + cursor wait !important + opacity 0.7 + +script. + @mixin \api + @mixin \is-promise + @mixin \stream + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @init = true + @wait = false + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @init = false + @update! + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @on-stream-follow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @on-stream-unfollow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @onclick = ~> + @wait = true + if @user.is_following + @api \following/delete do + user_id: @user.id + .then ~> + @user.is_following = false + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! + else + @api \following/create do + user_id: @user.id + .then ~> + @user.is_following = true + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! diff --git a/src/web/app/desktop/tags/contextmenu.tag b/src/web/app/desktop/tags/contextmenu.tag new file mode 100644 index 0000000000..7c3c7b8a24 --- /dev/null +++ b/src/web/app/desktop/tags/contextmenu.tag @@ -0,0 +1,138 @@ +mk-contextmenu + | <yield /> + +style. + $width = 240px + $item-height = 38px + $padding = 10px + + display none + position fixed + top 0 + left 0 + z-index 4096 + width $width + font-size 0.8em + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + + ul + display block + margin 0 + padding $padding 0 + list-style none + + li + display block + + &.separator + margin-top $padding + padding-top $padding + border-top solid 1px #eee + + &.has-child + > p + cursor default + + > i:last-child + position absolute + top 0 + right 8px + line-height $item-height + + &:hover > ul + visibility visible + + &:active + > p, a + background $theme-color + + > p, a + display block + z-index 1 + margin 0 + padding 0 32px 0 38px + line-height $item-height + color #868C8C + text-decoration none + cursor pointer + + &:hover + text-decoration none + + * + pointer-events none + + > i + width 28px + margin-left -28px + text-align center + + &:hover + > p, a + text-decoration none + background $theme-color + color $theme-color-foreground + + &:active + > p, a + text-decoration none + background darken($theme-color, 10%) + color $theme-color-foreground + + li > ul + visibility hidden + position absolute + top 0 + left $width + margin-top -($padding) + width $width + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + transition visibility 0s linear 0.2s + +script. + + @root.add-event-listener \contextmenu (e) ~> + e.prevent-default! + + @mousedown = (e) ~> + e.prevent-default! + if (!contains @root, e.target) and (@root != e.target) + @close! + return false + + @open = (pos) ~> + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + @root.style.display = \block + @root.style.left = pos.x + \px + @root.style.top = pos.y + \px + + Velocity @root, \finish true + Velocity @root, { opacity: 0 } 0ms + Velocity @root, { + opacity: 1 + } { + queue: false + duration: 100ms + easing: \linear + } + + @close = ~> + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + @trigger \closed + @unmount! + + function contains(parent, child) + node = child.parent-node + while (node != null) + if (node == parent) + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/crop-window.tag b/src/web/app/desktop/tags/crop-window.tag new file mode 100644 index 0000000000..16e1a72b3a --- /dev/null +++ b/src/web/app/desktop/tags/crop-window.tag @@ -0,0 +1,189 @@ +mk-crop-window + mk-window@window(is-modal={ true }, width={ '800px' }) + <yield to="header"> + i.fa.fa-crop + | { parent.title } + </yield> + <yield to="content"> + div.body + img@img(src={ parent.image.url + '?thumbnail&quality=80' }, alt='') + div.action + button.skip(onclick={ parent.skip }) クロップをスキップ + button.cancel(onclick={ parent.cancel }) キャンセル + button.ok(onclick={ parent.ok }) 決定 + </yield> + +style. + display block + + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + + > .body + > img + width 100% + max-height 400px + + .cropper-modal { + opacity: 0.8; + } + + .cropper-view-box { + outline-color: $theme-color; + } + + .cropper-line, .cropper-point { + background-color: $theme-color; + } + + .cropper-bg { + animation: cropper-bg 0.5s linear infinite; + } + + @-webkit-keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + @-moz-keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + @-ms-keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + @keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + > .action + height 72px + background lighten($theme-color, 95%) + + .ok + .cancel + .skip + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + .cancel + width 120px + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + .skip + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + .cancel + right 148px + + .skip + left 16px + width 150px + +script. + @mixin \cropper + + @image = @opts.file + @title = @opts.title + @aspect-ratio = @opts.aspect-ratio + @cropper = null + + @on \mount ~> + @img = @refs.window.refs.img + @cropper = new @Cropper @img, do + aspect-ratio: @aspect-ratio + highlight: no + view-mode: 1 + + @ok = ~> + @cropper.get-cropped-canvas!.to-blob (blob) ~> + @trigger \cropped blob + @refs.window.close! + + @skip = ~> + @trigger \skiped + @refs.window.close! + + @cancel = ~> + @trigger \canceled + @refs.window.close! diff --git a/src/web/app/desktop/tags/debugger.tag b/src/web/app/desktop/tags/debugger.tag new file mode 100644 index 0000000000..e2b522cb00 --- /dev/null +++ b/src/web/app/desktop/tags/debugger.tag @@ -0,0 +1,87 @@ +mk-debugger + mk-window@window(is-modal={ false }, width={ '700px' }, height={ '550px' }) + <yield to="header"> + i.fa.fa-wrench + | Debugger + </yield> + <yield to="content"> + section.progress-dialog + h1 progress-dialog + button.style-normal(onclick={ parent.progress-dialog }): i.fa.fa-play + button.style-normal(onclick={ parent.progress-dialog-destroy }): i.fa.fa-stop + label + p TITLE: + input@progress-title(value='Title') + label + p VAL: + input@progress-value(type='number', oninput={ parent.progress-change }, value=0) + label + p MAX: + input@progress-max(type='number', oninput={ parent.progress-change }, value=100) + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + overflow auto + + > section + padding 32px + + // & + section + // margin-top 16px + + > h1 + display block + margin 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + > label + display block + + > p + display inline + margin 0 + + > .progress-dialog + button + display inline-block + margin 8px + +script. + @mixin \open-window + + @on \mount ~> + @progress-title = @tags['mk-window'].progress-title + @progress-value = @tags['mk-window'].progress-value + @progress-max = @tags['mk-window'].progress-max + + @refs.window.on \closed ~> + @unmount! + + ################################ + + @progress-controller = riot.observable! + + @progress-dialog = ~> + @open-window \mk-progress-dialog do + title: @progress-title.value + value: @progress-value.value + max: @progress-max.value + controller: @progress-controller + + @progress-change = ~> + @progress-controller.trigger do + \update + @progress-value.value + @progress-max.value + + @progress-dialog-destroy = ~> + @progress-controller.trigger \close diff --git a/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag b/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag new file mode 100644 index 0000000000..f11a0c0857 --- /dev/null +++ b/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag @@ -0,0 +1,56 @@ +mk-detect-slow-internet-connection-notice + i: i.fa.fa-exclamation + div: p インターネット回線が遅いようです。 + +style. + display block + pointer-events none + position fixed + z-index 16384 + top 64px + right 16px + margin 0 + padding 0 + width 298px + font-size 0.9em + background #fff + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + opacity 0 + + > i + display block + width 48px + line-height 48px + margin-right 0.25em + text-align center + color $theme-color-foreground + font-size 1.5em + background $theme-color + + > div + display block + position absolute + top 0 + left 48px + margin 0 + width 250px + height 48px + color #666 + + > p + display block + margin 0 + padding 8px + +script. + @mixin \net + + @net.on \detected-slow-network ~> + Velocity @root, { + opacity: 1 + } 200ms \linear + set-timeout ~> + Velocity @root, { + opacity: 0 + } 200ms \linear + , 10000ms diff --git a/src/web/app/desktop/tags/dialog.tag b/src/web/app/desktop/tags/dialog.tag new file mode 100644 index 0000000000..88a461db84 --- /dev/null +++ b/src/web/app/desktop/tags/dialog.tag @@ -0,0 +1,141 @@ +mk-dialog + div.bg@bg(onclick={ bg-click }) + div.main@main + header@header + div.body@body + div.buttons + virtual(each={ opts.buttons }) + button(onclick={ _onclick }) { text } + +style. + display block + + > .bg + display block + position fixed + z-index 8192 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 8192 + top 20% + left 0 + right 0 + margin 0 auto 0 auto + padding 32px 42px + width 480px + background #fff + + > header + margin 1em 0 + color $theme-color + // color #43A4EC + font-weight bold + + > i + margin-right 0.5em + + > .body + margin 1em 0 + color #888 + + > .buttons + > button + display inline-block + float right + margin 0 + padding 10px 10px + font-size 1.1em + font-weight normal + text-decoration none + color #888 + background transparent + outline none + border none + border-radius 0 + cursor pointer + transition color 0.1s ease + + i + margin 0 0.375em + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + +script. + @can-through = if opts.can-through? then opts.can-through else true + @opts.buttons.for-each (button) ~> + button._onclick = ~> + if button.onclick? + button.onclick! + @close! + + @on \mount ~> + @refs.header.innerHTML = @opts.title + @refs.body.innerHTML = @opts.text + + @refs.bg.style.pointer-events = \auto + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 1 + } { + queue: false + duration: 100ms + easing: \linear + } + + Velocity @refs.main, { + opacity: 0 + scale: 1.2 + } { + duration: 0 + } + Velocity @refs.main, { + opacity: 1 + scale: 1 + } { + duration: 300ms + easing: [ 0, 0.5, 0.5, 1 ] + } + + @close = ~> + @refs.bg.style.pointer-events = \none + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 0 + } { + queue: false + duration: 300ms + easing: \linear + } + + @refs.main.style.pointer-events = \none + Velocity @refs.main, \finish true + Velocity @refs.main, { + opacity: 0 + scale: 0.8 + } { + queue: false + duration: 300ms + easing: [ 0.5, -0.5, 1, 0.5 ] + complete: ~> + @unmount! + } + + @bg-click = ~> + if @can-through + if @opts.on-through? + @opts.on-through! + @close! diff --git a/src/web/app/desktop/tags/donation.tag b/src/web/app/desktop/tags/donation.tag new file mode 100644 index 0000000000..9f8a1a6729 --- /dev/null +++ b/src/web/app/desktop/tags/donation.tag @@ -0,0 +1,63 @@ +mk-donation + button.close(onclick={ close }) 閉じる x + div.message + p 利用者の皆さま、 + p + | 今日は、日本の皆さまにお知らせがあります。 + | Misskeyの援助をお願いいたします。 + | 私は独立性を守るため、一切の広告を掲載いたしません。 + | 平均で約¥1,500の寄付をいただき、運営しております。 + | 援助をしてくださる利用者はほんの少数です。 + | お願いいたします。 + | 今日、利用者の皆さまが¥300ご援助くだされば、募金活動を一時間で終了することができます。 + | コーヒー1杯ほどの金額です。 + | Misskeyを活用しておられるのでしたら、広告を掲載せずにもう1年活動できるよう、どうか1分だけお時間をください。 + | 私は小さな非営利個人ですが、サーバー、プログラム、人件費など、世界でトップクラスのウェブサイト同等のコストがかかります。 + | 利用者は何億人といますが、他の大きなサイトに比べてほんの少額の費用で運営しているのです。 + | 人間の可能性、自由、そして機会。知識こそ、これらの基盤を成すものです。 + | 私は、誰もが無料かつ制限なく知識に触れられるべきだと信じています。 + | 募金活動を終了し、Misskeyの改善に戻れるようご援助ください。 + | よろしくお願いいたします。 + +style. + display block + color #fff + background #03072C + + > .close + position absolute + top 16px + right 16px + z-index 1 + + > .message + padding 32px + font-size 1.4em + font-family serif + + > p + display block + margin 0 auto + max-width 1200px + + > p:first-child + margin-bottom 16px + +script. + @mixin \api + @mixin \i + + @close = (e) ~> + e.prevent-default! + e.stop-propagation! + + @I.data.no_donation = true + @api \i/appdata/set do + data: JSON.stringify do + no_donation: @I.data.no_donation + .then ~> + @update-i! + + @unmount! + + @parent.parent.set-root-layout! diff --git a/src/web/app/desktop/tags/drive/base-contextmenu.tag b/src/web/app/desktop/tags/drive/base-contextmenu.tag new file mode 100644 index 0000000000..c8b51009ea --- /dev/null +++ b/src/web/app/desktop/tags/drive/base-contextmenu.tag @@ -0,0 +1,28 @@ +mk-drive-browser-base-contextmenu + mk-contextmenu@ctx + ul + li(onclick={ parent.create-folder }): p + i.fa.fa-folder-o + | フォルダーを作成 + li(onclick={ parent.upload }): p + i.fa.fa-upload + | ファイルをアップロード + +script. + @browser = @opts.browser + + @on \mount ~> + @refs.ctx.on \closed ~> + @trigger \closed + @unmount! + + @open = (pos) ~> + @refs.ctx.open pos + + @create-folder = ~> + @browser.create-folder! + @refs.ctx.close! + + @upload = ~> + @browser.select-local-file! + @refs.ctx.close! diff --git a/src/web/app/desktop/tags/drive/browser-window.tag b/src/web/app/desktop/tags/drive/browser-window.tag new file mode 100644 index 0000000000..b3a5fc9a47 --- /dev/null +++ b/src/web/app/desktop/tags/drive/browser-window.tag @@ -0,0 +1,29 @@ +mk-drive-browser-window + mk-window@window(is-modal={ false }, width={ '800px' }, height={ '500px' }) + <yield to="header"> + i.fa.fa-cloud + | ドライブ + </yield> + <yield to="content"> + mk-drive-browser(multiple={ true }, folder={ parent.folder }) + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > mk-drive-browser + height 100% + +script. + @folder = if @opts.folder? then @opts.folder else null + + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @close = ~> + @refs.window.close! diff --git a/src/web/app/desktop/tags/drive/browser.tag b/src/web/app/desktop/tags/drive/browser.tag new file mode 100644 index 0000000000..62e6425fe5 --- /dev/null +++ b/src/web/app/desktop/tags/drive/browser.tag @@ -0,0 +1,634 @@ +mk-drive-browser + nav + div.path(oncontextmenu={ path-oncontextmenu }) + mk-drive-browser-nav-folder(class={ current: folder == null }, folder={ null }) + virtual(each={ folder in hierarchy-folders }) + span.separator: i.fa.fa-angle-right + mk-drive-browser-nav-folder(folder={ folder }) + span.separator(if={ folder != null }): i.fa.fa-angle-right + span.folder.current(if={ folder != null }) + | { folder.name } + input.search(type='search', placeholder!=' 検索') + div.main@main(class={ uploading: uploads.length > 0, loading: loading }, onmousedown={ onmousedown }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }, oncontextmenu={ oncontextmenu }) + div.selection@selection + div.contents@contents + div.folders@folders-container(if={ folders.length > 0 }) + virtual(each={ folder in folders }) + mk-drive-browser-folder.folder(folder={ folder }) + button(if={ more-folders }) + | もっと読み込む + div.files@files-container(if={ files.length > 0 }) + virtual(each={ file in files }) + mk-drive-browser-file.file(file={ file }) + button(if={ more-files }) + | もっと読み込む + div.empty(if={ files.length == 0 && folders.length == 0 && !loading }) + p(if={ draghover }) + | ドロップですか?いいですよ、ボクはカワイイですからね + p(if={ !draghover && folder == null }) + strong ドライブには何もありません。 + br + | 右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。 + p(if={ !draghover && folder != null }) + | このフォルダーは空です + div.loading(if={ loading }). + <div class="spinner"> + <div class="dot1"></div> + <div class="dot2"></div> + </div> + div.dropzone(if={ draghover }) + mk-uploader@uploader + input@file-input(type='file', accept='*/*', multiple, tabindex='-1', onchange={ change-file-input }) + +style. + display block + + > nav + display block + z-index 2 + width 100% + overflow auto + font-size 0.9em + color #555 + background #fff + //border-bottom 1px solid #dfdfdf + box-shadow 0 1px 0 rgba(0, 0, 0, 0.05) + + &, * + user-select none + + > .path + display inline-block + vertical-align bottom + margin 0 + padding 0 8px + width calc(100% - 200px) + line-height 38px + white-space nowrap + + > * + display inline-block + margin 0 + padding 0 8px + line-height 38px + cursor pointer + + i + margin-right 4px + + * + pointer-events none + + &:hover + text-decoration underline + + &.current + font-weight bold + cursor default + + &:hover + text-decoration none + + &.separator + margin 0 + padding 0 + opacity 0.5 + cursor default + + > i + margin 0 + + > .search + display inline-block + vertical-align bottom + user-select text + cursor auto + margin 0 + padding 0 18px + width 200px + font-size 1em + line-height 38px + background transparent + outline none + //border solid 1px #ddd + border none + border-radius 0 + box-shadow none + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &[data-active='true'] + background #fff + + &::-webkit-input-placeholder, + &:-ms-input-placeholder, + &:-moz-placeholder + color $ui-controll-foreground-color + + > .main + padding 8px + height calc(100% - 38px) + overflow auto + + &, * + user-select none + + &.loading + cursor wait !important + + * + pointer-events none + + > .contents + opacity 0.5 + + &.uploading + height calc(100% - 38px - 100px) + + > .selection + display none + position absolute + z-index 128 + top 0 + left 0 + border solid 1px $theme-color + background rgba($theme-color, 0.5) + pointer-events none + + > .contents + + > .folders + &:after + content "" + display block + clear both + + > .folder + float left + + > .files + &:after + content "" + display block + clear both + + > .file + float left + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .loading + .spinner + margin 100px auto + width 40px + height 40px + text-align center + + animation sk-rotate 2.0s infinite linear + + .dot1, .dot2 + width 60% + height 60% + display inline-block + position absolute + top 0 + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + + animation sk-bounce 2.0s infinite ease-in-out + + .dot2 + top auto + bottom 0 + animation-delay -1.0s + + @keyframes sk-rotate { 100% { transform: rotate(360deg); }} + + @keyframes sk-bounce { + 0%, 100% { + transform: scale(0.0); + } 50% { + transform: scale(1.0); + } + } + + > .dropzone + position absolute + left 0 + top 38px + width 100% + height calc(100% - 38px) + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + + > mk-uploader + height 100px + padding 16px + background #fff + + > input + display none + +script. + @mixin \api + @mixin \dialog + @mixin \input-dialog + @mixin \stream + + @files = [] + @folders = [] + @hierarchy-folders = [] + + @uploads = [] + + # 現在の階層(フォルダ) + # * null でルートを表す + @folder = null + + @multiple = if @opts.multiple? then @opts.multiple else false + + # ドロップされようとしているか + @draghover = false + + # 自信の所有するアイテムがドラッグをスタートさせたか + # (自分自身の階層にドロップできないようにするためのフラグ) + @is-drag-source = false + + @on \mount ~> + @refs.uploader.on \uploaded (file) ~> + @add-file file, true + + @refs.uploader.on \change-uploads (uploads) ~> + @uploads = uploads + @update! + + @stream.on \drive_file_created @on-stream-drive-file-created + @stream.on \drive_file_updated @on-stream-drive-file-updated + @stream.on \drive_folder_created @on-stream-drive-folder-created + @stream.on \drive_folder_updated @on-stream-drive-folder-updated + + # Riotのバグでnullを渡しても""になる + # https://github.com/riot/riot/issues/2080 + #if @opts.folder? + if @opts.folder? and @opts.folder != '' + @move @opts.folder + else + @load! + + @on \unmount ~> + @stream.off \drive_file_created @on-stream-drive-file-created + @stream.off \drive_file_updated @on-stream-drive-file-updated + @stream.off \drive_folder_created @on-stream-drive-folder-created + @stream.off \drive_folder_updated @on-stream-drive-folder-updated + + @on-stream-drive-file-created = (file) ~> + @add-file file, true + + @on-stream-drive-file-updated = (file) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + @remove-file file + else + @add-file file, true + + @on-stream-drive-folder-created = (folder) ~> + @add-folder folder, true + + @on-stream-drive-folder-updated = (folder) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + @remove-folder folder + else + @add-folder folder, true + + @onmousedown = (e) ~> + if (contains @refs.folders-container, e.target) or (contains @refs.files-container, e.target) + return true + + rect = @refs.main.get-bounding-client-rect! + + left = e.page-x + @refs.main.scroll-left - rect.left - window.page-x-offset + top = e.page-y + @refs.main.scroll-top - rect.top - window.page-y-offset + + move = (e) ~> + @refs.selection.style.display = \block + + cursor-x = e.page-x + @refs.main.scroll-left - rect.left - window.page-x-offset + cursor-y = e.page-y + @refs.main.scroll-top - rect.top - window.page-y-offset + w = cursor-x - left + h = cursor-y - top + + if w > 0 + @refs.selection.style.width = w + \px + @refs.selection.style.left = left + \px + else + @refs.selection.style.width = -w + \px + @refs.selection.style.left = cursor-x + \px + + if h > 0 + @refs.selection.style.height = h + \px + @refs.selection.style.top = top + \px + else + @refs.selection.style.height = -h + \px + @refs.selection.style.top = cursor-y + \px + + up = (e) ~> + document.document-element.remove-event-listener \mousemove move + document.document-element.remove-event-listener \mouseup up + + @refs.selection.style.display = \none + + document.document-element.add-event-listener \mousemove move + document.document-element.add-event-listener \mouseup up + + @path-oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + return false + + @ondragover = (e) ~> + e.prevent-default! + e.stop-propagation! + + # ドラッグ元が自分自身の所有するアイテムかどうか + if !@is-drag-source + # ドラッグされてきたものがファイルだったら + if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + @draghover = true + else + # 自分自身にはドロップさせない + e.data-transfer.drop-effect = \none + return false + + @ondragenter = (e) ~> + e.prevent-default! + if !@is-drag-source + @draghover = true + + @ondragleave = (e) ~> + @draghover = false + + @ondrop = (e) ~> + e.prevent-default! + e.stop-propagation! + + @draghover = false + + # ドロップされてきたものがファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @upload file, @folder + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + file = obj.id + if (@files.some (f) ~> f.id == file) + return false + @remove-file file + @api \drive/files/update do + file_id: file + folder_id: if @folder? then @folder.id else \null + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + # (ドライブの)フォルダーだったら + else if obj.type == \folder + folder = obj.id + # 移動先が自分自身ならreject + if @folder? and folder == @folder.id + return false + if (@folders.some (f) ~> f.id == folder) + return false + @remove-folder folder + @api \drive/folders/update do + folder_id: folder + parent_id: if @folder? then @folder.id else \null + .then ~> + # something + .catch (err) ~> + if err == 'detected-circular-definition' + @dialog do + '<i class="fa fa-exclamation-triangle"></i>操作を完了できません' + '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。' + [ + text: \OK + ] + + return false + + @oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + + ctx = document.body.append-child document.create-element \mk-drive-browser-base-contextmenu + ctx = riot.mount ctx, do + browser: @ + ctx = ctx.0 + ctx.open do + x: e.page-x - window.page-x-offset + y: e.page-y - window.page-y-offset + + return false + + @select-local-file = ~> + @refs.file-input.click! + + @create-folder = ~> + name <~ @input-dialog do + 'フォルダー作成' + 'フォルダー名' + null + + @api \drive/folders/create do + name: name + folder_id: if @folder? then @folder.id else undefined + .then (folder) ~> + @add-folder folder, true + @update! + .catch (err) ~> + console.error err + + @change-file-input = ~> + files = @refs.file-input.files + for i from 0 to files.length - 1 + file = files.item i + @upload file, @folder + + @upload = (file, folder) ~> + if folder? and typeof folder == \object + folder = folder.id + @refs.uploader.upload file, folder + + @get-selection = ~> + @files.filter (file) -> file._selected + + @new-window = (folder-id) ~> + browser = document.body.append-child document.create-element \mk-drive-browser-window + riot.mount browser, do + folder: folder-id + + @move = (target-folder) ~> + if target-folder? and typeof target-folder == \object + target-folder = target-folder.id + + if target-folder == null + @go-root! + return + + @loading = true + @update! + + @api \drive/folders/show do + folder_id: target-folder + .then (folder) ~> + @folder = folder + @hierarchy-folders = [] + + x = (f) ~> + @hierarchy-folders.unshift f + if f.parent? + x f.parent + + if folder.parent? + x folder.parent + + @update! + @load! + .catch (err, text-status) -> + console.error err + + @add-folder = (folder, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + return + + if (@folders.some (f) ~> f.id == folder.id) + exist = (@folders.map (f) -> f.id).index-of folder.id + @folders[exist] = folder + @update! + return + + if unshift + @folders.unshift folder + else + @folders.push folder + + @update! + + @add-file = (file, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + return + + if (@files.some (f) ~> f.id == file.id) + exist = (@files.map (f) -> f.id).index-of file.id + @files[exist] = file + @update! + return + + if unshift + @files.unshift file + else + @files.push file + + @update! + + @remove-folder = (folder) ~> + if typeof folder == \object + folder = folder.id + @folders = @folders.filter (f) -> f.id != folder + @update! + + @remove-file = (file) ~> + if typeof file == \object + file = file.id + @files = @files.filter (f) -> f.id != file + @update! + + @go-root = ~> + if @folder != null + @folder = null + @hierarchy-folders = [] + @update! + @load! + + @load = ~> + @folders = [] + @files = [] + @more-folders = false + @more-files = false + @loading = true + @update! + + load-folders = null + load-files = null + + folders-max = 30 + files-max = 30 + + # フォルダ一覧取得 + @api \drive/folders do + folder_id: if @folder? then @folder.id else null + limit: folders-max + 1 + .then (folders) ~> + if folders.length == folders-max + 1 + @more-folders = true + folders.pop! + load-folders := folders + complete! + .catch (err, text-status) ~> + console.error err + + # ファイル一覧取得 + @api \drive/files do + folder_id: if @folder? then @folder.id else null + limit: files-max + 1 + .then (files) ~> + if files.length == files-max + 1 + @more-files = true + files.pop! + load-files := files + complete! + .catch (err, text-status) ~> + console.error err + + flag = false + complete = ~> + if flag + load-folders.for-each (folder) ~> + @add-folder folder + load-files.for-each (file) ~> + @add-file file + @loading = false + @update! + else + flag := true + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/drive/file-contextmenu.tag b/src/web/app/desktop/tags/drive/file-contextmenu.tag new file mode 100644 index 0000000000..7d7dca6c92 --- /dev/null +++ b/src/web/app/desktop/tags/drive/file-contextmenu.tag @@ -0,0 +1,97 @@ +mk-drive-browser-file-contextmenu + mk-contextmenu@ctx: ul + li(onclick={ parent.rename }): p + i.fa.fa-i-cursor + | 名前を変更 + li(onclick={ parent.copy-url }): p + i.fa.fa-link + | URLをコピー + li: a(href={ parent.file.url + '?download' }, download={ parent.file.name }, onclick={ parent.download }) + i.fa.fa-download + | ダウンロード + li.separator + li(onclick={ parent.delete }): p + i.fa.fa-trash-o + | 削除 + li.separator + li.has-child + p + | その他... + i.fa.fa-caret-right + ul + li(onclick={ parent.set-avatar }): p + | アバターに設定 + li(onclick={ parent.set-banner }): p + | バナーに設定 + li(onclick={ parent.set-wallpaper }): p + | 壁紙に設定 + li.has-child + p + | アプリで開く... + i.fa.fa-caret-right + ul + li(onclick={ parent.add-app }): p + | アプリを追加... + +script. + @mixin \api + @mixin \i + @mixin \update-avatar + @mixin \update-banner + @mixin \update-wallpaper + @mixin \input-dialog + @mixin \NotImplementedException + + @browser = @opts.browser + @file = @opts.file + + @on \mount ~> + @refs.ctx.on \closed ~> + @trigger \closed + @unmount! + + @open = (pos) ~> + @refs.ctx.open pos + + @rename = ~> + @refs.ctx.close! + + name <~ @input-dialog do + 'ファイル名の変更' + '新しいファイル名を入力してください' + @file.name + + @api \drive/files/update do + file_id: @file.id + name: name + .then ~> + # something + .catch (err) ~> + console.error err + + @copy-url = ~> + @NotImplementedException! + + @download = ~> + @refs.ctx.close! + + @set-avatar = ~> + @refs.ctx.close! + @update-avatar @I, (i) ~> + @update-i i + , @file + + @set-banner = ~> + @refs.ctx.close! + @update-banner @I, (i) ~> + @update-i i + , @file + + @set-wallpaper = ~> + @refs.ctx.close! + @update-wallpaper @I, (i) ~> + @update-i i + , @file + + @add-app = ~> + @NotImplementedException! diff --git a/src/web/app/desktop/tags/drive/file.tag b/src/web/app/desktop/tags/drive/file.tag new file mode 100644 index 0000000000..1702bb6501 --- /dev/null +++ b/src/web/app/desktop/tags/drive/file.tag @@ -0,0 +1,207 @@ +mk-drive-browser-file(data-is-selected={ (file._selected || false).toString() }, data-is-contextmenu-showing={ is-contextmenu-showing.toString() }, onclick={ onclick }, oncontextmenu={ oncontextmenu }, draggable='true', ondragstart={ ondragstart }, ondragend={ ondragend }, title={ title }) + div.label(if={ I.avatar_id == file.id }) + img(src='/_/resources/label.svg') + p アバター + div.label(if={ I.banner_id == file.id }) + img(src='/_/resources/label.svg') + p バナー + div.label(if={ I.data.wallpaper == file.id }) + img(src='/_/resources/label.svg') + p 壁紙 + div.thumbnail: img(src={ file.url + '?thumbnail&size=128' }, alt='') + p.name + span { file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name } + span.ext(if={ file.name.lastIndexOf('.') != -1 }) { file.name.substr(file.name.lastIndexOf('.')) } + +style. + display block + margin 4px + padding 8px 0 0 0 + width 144px + height 180px + border-radius 4px + + &, * + cursor pointer + + &:hover + background rgba(0, 0, 0, 0.05) + + > .label + &:before + &:after + background #0b65a5 + + &:active + background rgba(0, 0, 0, 0.1) + + > .label + &:before + &:after + background #0b588c + + &[data-is-selected='true'] + background $theme-color + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + + > .label + &:before + &:after + display none + + > .name + color $theme-color-foreground + + &[data-is-contextmenu-showing='true'] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + > .label + position absolute + top 0 + left 0 + pointer-events none + + &:before + content "" + display block + position absolute + z-index 1 + top 0 + left 57px + width 28px + height 8px + background #0c7ac9 + + &:after + content "" + display block + position absolute + z-index 1 + top 57px + left 0 + width 8px + height 28px + background #0c7ac9 + + > img + position absolute + z-index 2 + top 0 + left 0 + + > p + position absolute + z-index 3 + top 19px + left -28px + width 120px + margin 0 + text-align center + line-height 28px + color #fff + transform rotate(-45deg) + + > .thumbnail + width 128px + height 128px + left 8px + + > img + display block + position absolute + top 0 + left 0 + right 0 + bottom 0 + margin auto + max-width 128px + max-height 128px + pointer-events none + + > .name + display block + margin 4px 0 0 0 + font-size 0.8em + text-align center + word-break break-all + color #444 + overflow hidden + + > .ext + opacity 0.5 + +script. + @mixin \i + @mixin \bytes-to-size + + @file = @opts.file + @browser = @parent + + @title = @file.name + '\n' + @file.type + ' ' + (@bytes-to-size @file.datasize) + + @is-contextmenu-showing = false + + @onclick = ~> + if @browser.multiple + if @file._selected? + @file._selected = !@file._selected + else + @file._selected = true + @browser.trigger \change-selection @browser.get-selection! + else + if @file._selected + @browser.trigger \selected @file + else + @browser.files.for-each (file) ~> + file._selected = false + @file._selected = true + @browser.trigger \change-selection @file + + @oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + + @is-contextmenu-showing = true + @update! + ctx = document.body.append-child document.create-element \mk-drive-browser-file-contextmenu + ctx = riot.mount ctx, do + browser: @browser + file: @file + ctx = ctx.0 + ctx.open do + x: e.page-x - window.page-x-offset + y: e.page-y - window.page-y-offset + ctx.on \closed ~> + @is-contextmenu-showing = false + @update! + return false + + @ondragstart = (e) ~> + e.data-transfer.effect-allowed = \move + e.data-transfer.set-data 'text' JSON.stringify do + type: \file + id: @file.id + file: @file + @is-dragging = true + + # 親ブラウザに対して、ドラッグが開始されたフラグを立てる + # (=あなたの子供が、ドラッグを開始しましたよ) + @browser.is-drag-source = true + + @ondragend = (e) ~> + @is-dragging = false + @browser.is-drag-source = false diff --git a/src/web/app/desktop/tags/drive/folder-contextmenu.tag b/src/web/app/desktop/tags/drive/folder-contextmenu.tag new file mode 100644 index 0000000000..67fb1047b7 --- /dev/null +++ b/src/web/app/desktop/tags/drive/folder-contextmenu.tag @@ -0,0 +1,62 @@ +mk-drive-browser-folder-contextmenu + mk-contextmenu@ctx: ul + li(onclick={ parent.move }): p + i.fa.fa-arrow-right + | このフォルダへ移動 + li(onclick={ parent.new-window }): p + i.fa.fa-share-square-o + | 新しいウィンドウで表示 + li.separator + li(onclick={ parent.rename }): p + i.fa.fa-i-cursor + | 名前を変更 + li.separator + li(onclick={ parent.delete }): p + i.fa.fa-trash-o + | 削除 + +script. + @mixin \api + @mixin \input-dialog + + @browser = @opts.browser + @folder = @opts.folder + + @open = (pos) ~> + @refs.ctx.open pos + + @refs.ctx.on \closed ~> + @trigger \closed + @unmount! + + @move = ~> + @browser.move @folder.id + @refs.ctx.close! + + @new-window = ~> + @browser.new-window @folder.id + @refs.ctx.close! + + @create-folder = ~> + @browser.create-folder! + @refs.ctx.close! + + @upload = ~> + @browser.select-lcoal-file! + @refs.ctx.close! + + @rename = ~> + @refs.ctx.close! + + name <~ @input-dialog do + 'フォルダ名の変更' + '新しいフォルダ名を入力してください' + @folder.name + + @api \drive/folders/update do + folder_id: @folder.id + name: name + .then ~> + # something + .catch (err) ~> + console.error err diff --git a/src/web/app/desktop/tags/drive/folder.tag b/src/web/app/desktop/tags/drive/folder.tag new file mode 100644 index 0000000000..0f3b06d543 --- /dev/null +++ b/src/web/app/desktop/tags/drive/folder.tag @@ -0,0 +1,183 @@ +mk-drive-browser-folder(data-is-contextmenu-showing={ is-contextmenu-showing.toString() }, data-draghover={ draghover.toString() }, onclick={ onclick }, onmouseover={ onmouseover }, onmouseout={ onmouseout }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }, oncontextmenu={ oncontextmenu }, draggable='true', ondragstart={ ondragstart }, ondragend={ ondragend }, title={ title }) + p.name + i.fa.fa-fw(class={ fa-folder-o: !hover, fa-folder-open-o: hover }) + | { folder.name } + +style. + display block + margin 4px + padding 8px + width 144px + height 64px + background lighten($theme-color, 95%) + border-radius 4px + + &, * + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 90%) + + &:active + background lighten($theme-color, 85%) + + &[data-is-contextmenu-showing='true'] + &[data-draghover='true'] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + &[data-draghover='true'] + background lighten($theme-color, 90%) + + > .name + margin 0 + font-size 0.9em + color darken($theme-color, 30%) + + > i + margin-right 4px + margin-left 2px + text-align left + +script. + @mixin \api + @mixin \dialog + + @folder = @opts.folder + @browser = @parent + + @title = @folder.name + @hover = false + @draghover = false + @is-contextmenu-showing = false + + @onclick = ~> + @browser.move @folder + + @onmouseover = ~> + @hover = true + + @onmouseout = ~> + @hover = false + + @ondragover = (e) ~> + e.prevent-default! + e.stop-propagation! + + # 自分自身がドラッグされていない場合 + if !@is-dragging + # ドラッグされてきたものがファイルだったら + if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + else + # 自分自身にはドロップさせない + e.data-transfer.drop-effect = \none + return false + + @ondragenter = ~> + if !@is-dragging + @draghover = true + + @ondragleave = ~> + @draghover = false + + @ondrop = (e) ~> + e.stop-propagation! + @draghover = false + + # ファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @browser.upload file, @folder + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + file = obj.id + @browser.remove-file file + @api \drive/files/update do + file_id: file + folder_id: @folder.id + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + # (ドライブの)フォルダーだったら + else if obj.type == \folder + folder = obj.id + # 移動先が自分自身ならreject + if folder == @folder.id + return false + @browser.remove-folder folder + @api \drive/folders/update do + folder_id: folder + parent_id: @folder.id + .then ~> + # something + .catch (err) ~> + if err == 'detected-circular-definition' + @dialog do + '<i class="fa fa-exclamation-triangle"></i>操作を完了できません' + '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。' + [ + text: \OK + ] + + return false + + @ondragstart = (e) ~> + e.data-transfer.effect-allowed = \move + e.data-transfer.set-data 'text' JSON.stringify do + type: \folder + id: @folder.id + @is-dragging = true + + # 親ブラウザに対して、ドラッグが開始されたフラグを立てる + # (=あなたの子供が、ドラッグを開始しましたよ) + @browser.is-drag-source = true + + @ondragend = (e) ~> + @is-dragging = false + @browser.is-drag-source = false + + @oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + + @is-contextmenu-showing = true + @update! + ctx = document.body.append-child document.create-element \mk-drive-browser-folder-contextmenu + ctx = riot.mount ctx, do + browser: @browser + folder: @folder + ctx = ctx.0 + ctx.open do + x: e.page-x - window.page-x-offset + y: e.page-y - window.page-y-offset + ctx.on \closed ~> + @is-contextmenu-showing = false + @update! + + return false diff --git a/src/web/app/desktop/tags/drive/nav-folder.tag b/src/web/app/desktop/tags/drive/nav-folder.tag new file mode 100644 index 0000000000..398a26a80b --- /dev/null +++ b/src/web/app/desktop/tags/drive/nav-folder.tag @@ -0,0 +1,96 @@ +mk-drive-browser-nav-folder(data-draghover={ draghover }, onclick={ onclick }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }) + i.fa.fa-cloud(if={ folder == null }) + span { folder == null ? 'ドライブ' : folder.name } + +style. + &[data-draghover] + background #eee + +script. + @mixin \api + + # Riotのバグでnullを渡しても""になる + # https://github.com/riot/riot/issues/2080 + #@folder = @opts.folder + @folder = if @opts.folder? and @opts.folder != '' then @opts.folder else null + @browser = @parent + + @hover = false + + @onclick = ~> + @browser.move @folder + + @onmouseover = ~> + @hover = true + + @onmouseout = ~> + @hover = false + + @ondragover = (e) ~> + e.prevent-default! + e.stop-propagation! + + # このフォルダがルートかつカレントディレクトリならドロップ禁止 + if @folder == null and @browser.folder == null + e.data-transfer.drop-effect = \none + # ドラッグされてきたものがファイルだったら + else if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + return false + + @ondragenter = ~> + if @folder != null or @browser.folder != null + @draghover = true + + @ondragleave = ~> + if @folder != null or @browser.folder != null + @draghover = false + + @ondrop = (e) ~> + e.stop-propagation! + @draghover = false + + # ファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @browser.upload file, @folder + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + file = obj.id + @browser.remove-file file + @api \drive/files/update do + file_id: file + folder_id: if @folder? then @folder.id else null + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + # (ドライブの)フォルダーだったら + else if obj.type == \folder + folder = obj.id + # 移動先が自分自身ならreject + if @folder? and folder == @folder.id + return false + @browser.remove-folder folder + @api \drive/folders/update do + folder_id: folder + parent_id: if @folder? then @folder.id else null + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + return false diff --git a/src/web/app/desktop/tags/ellipsis-icon.tag b/src/web/app/desktop/tags/ellipsis-icon.tag new file mode 100644 index 0000000000..5d18bc0473 --- /dev/null +++ b/src/web/app/desktop/tags/ellipsis-icon.tag @@ -0,0 +1,34 @@ +mk-ellipsis-icon + div + div + div + +style. + display block + width 70px + margin 0 auto + text-align center + + > div + display inline-block + width 18px + height 18px + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + animation bounce 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + margin 0 6px + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes bounce + 0%, 80%, 100% + transform scale(0) + 40% + transform scale(1) diff --git a/src/web/app/desktop/tags/follow-button.tag b/src/web/app/desktop/tags/follow-button.tag new file mode 100644 index 0000000000..41bd1f0e12 --- /dev/null +++ b/src/web/app/desktop/tags/follow-button.tag @@ -0,0 +1,127 @@ +mk-follow-button + button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following }, + onclick={ onclick }, + disabled={ wait }, + title={ user.is_following ? 'フォロー解除' : 'フォローする' }) + i.fa.fa-minus(if={ !wait && user.is_following }) + i.fa.fa-plus(if={ !wait && !user.is_following }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait }) + div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw + +style. + display block + + > button + > .init + display block + cursor pointer + padding 0 + margin 0 + width 32px + height 32px + font-size 1em + outline none + border-radius 4px + + * + pointer-events none + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &.follow + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.unfollow + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &.wait + cursor wait !important + opacity 0.7 + +script. + @mixin \api + @mixin \is-promise + @mixin \stream + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @init = true + @wait = false + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @init = false + @update! + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @on-stream-follow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @on-stream-unfollow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @onclick = ~> + @wait = true + if @user.is_following + @api \following/delete do + user_id: @user.id + .then ~> + @user.is_following = false + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! + else + @api \following/create do + user_id: @user.id + .then ~> + @user.is_following = true + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! diff --git a/src/web/app/desktop/tags/following-setuper.tag b/src/web/app/desktop/tags/following-setuper.tag new file mode 100644 index 0000000000..9b75a251e0 --- /dev/null +++ b/src/web/app/desktop/tags/following-setuper.tag @@ -0,0 +1,163 @@ +mk-following-setuper + p.title 気になるユーザーをフォロー: + div.users(if={ !loading && users.length > 0 }) + div.user(each={ users }) + a.avatar-anchor(href={ CONFIG.url + '/' + username }) + img.avatar(src={ avatar_url + '?thumbnail&size=42' }, alt='', data-user-preview={ id }) + div.body + a.name(href={ CONFIG.url + '/' + username }, target='_blank', data-user-preview={ id }) { name } + p.username @{ username } + mk-follow-button(user={ this }) + p.empty(if={ !loading && users.length == 0 }) + | おすすめのユーザーは見つかりませんでした。 + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + a.refresh(onclick={ refresh }) もっと見る + button.close(onclick={ close }, title='閉じる'): i.fa.fa-times + +style. + display block + padding 24px + background #fff + + > .title + margin 0 0 12px 0 + font-size 1em + font-weight bold + color #888 + + > .users + &:after + content "" + display block + clear both + + > .user + padding 16px + width 238px + float left + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + margin 0 + font-size 15px + line-height 16px + color #ccc + + > mk-follow-button + position absolute + top 16px + right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + > .refresh + display block + margin 0 8px 0 0 + text-align right + font-size 0.9em + color #999 + + > .close + cursor pointer + display block + position absolute + top 6px + right 6px + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + background transparent + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px + +script. + @mixin \api + @mixin \user-preview + + @users = null + @loading = true + + @limit = 6users + @page = 0 + + @on \mount ~> + @load! + + @load = ~> + @loading = true + @users = null + @update! + + @api \users/recommendation do + limit: @limit + offset: @limit * @page + .then (users) ~> + @loading = false + @users = users + @update! + .catch (err, text-status) -> + console.error err + + @refresh = ~> + if @users.length < @limit + @page = 0 + else + @page++ + @load! + + @close = ~> + @unmount! diff --git a/src/web/app/desktop/tags/go-top.tag b/src/web/app/desktop/tags/go-top.tag new file mode 100644 index 0000000000..a11f4a3640 --- /dev/null +++ b/src/web/app/desktop/tags/go-top.tag @@ -0,0 +1,15 @@ +mk-go-top + button.hidden(title='一番上へ') + i.fa.fa-angle-up + +script. + + window.add-event-listener \load @on-scroll + window.add-event-listener \scroll @on-scroll + window.add-event-listener \resize @on-scroll + + @on-scroll = ~> + if $ window .scroll-top! > 500px + @remove-class \hidden + else + @add-class \hidden diff --git a/src/web/app/desktop/tags/home-widgets/broadcast.tag b/src/web/app/desktop/tags/home-widgets/broadcast.tag new file mode 100644 index 0000000000..43f1251643 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/broadcast.tag @@ -0,0 +1,75 @@ +mk-broadcast-home-widget + div.icon + svg(height='32', version='1.1', viewBox='0 0 32 32', width='32') + path.tower(d='M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z') + path.wave.a(d='M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z') + path.wave.b(d='M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z') + path.wave.c(d='M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z') + path.wave.d(d='M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z') + + h1 開発者募集中! + p: a(href='https://github.com/syuilo/misskey', target='_blank') Misskeyはオープンソースで開発されています。Webのリポジトリはこちら + +style. + display block + padding 10px 10px 10px 50px + background transparent + border-color #4078c0 !important + + &:after + content "" + display block + clear both + + > .icon + display block + float left + margin-left -40px + + > svg + fill currentColor + color #4078c0 + + > .wave + opacity 1 + + &.a + animation wave 20s ease-in-out 2.1s infinite + &.b + animation wave 20s ease-in-out 2s infinite + &.c + animation wave 20s ease-in-out 2s infinite + &.d + animation wave 20s ease-in-out 2.1s infinite + + @keyframes wave + 0% + opacity 1 + 1.5% + opacity 0 + 3.5% + opacity 0 + 5% + opacity 1 + 6.5% + opacity 0 + 8.5% + opacity 0 + 10% + opacity 1 + + > h1 + margin 0 + font-size 0.95em + font-weight normal + color #4078c0 + + > p + display block + z-index 1 + margin 0 + font-size 0.7em + color #555 + + a + color #555 diff --git a/src/web/app/desktop/tags/home-widgets/calendar.tag b/src/web/app/desktop/tags/home-widgets/calendar.tag new file mode 100644 index 0000000000..26cea1c699 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/calendar.tag @@ -0,0 +1,147 @@ +mk-calendar-home-widget(data-special={ special }) + div.calendar(data-is-holiday={ is-holiday }) + p.month-and-year + span.year { year }年 + span.month { month }月 + p.day { day }日 + p.week-day { week-day }曜日 + div.info + div + p + | 今日: + b { day-p.to-fixed(1) }% + div.meter + div.val(style={ 'width:' + day-p + '%' }) + + div + p + | 今月: + b { month-p.to-fixed(1) }% + div.meter + div.val(style={ 'width:' + month-p + '%' }) + + div + p + | 今年: + b { year-p.to-fixed(1) }% + div.meter + div.val(style={ 'width:' + year-p + '%' }) + +style. + display block + padding 16px 0 + color #777 + background #fff + + &[data-special='on-new-years-day'] + border-color #ef95a0 !important + + &:after + content "" + display block + clear both + + > .calendar + float left + width 60% + text-align center + + &[data-is-holiday] + > .day + color #ef95a0 + + > p + margin 0 + line-height 18px + font-size 14px + + > span + margin 0 4px + + > .day + margin 10px 0 + line-height 32px + font-size 28px + + > .info + display block + float left + width 40% + padding 0 16px 0 0 + + > div + margin-bottom 8px + + &:last-child + margin-bottom 4px + + > p + margin 0 0 2px 0 + font-size 12px + line-height 18px + color #888 + + > b + margin-left 2px + + > .meter + width 100% + overflow hidden + background #eee + border-radius 8px + + > .val + height 4px + background $theme-color + + &:nth-child(1) + > .meter > .val + background #f7796c + + &:nth-child(2) + > .meter > .val + background #a1de41 + + &:nth-child(3) + > .meter > .val + background #41ddde + +script. + @draw = ~> + now = new Date! + nd = now.get-date! + nm = now.get-month! + ny = now.get-full-year! + + @year = ny + @month = nm + 1 + @day = nd + @week-day = [\日 \月 \火 \水 \木 \金 \土][now.get-day!] + + @day-numer = (now - (new Date ny, nm, nd)) + @day-denom = 1000ms * 60s * 60m * 24h + @month-numer = (now - (new Date ny, nm, 1)) + @month-denom = (new Date ny, nm + 1, 1) - (new Date ny, nm, 1) + @year-numer = (now - (new Date ny, 0, 0)) + @year-denom = (new Date ny + 1, 0, 0) - (new Date ny, 0, 0) + + @day-p = @day-numer / @day-denom * 100 + @month-p = @month-numer / @month-denom * 100 + @year-p = @year-numer / @year-denom * 100 + + @is-holiday = + (now.get-day! == 0 or now.get-day! == 6) + + @special = + | nm == 0 and nd == 1 => \on-new-years-day + | _ => false + + @update! + + @draw! + + @on \mount ~> + @clock = set-interval @draw, 1000ms + + @on \unmount ~> + clear-interval @clock diff --git a/src/web/app/desktop/tags/home-widgets/donation.tag b/src/web/app/desktop/tags/home-widgets/donation.tag new file mode 100644 index 0000000000..a41bd9f8a4 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/donation.tag @@ -0,0 +1,37 @@ +mk-donation-home-widget + article + h1 + i.fa.fa-heart + | 寄付のお願い + p + | Misskeyの運営にはドメイン、サーバー等のコストが掛かります。 + | Misskeyは広告を掲載したりしないため、 収入を皆様からの寄付に頼っています。 + | もしご興味があれば、 + a(href='/syuilo', data-user-preview='@syuilo') @syuilo + | までご連絡ください。ご協力ありがとうございます。 + +style. + display block + background #fff + border-color #ead8bb !important + + > article + padding 20px + + > h1 + margin 0 0 5px 0 + font-size 1em + color #888 + + > i + margin-right 0.25em + + > p + display block + z-index 1 + margin 0 + font-size 0.8em + color #999 + +script. + @mixin \user-preview diff --git a/src/web/app/desktop/tags/home-widgets/mentions.tag b/src/web/app/desktop/tags/home-widgets/mentions.tag new file mode 100644 index 0000000000..0f0cd0269d --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/mentions.tag @@ -0,0 +1,117 @@ +mk-mentions-home-widget + header + span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) すべて + span(data-is-active={ mode == 'following' }, onclick={ set-mode.bind(this, 'following') }) フォロー中 + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-comments-o + span(if={ mode == 'all' }) あなた宛ての投稿はありません。 + span(if={ mode == 'following' }) あなたがフォローしているユーザーからの言及はありません。 + mk-timeline@timeline + <yield to="footer"> + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + </yield> + +style. + display block + background #fff + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \i + @mixin \api + + @is-loading = true + @is-empty = false + @more-loading = false + @mode = \all + + @on \mount ~> + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @fetch ~> + @trigger \loaded + + @on \unmount ~> + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @fetch = (cb) ~> + @api \posts/mentions do + following: @mode == \following + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + if cb? then cb! + .catch (err) ~> + console.error err + if cb? then cb! + + @more = ~> + if @more-loading or @is-loading or @refs.timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \posts/mentions do + following: @mode == \following + max_id: @refs.timeline.tail!.id + .then (posts) ~> + @more-loading = false + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 8 + @more! + + @set-mode = (mode) ~> + @update do + mode: mode + @fetch! diff --git a/src/web/app/desktop/tags/home-widgets/nav.tag b/src/web/app/desktop/tags/home-widgets/nav.tag new file mode 100644 index 0000000000..5b8e7e1b3c --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/nav.tag @@ -0,0 +1,23 @@ +mk-nav-home-widget + a(href={ CONFIG.urls.about }) Misskeyについて + i ・ + a(href={ CONFIG.urls.about + '/status' }) ステータス + i ・ + a(href='https://github.com/syuilo/misskey') リポジトリ + i ・ + a(href={ CONFIG.urls.dev }) 開発者 + i ・ + a(href='https://twitter.com/misskey_xyz', target='_blank') Follow us on <i class="fa fa-twitter"></i> + +style. + display block + padding 16px + font-size 12px + color #aaa + background #fff + + a + color #999 + + i + color #ccc diff --git a/src/web/app/desktop/tags/home-widgets/notifications.tag b/src/web/app/desktop/tags/home-widgets/notifications.tag new file mode 100644 index 0000000000..588a765d02 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/notifications.tag @@ -0,0 +1,49 @@ +mk-notifications-home-widget + p.title + i.fa.fa-bell-o + | 通知 + button(onclick={ settings }, title='通知の設定'): i.fa.fa-cog + mk-notifications + +style. + display block + background #fff + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > mk-notifications + max-height 300px + overflow auto + +script. + @settings = ~> + w = riot.mount document.body.append-child document.create-element \mk-settings-window .0 + w.switch \notification diff --git a/src/web/app/desktop/tags/home-widgets/photo-stream.tag b/src/web/app/desktop/tags/home-widgets/photo-stream.tag new file mode 100644 index 0000000000..b972706574 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/photo-stream.tag @@ -0,0 +1,86 @@ +mk-photo-stream-home-widget + p.title + i.fa.fa-camera + | フォトストリーム + p.initializing(if={ initializing }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + div.stream(if={ !initializing && images.length > 0 }) + virtual(each={ image in images }) + div.img(style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }) + p.empty(if={ !initializing && images.length == 0 }) + | 写真はありません + +style. + display block + background #fff + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + > .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + background-clip content-box + border solid 2px transparent + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \stream + + @images = [] + @initializing = true + + @on \mount ~> + @stream.on \drive_file_created @on-stream-drive-file-created + + @api \drive/stream do + type: 'image/*' + limit: 9images + .then (images) ~> + @initializing = false + @images = images + @update! + + @on \unmount ~> + @stream.off \drive_file_created @on-stream-drive-file-created + + @on-stream-drive-file-created = (file) ~> + if /^image\/.+$/.test file.type + @images.unshift file + if @images.length > 9 + @images.pop! + @update! diff --git a/src/web/app/desktop/tags/home-widgets/profile.tag b/src/web/app/desktop/tags/home-widgets/profile.tag new file mode 100644 index 0000000000..ae8d43c645 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/profile.tag @@ -0,0 +1,55 @@ +mk-profile-home-widget + div.banner(style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=256)' : '' }, onclick={ set-banner }) + img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, onclick={ set-avatar }, alt='avatar', data-user-preview={ I.id }) + a.name(href={ CONFIG.url + '/' + I.username }) { I.name } + p.username @{ I.username } + +style. + display block + background #fff + + > .banner + height 100px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + top 76px + left 16px + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + vertical-align bottom + + > .name + display block + margin 10px 0 0 92px + line-height 16px + font-weight bold + color #555 + + > .username + display block + margin 4px 0 8px 92px + line-height 16px + font-size 0.9em + color #999 + +script. + @mixin \i + @mixin \user-preview + @mixin \update-avatar + @mixin \update-banner + + @set-avatar = ~> + @update-avatar @I, (i) ~> + @update-i i + + @set-banner = ~> + @update-banner @I, (i) ~> + @update-i i diff --git a/src/web/app/desktop/tags/home-widgets/rss-reader.tag b/src/web/app/desktop/tags/home-widgets/rss-reader.tag new file mode 100644 index 0000000000..b9095fb2d2 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/rss-reader.tag @@ -0,0 +1,94 @@ +mk-rss-reader-home-widget + p.title + i.fa.fa-rss-square + | RSS + button(onclick={ settings }, title='設定'): i.fa.fa-cog + div.feed(if={ !initializing }) + virtual(each={ item in items }) + a(href={ item.link }, target='_blank') { item.title } + p.initializing(if={ initializing }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > button + position absolute + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .feed + padding 12px 16px + font-size 0.9em + + > a + display block + padding 4px 0 + color #666 + border-bottom dashed 1px #eee + + &:last-child + border-bottom none + + > .initializing + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \NotImplementedException + + @url = 'http://news.yahoo.co.jp/pickup/rss.xml' + @items = [] + @initializing = true + + @on \mount ~> + @fetch! + @clock = set-interval @fetch, 60000ms + + @on \unmount ~> + clear-interval @clock + + @fetch = ~> + @api CONFIG.url + '/api:rss' do + url: @url + .then (feed) ~> + @items = feed.rss.channel.item + @initializing = false + @update! + .catch (err) -> + console.error err + + @settings = ~> + @NotImplementedException! diff --git a/src/web/app/desktop/tags/home-widgets/timeline.tag b/src/web/app/desktop/tags/home-widgets/timeline.tag new file mode 100644 index 0000000000..ea2746d1ed --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/timeline.tag @@ -0,0 +1,113 @@ +mk-timeline-home-widget + mk-following-setuper(if={ no-following }) + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-comments-o + | 自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。 + mk-timeline@timeline + <yield to="footer"> + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + </yield> + +style. + display block + background #fff + + > mk-following-setuper + border-bottom solid 1px #eee + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \i + @mixin \api + @mixin \stream + + @is-loading = true + @is-empty = false + @more-loading = false + @no-following = @I.following_count == 0 + + @on \mount ~> + @stream.on \post @on-stream-post + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @load ~> + @trigger \loaded + + @on \unmount ~> + @stream.off \post @on-stream-post + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @load = (cb) ~> + @api \posts/timeline + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + if cb? then cb! + .catch (err) ~> + console.error err + if cb? then cb! + + @more = ~> + if @more-loading or @is-loading or @refs.timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \posts/timeline do + max_id: @refs.timeline.tail!.id + .then (posts) ~> + @more-loading = false + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-stream-post = (post) ~> + @is-empty = false + @update! + @refs.timeline.add-post post + + @on-stream-follow = ~> + @load! + + @on-stream-unfollow = ~> + @load! + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 8 + @more! diff --git a/src/web/app/desktop/tags/home-widgets/tips.tag b/src/web/app/desktop/tags/home-widgets/tips.tag new file mode 100644 index 0000000000..9c1aa33ec0 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/tips.tag @@ -0,0 +1,70 @@ +mk-tips-home-widget + p@tip + i.fa.fa-lightbulb-o + span@text + +style. + display block + background transparent !important + border none !important + overflow visible !important + + > p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.7em + color #999 + + > i + margin-right 4px + + kbd + display inline + padding 0 6px + margin 0 2px + font-size 1em + font-family inherit + border solid 1px #999 + border-radius 2px + +script. + @tips = [ + '<kbd>t</kbd>でタイムラインにフォーカスできます' + '<kbd>p</kbd>または<kbd>n</kbd>で投稿フォームを開きます' + '投稿フォームにはファイルをドラッグ&ドロップできます' + '投稿フォームにクリップボードにある画像データをペーストできます' + 'ドライブにファイルをドラッグ&ドロップしてアップロードできます' + 'ドライブでファイルをドラッグしてフォルダ移動できます' + 'ドライブでフォルダをドラッグしてフォルダ移動できます' + 'ホームをカスタマイズできます(準備中)' + 'MisskeyはMIT Licenseです' + ] + + @on \mount ~> + @set! + @clock = set-interval @change, 20000ms + + @on \unmount ~> + clear-interval @clock + + @set = ~> + @refs.text.innerHTML = @tips[Math.floor Math.random! * @tips.length] + @update! + + @change = ~> + Velocity @refs.tip, { + opacity: 0 + } { + duration: 500ms + easing: \linear + complete: @set + } + + Velocity @refs.tip, { + opacity: 1 + } { + duration: 500ms + easing: \linear + } diff --git a/src/web/app/desktop/tags/home-widgets/user-recommendation.tag b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag new file mode 100644 index 0000000000..bfb90da065 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag @@ -0,0 +1,154 @@ +mk-user-recommendation-home-widget + p.title + i.fa.fa-users + | おすすめユーザー + button(onclick={ refresh }, title='他を見る'): i.fa.fa-refresh + div.user(if={ !loading && users.length != 0 }, each={ _user in users }) + a.avatar-anchor(href={ CONFIG.url + '/' + _user.username }) + img.avatar(src={ _user.avatar_url + '?thumbnail&size=42' }, alt='', data-user-preview={ _user.id }) + div.body + a.name(href={ CONFIG.url + '/' + _user.username }, data-user-preview={ _user.id }) { _user.name } + p.username @{ _user.username } + mk-follow-button(user={ _user }) + p.empty(if={ !loading && users.length == 0 }) + | いません! + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > i + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .user + padding 16px + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + display block + margin 0 + font-size 15px + line-height 16px + color #ccc + + > mk-follow-button + position absolute + top 16px + right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \user-preview + + @users = null + @loading = true + + @limit = 3users + @page = 0 + + @on \mount ~> + @fetch! + @clock = set-interval ~> + if @users.length < @limit + @fetch true + , 60000ms + + @on \unmount ~> + clear-interval @clock + + @fetch = (quiet = false) ~> + @loading = true + @users = null + if not quiet then @update! + @api \users/recommendation do + limit: @limit + offset: @limit * @page + .then (users) ~> + @loading = false + @users = users + @update! + .catch (err, text-status) -> + console.error err + + @refresh = ~> + if @users.length < @limit + @page = 0 + else + @page++ + @fetch! diff --git a/src/web/app/desktop/tags/home.tag b/src/web/app/desktop/tags/home.tag new file mode 100644 index 0000000000..1ae856a6b2 --- /dev/null +++ b/src/web/app/desktop/tags/home.tag @@ -0,0 +1,86 @@ +mk-home + div.main + div.left@left + main + mk-timeline-home-widget@tl(if={ mode == 'timeline' }) + mk-mentions-home-widget@tl(if={ mode == 'mentions' }) + div.right@right + mk-detect-slow-internet-connection-notice + +style. + display block + + > .main + margin 0 auto + max-width 1200px + + &:after + content "" + display block + clear both + + > * + float left + + > * + display block + //border solid 1px #eaeaea + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + &:not(:last-child) + margin-bottom 16px + + > main + padding 16px + width calc(100% - 275px * 2) + + > *:not(main) + width 275px + + > .left + padding 16px 0 16px 16px + + > .right + padding 16px 16px 16px 0 + + @media (max-width 1100px) + > *:not(main) + display none + + > main + float none + width 100% + max-width 700px + margin 0 auto + +script. + @mixin \i + @mode = @opts.mode || \timeline + + # https://github.com/riot/riot/issues/2080 + if @mode == '' then @mode = \timeline + + @home = [] + + @on \mount ~> + @refs.tl.on \loaded ~> + @trigger \loaded + + @I.data.home.for-each (widget) ~> + try + el = document.create-element \mk- + widget.name + \-home-widget + switch widget.place + | \left => @refs.left.append-child el + | \right => @refs.right.append-child el + @home.push (riot.mount el, do + id: widget.id + data: widget.data + .0) + catch e + # noop + + @on \unmount ~> + @home.for-each (widget) ~> + widget.unmount! diff --git a/src/web/app/desktop/tags/image-dialog.tag b/src/web/app/desktop/tags/image-dialog.tag new file mode 100644 index 0000000000..6a3885d7c7 --- /dev/null +++ b/src/web/app/desktop/tags/image-dialog.tag @@ -0,0 +1,73 @@ +mk-image-dialog + div.bg@bg(onclick={ close }) + img@img(src={ image.url }, alt={ image.name }, title={ image.name }, onclick={ close }) + +style. + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + opacity 0 + + > .bg + display block + position fixed + z-index 1 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + + > img + position fixed + z-index 2 + top 0 + right 0 + bottom 0 + left 0 + max-width 100% + max-height 100% + margin auto + cursor zoom-out + +script. + @image = @opts.image + + @on \mount ~> + Velocity @root, { + opacity: 1 + } { + duration: 100ms + easing: \linear + } + + #Velocity @img, { + # scale: 1 + # opacity: 1 + #} { + # duration: 200ms + # easing: \ease-out + #} + + @close = ~> + Velocity @root, { + opacity: 0 + } { + duration: 100ms + easing: \linear + complete: ~> @unmount! + } + + #Velocity @img, { + # scale: 0.9 + # opacity: 0 + #} { + # duration: 200ms + # easing: \ease-in + # complete: ~> + # @unmount! + #} diff --git a/src/web/app/desktop/tags/images-viewer.tag b/src/web/app/desktop/tags/images-viewer.tag new file mode 100644 index 0000000000..a9939d67c4 --- /dev/null +++ b/src/web/app/desktop/tags/images-viewer.tag @@ -0,0 +1,43 @@ +mk-images-viewer + div.image@view(onmousemove={ mousemove }, style={ 'background-image: url(' + image.url + '?thumbnail' }, onclick={ click }) + img(src={ image.url + '?thumbnail&size=512' }, alt={ image.name }, title={ image.name }) + +style. + display block + padding 8px + overflow hidden + box-shadow 0 0 4px rgba(0, 0, 0, 0.2) + border-radius 4px + + > .image + cursor zoom-in + + > img + display block + max-height 256px + max-width 100% + margin 0 auto + + &:hover + > img + visibility hidden + + &:not(:hover) + background-image none !important + +script. + @images = @opts.images + @image = @images.0 + + @mousemove = (e) ~> + rect = @refs.view.get-bounding-client-rect! + mouse-x = e.client-x - rect.left + mouse-y = e.client-y - rect.top + xp = mouse-x / @refs.view.offset-width * 100 + yp = mouse-y / @refs.view.offset-height * 100 + @refs.view.style.background-position = xp + '% ' + yp + '%' + + @click = ~> + dialog = document.body.append-child document.create-element \mk-image-dialog + riot.mount dialog, do + image: @image diff --git a/src/web/app/desktop/tags/input-dialog.tag b/src/web/app/desktop/tags/input-dialog.tag new file mode 100644 index 0000000000..62ec4f5177 --- /dev/null +++ b/src/web/app/desktop/tags/input-dialog.tag @@ -0,0 +1,156 @@ +mk-input-dialog + mk-window@window(is-modal={ true }, width={ '500px' }) + <yield to="header"> + i.fa.fa-i-cursor + | { parent.title } + </yield> + <yield to="content"> + div.body + input@text(oninput={ parent.update }, onkeydown={ parent.on-keydown }, placeholder={ parent.placeholder }) + div.action + button.cancel(onclick={ parent.cancel }) キャンセル + button.ok(disabled={ !parent.allow-empty && refs.text.value.length == 0 }, onclick={ parent.ok }) 決定 + </yield> + +style. + display block + + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > .body + padding 16px + + > input + display block + padding 8px + margin 0 + width 100% + max-width 100% + min-width 100% + font-size 1em + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + > .action + height 72px + background lighten($theme-color, 95%) + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +script. + @done = false + + @title = @opts.title + @placeholder = @opts.placeholder + @default = @opts.default + @allow-empty = if @opts.allow-empty? then @opts.allow-empty else true + + @on \mount ~> + @text = @refs.window.refs.text + if @default? + @text.value = @default + @text.focus! + + @refs.window.on \closing ~> + if @done + @opts.on-ok @text.value + else + if @opts.on-cancel? + @opts.on-cancel! + + @refs.window.on \closed ~> + @unmount! + + @cancel = ~> + @done = false + @refs.window.close! + + @ok = ~> + if not @allow-empty and @text.value == '' then return + @done = true + @refs.window.close! + + @on-keydown = (e) ~> + if e.which == 13 # Enter + e.prevent-default! + e.stop-propagation! + @ok! diff --git a/src/web/app/desktop/tags/list-user.tag b/src/web/app/desktop/tags/list-user.tag new file mode 100644 index 0000000000..1058de22e0 --- /dev/null +++ b/src/web/app/desktop/tags/list-user.tag @@ -0,0 +1,100 @@ +mk-list-user + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.main + header + div.left + a.name(href={ CONFIG.url + '/' + user.username }) + | { user.name } + span.username + | @{ user.username } + div.body + p.followed(if={ user.is_followed }) フォローされています + div.bio { user.bio } + mk-follow-button(user={ user }) + +style. + display block + margin 0 + padding 16px + font-size 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + margin-bottom 2px + white-space nowrap + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .body + > .followed + display inline-block + margin 0 0 4px 0 + padding 2px 8px + vertical-align top + font-size 10px + color #71afc7 + background #eefaff + border-radius 4px + + > .bio + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + + > mk-follow-button + position absolute + top 16px + right 16px + +script. + @user = @opts.user diff --git a/src/web/app/desktop/tags/log-window.tag b/src/web/app/desktop/tags/log-window.tag new file mode 100644 index 0000000000..6dabc4de34 --- /dev/null +++ b/src/web/app/desktop/tags/log-window.tag @@ -0,0 +1,20 @@ +mk-log-window + mk-window@window(width={ '600px' }, height={ '400px' }) + <yield to="header"> + i.fa.fa-terminal + | Log + </yield> + <yield to="content"> + mk-log + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + +script. + @on \mount ~> + @refs.window.on \closed ~> + @unmount! diff --git a/src/web/app/desktop/tags/log.tag b/src/web/app/desktop/tags/log.tag new file mode 100644 index 0000000000..20e5f8f699 --- /dev/null +++ b/src/web/app/desktop/tags/log.tag @@ -0,0 +1,62 @@ +mk-log + header + button.follow(class={ following: following }, onclick={ follow }) Follow + div.logs@logs + code(each={ logs }) + span.date { date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds() } + span.message { message } + +style. + display block + height 100% + color #fff + background #000 + + > header + height 32px + background #343a42 + + > button + line-height 32px + + > .follow + position absolute + top 0 + right 0 + + &.following + color #ff0 + + > .logs + height calc(100% - 32px) + overflow auto + + > code + display block + padding 4px 8px + + &:hover + background rgba(#fff, 0.15) + + > .date + margin-right 8px + opacity 0.5 + +script. + @mixin \log + + @following = true + + @on \mount ~> + @log-event.on \log @on-log + + @on \unmount ~> + @log-event.off \log @on-log + + @follow = ~> + @following = true + + @on-log = ~> + @update! + if @following + @refs.logs.scroll-top = @refs.logs.scroll-height diff --git a/src/web/app/desktop/tags/messaging/form.tag b/src/web/app/desktop/tags/messaging/form.tag new file mode 100644 index 0000000000..12eb0cb40f --- /dev/null +++ b/src/web/app/desktop/tags/messaging/form.tag @@ -0,0 +1,162 @@ +mk-messaging-form + textarea@text(onkeypress={ onkeypress }, onpaste={ onpaste }, placeholder='ここにメッセージを入力') + div.files + mk-uploader@uploader + button.send(onclick={ send }, disabled={ sending }, title='メッセージを送信') + i.fa.fa-paper-plane(if={ !sending }) + i.fa.fa-spinner.fa-spin(if={ sending }) + button.attach-from-local(type='button', title='PCから画像を添付する') + i.fa.fa-upload + button.attach-from-drive(type='button', title='アルバムから画像を添付する') + i.fa.fa-folder-open + input(name='file', type='file', accept='image/*') + +style. + display block + + > textarea + cursor auto + display block + width 100% + min-width 100% + max-width 100% + height 64px + margin 0 + padding 8px + font-size 1em + color #000 + outline none + border none + border-top solid 1px #eee + border-radius 0 + box-shadow none + background transparent + + > .send + position absolute + bottom 0 + right 0 + margin 0 + padding 10px 14px + line-height 1em + font-size 1em + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + .files + display block + margin 0 + padding 0 8px + list-style none + + &:after + content '' + display block + clear both + + > li + display block + float left + margin 4px + padding 0 + width 64px + height 64px + background-color #eee + background-repeat no-repeat + background-position center center + background-size cover + cursor move + + &:hover + > .remove + display block + + > .remove + display none + position absolute + right -6px + top -6px + margin 0 + padding 0 + background transparent + outline none + border none + border-radius 0 + box-shadow none + cursor pointer + + .attach-from-local + .attach-from-drive + margin 0 + padding 10px 14px + line-height 1em + font-size 1em + font-weight normal + text-decoration none + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + input[type=file] + display none + +script. + @mixin \api + + @user = @opts.user + + @onpaste = (e) ~> + data = e.clipboard-data + items = data.items + for i from 0 to items.length - 1 + item = items[i] + switch (item.kind) + | \file => + @upload item.get-as-file! + + @onkeypress = (e) ~> + if (e.which == 10 || e.which == 13) && e.ctrl-key + @send! + + @select-file = ~> + @refs.file.click! + + @select-file-from-drive = ~> + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + event = riot.observable! + riot.mount browser, do + multiple: true + event: event + event.one \selected (files) ~> + files.for-each @add-file + + @send = ~> + @sending = true + @api \messaging/messages/create do + user_id: @user.id + text: @refs.text.value + .then (message) ~> + @clear! + .catch (err) ~> + console.error err + .then ~> + @sending = false + @update! + + @clear = ~> + @refs.text.value = '' + @files = [] + @update! diff --git a/src/web/app/desktop/tags/messaging/index.tag b/src/web/app/desktop/tags/messaging/index.tag new file mode 100644 index 0000000000..9f57500b83 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/index.tag @@ -0,0 +1,302 @@ +mk-messaging + div.search + div.form + label(for='search-input') + i.fa.fa-search + input@search-input(type='search', oninput={ search }, placeholder='ユーザーを探す') + div.result + ol.users(if={ search-result.length > 0 }) + li(each={ user in search-result }) + a(onclick={ user._click }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='') + span.name { user.name } + span.username @{ user.username } + div.main + div.history(if={ history.length > 0 }) + virtual(each={ history }) + a.user(data-is-me={ is_me }, data-is-read={ is_read }, onclick={ _click }): div + img.avatar(src={ (is_me ? recipient.avatar_url : user.avatar_url) + '?thumbnail&size=64' }, alt='') + header + span.name { is_me ? recipient.name : user.name } + span.username { '@' + (is_me ? recipient.username : user.username ) } + mk-time(time={ created_at }) + div.body + p.text + span.me(if={ is_me }) あなた: + | { text } + p.no-history(if={ history.length == 0 }) + | 履歴はありません。 + br + | ユーザーを検索して、いつでもメッセージを送受信できます。 + +style. + display block + + > .search + display block + position absolute + top 0 + left 0 + z-index 1 + width 100% + background #fff + box-shadow 0 0px 2px rgba(0, 0, 0, 0.2) + + > .form + padding 8px + background #f7f7f7 + + > label + display block + position absolute + top 0 + left 8px + z-index 1 + height 100% + width 38px + pointer-events none + + > i + display block + position absolute + top 0 + right 0 + bottom 0 + left 0 + width 1em + height 1em + margin auto + color #555 + + > input + margin 0 + padding 0 12px 0 38px + width 100% + font-size 1em + line-height 38px + color #000 + outline none + border solid 1px #eee + border-radius 5px + box-shadow none + transition color 0.5s ease, border 0.5s ease + + &:hover + border solid 1px #ddd + transition border 0.2s ease + + &:focus + color darken($theme-color, 20%) + border solid 1px $theme-color + transition color 0, border 0 + + > .result + display block + top 0 + left 0 + z-index 2 + width 100% + margin 0 + padding 0 + background #fff + + > .users + margin 0 + padding 0 + list-style none + + > li + > a + display inline-block + z-index 1 + width 100% + padding 8px 32px + vertical-align top + white-space nowrap + overflow hidden + color rgba(0, 0, 0, 0.8) + text-decoration none + transition none + + &:hover + color #fff + background $theme-color + + .name + color #fff + + .username + color #fff + + &:active + color #fff + background darken($theme-color, 10%) + + .name + color #fff + + .username + color #fff + + .avatar + vertical-align middle + min-width 32px + min-height 32px + max-width 32px + max-height 32px + margin 0 8px 0 0 + border-radius 6px + + .name + margin 0 8px 0 0 + /*font-weight bold*/ + font-weight normal + color rgba(0, 0, 0, 0.8) + + .username + font-weight normal + color rgba(0, 0, 0, 0.3) + + > .main + padding-top 56px + + > .history + + > a + display block + padding 20px 30px + text-decoration none + background #fff + border-bottom solid 1px #eee + + * + pointer-events none + user-select none + + &:hover + background #fafafa + + > .avatar + filter saturate(200%) + + &:active + background #eee + + &[data-is-read] + &[data-is-me] + opacity 0.8 + + &:not([data-is-me]):not([data-is-read]) + background-image url("/_/resources/desktop/unread.svg") + background-repeat no-repeat + background-position 0 center + + &:after + content "" + display block + clear both + + > div + max-width 500px + margin 0 auto + + > header + margin-bottom 2px + white-space nowrap + overflow hidden + + > .name + text-align left + display inline + margin 0 + padding 0 + font-size 1em + color rgba(0, 0, 0, 0.9) + font-weight bold + transition all 0.1s ease + + > .username + text-align left + margin 0 0 0 8px + color rgba(0, 0, 0, 0.5) + + > mk-time + position absolute + top 0 + right 0 + display inline + color rgba(0, 0, 0, 0.5) + font-size small + + > .avatar + float left + width 54px + height 54px + margin 0 16px 0 0 + border-radius 8px + transition all 0.1s ease + + > .body + + > .text + display block + margin 0 0 0 0 + padding 0 + overflow hidden + word-wrap break-word + font-size 1.1em + color rgba(0, 0, 0, 0.8) + + .me + color rgba(0, 0, 0, 0.4) + + > .image + display block + max-width 100% + max-height 512px + + > .no-history + margin 0 + padding 2em 1em + text-align center + color #999 + font-weight 500 + +script. + @mixin \i + @mixin \api + + @search-result = [] + + @on \mount ~> + @api \messaging/history + .then (history) ~> + @is-loading = false + history.for-each (message) ~> + message.is_me = message.user_id == @I.id + message._click = ~> + if message.is_me + @trigger \navigate-user message.recipient + else + @trigger \navigate-user message.user + @history = history + @update! + .catch (err) ~> + console.error err + + @search = ~> + q = @refs.search-input.value + if q == '' + @search-result = [] + else + @api \users/search do + query: q + .then (users) ~> + users.for-each (user) ~> + user._click = ~> + @trigger \navigate-user user + @search-result = [] + @search-result = users + @update! + .catch (err) ~> + console.error err diff --git a/src/web/app/desktop/tags/messaging/message.tag b/src/web/app/desktop/tags/messaging/message.tag new file mode 100644 index 0000000000..d7a2cc32a6 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/message.tag @@ -0,0 +1,227 @@ +mk-messaging-message(data-is-me={ message.is_me }) + a.avatar-anchor(href={ CONFIG.url + '/' + message.user.username }, title={ message.user.username }, target='_blank') + img.avatar(src={ message.user.avatar_url + '?thumbnail&size=64' }, alt='') + div.content-container + div.balloon + p.read(if={ message.is_me && message.is_read }) 既読 + button.delete-button(if={ message.is_me }, title='メッセージを削除') + img(src='/_/resources/desktop/messaging/delete.png', alt='Delete') + div.content(if={ !message.is_deleted }) + div@text + div.image(if={ message.file }) + img(src={ message.file.url }, alt='image', title={ message.file.name }) + div.content(if={ message.is_deleted }) + p.is-deleted このメッセージは削除されました + footer + mk-time(time={ message.created_at }) + i.fa.fa-pencil.is-edited(if={ message.is_edited }) + +style. + $me-balloon-color = #23A7B6 + + display block + padding 10px 12px 10px 12px + background-color transparent + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + + > .avatar + display block + min-width 54px + min-height 54px + max-width 54px + max-height 54px + margin 0 + border-radius 8px + transition all 0.1s ease + + > .content-container + display block + margin 0 12px + padding 0 + max-width calc(100% - 78px) + + > .balloon + display block + float inherit + margin 0 + padding 0 + max-width 100% + min-height 38px + border-radius 16px + + &:before + content "" + pointer-events none + display block + position absolute + top 12px + + &:hover + > .delete-button + display block + + > .delete-button + display none + position absolute + z-index 1 + top -4px + right -4px + margin 0 + padding 0 + cursor pointer + outline none + border none + border-radius 0 + box-shadow none + background transparent + + > img + vertical-align bottom + width 16px + height 16px + cursor pointer + + > .read + user-select none + display block + position absolute + z-index 1 + bottom -4px + left -12px + margin 0 + color rgba(0, 0, 0, 0.5) + font-size 11px + + > .content + + > .is-deleted + display block + margin 0 + padding 0 + overflow hidden + word-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.5) + + > [ref='text'] + display block + margin 0 + padding 8px 16px + overflow hidden + word-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.8) + + &, * + user-select text + cursor auto + + & + .file + &.image + > img + border-radius 0 0 16px 16px + + > .file + &.image + > img + display block + max-width 100% + max-height 512px + border-radius 16px + + > footer + display block + clear both + margin 0 + padding 2px + font-size 10px + color rgba(0, 0, 0, 0.4) + + > .is-edited + margin-left 4px + + &:not([data-is-me='true']) + > .avatar-anchor + float left + + > .content-container + float left + + > .balloon + background #eee + + &:before + left -14px + border-top solid 8px transparent + border-right solid 8px #eee + border-bottom solid 8px transparent + border-left solid 8px transparent + + > footer + text-align left + + &[data-is-me='true'] + > .avatar-anchor + float right + + > .content-container + float right + + > .balloon + background $me-balloon-color + + &:before + right -14px + left auto + border-top solid 8px transparent + border-right solid 8px transparent + border-bottom solid 8px transparent + border-left solid 8px $me-balloon-color + + > .content + + > p.is-deleted + color rgba(255, 255, 255, 0.5) + + > [ref='text'] + &, * + color #fff !important + + > footer + text-align right + + &[data-is-deleted='true'] + > .content-container + opacity 0.5 + +script. + @mixin \i + @mixin \text + + @message = @opts.message + @message.is_me = @message.user.id == @I.id + + @on \mount ~> + if @message.text? + tokens = @analyze @message.text + + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content diff --git a/src/web/app/desktop/tags/messaging/room-window.tag b/src/web/app/desktop/tags/messaging/room-window.tag new file mode 100644 index 0000000000..673b11419a --- /dev/null +++ b/src/web/app/desktop/tags/messaging/room-window.tag @@ -0,0 +1,26 @@ +mk-messaging-room-window + mk-window@window(is-modal={ false }, width={ '500px' }, height={ '560px' }) + <yield to="header"> + i.fa.fa-comments + | メッセージ: { parent.user.name } + </yield> + <yield to="content"> + mk-messaging-room(user={ parent.user }) + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > mk-messaging-room + height 100% + +script. + @user = @opts.user + + @on \mount ~> + @refs.window.on \closed ~> + @unmount! diff --git a/src/web/app/desktop/tags/messaging/room.tag b/src/web/app/desktop/tags/messaging/room.tag new file mode 100644 index 0000000000..ca396d7418 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/room.tag @@ -0,0 +1,227 @@ +mk-messaging-room + div.stream@stream + p.initializing(if={ init }) + i.fa.fa-spinner.fa-spin + | 読み込み中 + p.empty(if={ !init && messages.length == 0 }) + i.fa.fa-info-circle + | このユーザーとまだ会話したことがありません + virtual(each={ message, i in messages }) + mk-messaging-message(message={ message }) + p.date(if={ i != messages.length - 1 && message._date != messages[i + 1]._date }) + span { messages[i + 1]._datetext } + + div.typings + footer + div@notifications + div.grippie(title='ドラッグしてフォームの広さを調整') + mk-messaging-form(user={ user }) + +style. + display block + + > .stream + position absolute + top 0 + left 0 + width 100% + height calc(100% - 100px) + overflow auto + + > .empty + width 100% + margin 0 + padding 16px 8px 8px 8px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + i + margin-right 4px + + > .no-history + display block + margin 0 + padding 16px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + i + margin-right 4px + + > .message + // something + + > .date + display block + margin 8px 0 + text-align center + + &:before + content '' + display block + position absolute + height 1px + width 90% + top 16px + left 0 + right 0 + margin 0 auto + background rgba(0, 0, 0, 0.1) + + > span + display inline-block + margin 0 + padding 0 16px + //font-weight bold + line-height 32px + color rgba(0, 0, 0, 0.3) + background #fff + + > footer + position absolute + z-index 2 + bottom 0 + width 600px + max-width 100% + margin 0 auto + padding 0 + background rgba(255, 255, 255, 0.95) + background-clip content-box + + > [ref='notifications'] + position absolute + top -48px + width 100% + padding 8px 0 + text-align center + + > p + display inline-block + margin 0 + padding 0 12px 0 28px + cursor pointer + line-height 32px + font-size 12px + color $theme-color-foreground + background $theme-color + border-radius 16px + transition opacity 1s ease + + > i + position absolute + top 0 + left 10px + line-height 32px + font-size 16px + + > .grippie + height 10px + margin-top -10px + background transparent + cursor ns-resize + + &:hover + //background rgba(0, 0, 0, 0.1) + + &:active + //background rgba(0, 0, 0, 0.2) + +script. + @mixin \i + @mixin \api + @mixin \messaging-stream + + @user = @opts.user + @init = true + @sending = false + @messages = [] + + @connection = new @MessagingStreamConnection @I, @user.id + + @on \mount ~> + @connection.event.on \message @on-message + @connection.event.on \read @on-read + + document.add-event-listener \visibilitychange @on-visibilitychange + + @api \messaging/messages do + user_id: @user.id + .then (messages) ~> + @init = false + @messages = messages.reverse! + @update! + @scroll-to-bottom! + .catch (err) ~> + console.error err + + @on \unmount ~> + @connection.event.off \message @on-message + @connection.event.off \read @on-read + @connection.close! + + document.remove-event-listener \visibilitychange @on-visibilitychange + + @on \update ~> + @messages.for-each (message) ~> + date = (new Date message.created_at).get-date! + month = (new Date message.created_at).get-month! + 1 + message._date = date + message._datetext = month + '月 ' + date + '日' + + @on-message = (message) ~> + is-bottom = @is-bottom! + + @messages.push message + if message.user_id != @I.id and not document.hidden + @connection.socket.send JSON.stringify do + type: \read + id: message.id + @update! + + if is-bottom + # Scroll to bottom + @scroll-to-bottom! + else if message.user_id != @I.id + # Notify + @notify '新しいメッセージがあります' + + @on-read = (ids) ~> + if not Array.isArray ids then ids = [ids] + ids.for-each (id) ~> + if (@messages.some (x) ~> x.id == id) + exist = (@messages.map (x) -> x.id).index-of id + @messages[exist].is_read = true + @update! + + @is-bottom = ~> + current = @refs.stream.scroll-top + @refs.stream.offset-height + max = @refs.stream.scroll-height + current > (max - 32) + + @scroll-to-bottom = ~> + @refs.stream.scroll-top = @refs.stream.scroll-height + + @notify = (message) ~> + n = document.create-element \p + n.inner-HTML = '<i class="fa fa-arrow-circle-down"></i>' + message + n.onclick = ~> + @scroll-to-bottom! + n.parent-node.remove-child n + @refs.notifications.append-child n + + set-timeout ~> + n.style.opacity = 0 + set-timeout ~> + n.parent-node.remove-child n + , 1000ms + , 4000ms + + @on-visibilitychange = ~> + if document.hidden then return + @messages.for-each (message) ~> + if message.user_id != @I.id and not message.is_read + @connection.socket.send JSON.stringify do + type: \read + id: message.id diff --git a/src/web/app/desktop/tags/messaging/window.tag b/src/web/app/desktop/tags/messaging/window.tag new file mode 100644 index 0000000000..b6979b6244 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/window.tag @@ -0,0 +1,29 @@ +mk-messaging-window + mk-window@window(is-modal={ false }, width={ '500px' }, height={ '560px' }) + <yield to="header"> + i.fa.fa-comments + | メッセージ + </yield> + <yield to="content"> + mk-messaging@index + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > mk-messaging + height 100% + +script. + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @refs.window.refs.index.on \navigate-user (user) ~> + w = document.body.append-child document.create-element \mk-messaging-room-window + riot.mount w, do + user: user diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag new file mode 100644 index 0000000000..d47815a89b --- /dev/null +++ b/src/web/app/desktop/tags/notifications.tag @@ -0,0 +1,226 @@ +mk-notifications + div.notifications(if={ notifications.length != 0 }) + virtual(each={ notification, i in notifications }) + div.notification(class={ notification.type }) + mk-time(time={ notification.created_at }) + + div.main(if={ notification.type == 'like' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-thumbs-o-up + a(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) { notification.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'repost' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-retweet + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post.repost) } + + div.main(if={ notification.type == 'quote' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-quote-left + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'follow' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-user-plus + a(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) { notification.user.name } + + div.main(if={ notification.type == 'reply' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-reply + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'mention' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-at + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + p.date(if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date }) + span + i.fa.fa-angle-up + | { notification._datetext } + span + i.fa.fa-angle-down + | { notifications[i + 1]._datetext } + + p.empty(if={ notifications.length == 0 && !loading }) + | ありません! + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + + > .notifications + > .notification + margin 0 + padding 16px + font-size 0.9em + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size small + + > .main + word-wrap break-word + + &:after + content "" + display block + clear both + + .avatar-anchor + display block + float left + + img + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i + margin-right 4px + + .post-preview + color rgba(0, 0, 0, 0.7) + + .post-ref + color rgba(0, 0, 0, 0.7) + + &:before, &:after + font-family FontAwesome + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &:before + content "\f10d" + + &:after + content "\f10e" + + &.like + .text p i + color #FFAC33 + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #555 + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color #aaa + background #fdfdfd + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + span + margin 0 16px + + i + margin-right 8px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \stream + @mixin \user-preview + @mixin \get-post-summary + + @notifications = [] + @loading = true + + @on \mount ~> + @api \i/notifications + .then (notifications) ~> + @notifications = notifications + @loading = false + @update! + .catch (err, text-status) -> + console.error err + + @stream.on \notification @on-notification + + @on \unmount ~> + @stream.off \notification @on-notification + + @on-notification = (notification) ~> + @notifications.unshift notification + @update! + + @on \update ~> + @notifications.for-each (notification) ~> + date = (new Date notification.created_at).get-date! + month = (new Date notification.created_at).get-month! + 1 + notification._date = date + notification._datetext = month + '月 ' + date + '日' diff --git a/src/web/app/desktop/tags/pages/entrance.tag b/src/web/app/desktop/tags/pages/entrance.tag new file mode 100644 index 0000000000..5e18f616a3 --- /dev/null +++ b/src/web/app/desktop/tags/pages/entrance.tag @@ -0,0 +1,77 @@ +mk-entrance + main + img(src='/_/resources/title.svg', alt='Misskey') + + mk-entrance-signin(if={ mode == 'signin' }) + mk-entrance-signup(if={ mode == 'signup' }) + div.introduction(if={ mode == 'introduction' }) + mk-introduction + button(onclick={ signin }) わかった + + mk-forkit + + footer + mk-copyright + + // ↓ https://github.com/riot/riot/issues/2134 (将来的) + style(data-disable-scope). + #wait { + right: auto; + left: 15px; + } + +style. + display block + height 100% + + > main + display block + + > img + display block + width 160px + height 170px + margin 0 auto + pointer-events none + user-select none + + > .introduction + max-width 360px + margin 0 auto + color #777 + + > mk-introduction + padding 32px + background #fff + box-shadow 0 4px 16px rgba(0, 0, 0, 0.2) + + > button + display block + margin 16px auto 0 auto + color #666 + + &:hover + text-decoration underline + + > footer + > mk-copyright + margin 0 + text-align center + line-height 64px + font-size 10px + color rgba(#000, 0.5) + +script. + @mode = \signin + + @signup = ~> + @mode = \signup + @update! + + @signin = ~> + @mode = \signin + @update! + + @introduction = ~> + @mode = \introduction + @update! diff --git a/src/web/app/desktop/tags/pages/entrance/signin.tag b/src/web/app/desktop/tags/pages/entrance/signin.tag new file mode 100644 index 0000000000..8ff39bc296 --- /dev/null +++ b/src/web/app/desktop/tags/pages/entrance/signin.tag @@ -0,0 +1,128 @@ +mk-entrance-signin + a.help(href={ CONFIG.urls.about + '/help' }, title='お困りですか?'): i.fa.fa-question + div.form + h1 + img(if={ user }, src={ user.avatar_url + '?thumbnail&size=32' }) + p { user ? user.name : 'アカウント' } + mk-signin@signin + div.divider: span or + button.signup(onclick={ parent.signup }) 新規登録 + a.introduction(onclick={ introduction }) Misskeyについて + +style. + display block + width 290px + margin 0 auto + text-align center + + &:hover + > .help + opacity 1 + + > .help + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + background transparent + opacity 0 + transition opacity 0.1s ease + + &:hover + color #444 + + &:active + color #222 + + > i + padding 14px + + > .form + padding 10px 28px 16px 28px + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > h1 + display block + margin 0 + padding 0 + height 54px + line-height 54px + text-align center + text-transform uppercase + font-size 1em + font-weight bold + color rgba(0, 0, 0, 0.5) + border-bottom solid 1px rgba(0, 0, 0, 0.1) + + > p + display inline + margin 0 + padding 0 + + > img + display inline-block + top 10px + width 32px + height 32px + margin-right 8px + border-radius 100% + + &[src=''] + display none + + > .divider + padding 16px 0 + text-align center + + &:after + content "" + display block + position absolute + top 50% + width 100% + height 1px + border-top solid 1px rgba(0, 0, 0, 0.1) + + > * + z-index 1 + padding 0 8px + color rgba(0, 0, 0, 0.5) + background #fdfdfd + + > .signup + width 100% + line-height 56px + font-size 1em + color #fff + background $theme-color + border-radius 64px + + &:hover + background lighten($theme-color, 5%) + + &:active + background darken($theme-color, 5%) + + > .introduction + display inline-block + margin-top 16px + font-size 12px + color #666 + +script. + @on \mount ~> + @refs.signin.on \user (user) ~> + @update do + user: user + + @introduction = ~> + @parent.introduction! diff --git a/src/web/app/desktop/tags/pages/entrance/signup.tag b/src/web/app/desktop/tags/pages/entrance/signup.tag new file mode 100644 index 0000000000..1b585f7000 --- /dev/null +++ b/src/web/app/desktop/tags/pages/entrance/signup.tag @@ -0,0 +1,44 @@ +mk-entrance-signup + mk-signup + button.cancel(type='button', onclick={ parent.signin }, title='キャンセル'): i.fa.fa-times + +style. + display block + width 368px + margin 0 auto + + &:hover + > .cancel + opacity 1 + + > mk-signup + padding 18px 32px 0 32px + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > .cancel + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + box-shadow none + background transparent + opacity 0 + transition opacity 0.1s ease + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px diff --git a/src/web/app/desktop/tags/pages/home.tag b/src/web/app/desktop/tags/pages/home.tag new file mode 100644 index 0000000000..5d419a5802 --- /dev/null +++ b/src/web/app/desktop/tags/pages/home.tag @@ -0,0 +1,51 @@ +mk-home-page + mk-ui@ui(page={ page }): mk-home@home(mode={ parent.opts.mode }) + +style. + display block + + background-position center center + background-attachment fixed + background-size cover + +script. + @mixin \i + @mixin \api + @mixin \ui-progress + @mixin \stream + @mixin \get-post-summary + + @unread-count = 0 + + @page = switch @opts.mode + | \timelie => \home + | \mentions => \mentions + | _ => \home + + @on \mount ~> + @refs.ui.refs.home.on \loaded ~> + @Progress.done! + + document.title = 'Misskey' + if @I.data.wallpaper + @api \drive/files/show do + file_id: @I.data.wallpaper + .then (file) ~> + @root.style.background-image = 'url(' + file.url + ')' + @Progress.start! + @stream.on \post @on-stream-post + document.add-event-listener \visibilitychange @window-on-visibilitychange, false + + @on \unmount ~> + @stream.off \post @on-stream-post + document.remove-event-listener \visibilitychange @window-on-visibilitychange + + @on-stream-post = (post) ~> + if document.hidden and post.user_id !== @I.id + @unread-count++ + document.title = '(' + @unread-count + ') ' + @get-post-summary post + + @window-on-visibilitychange = ~> + if !document.hidden + @unread-count = 0 + document.title = 'Misskey' diff --git a/src/web/app/desktop/tags/pages/not-found.tag b/src/web/app/desktop/tags/pages/not-found.tag new file mode 100644 index 0000000000..fe23cc6fa4 --- /dev/null +++ b/src/web/app/desktop/tags/pages/not-found.tag @@ -0,0 +1,46 @@ +mk-not-found + mk-ui + main + h1 Not Found + img(src='/_/resources/rogge.jpg', alt='') + div.mask + +style. + display block + + main + display block + width 600px + margin 32px auto + + > img + display block + width 600px + height 459px + pointer-events none + user-select none + border-radius 16px + box-shadow 0 0 16px rgba(0, 0, 0, 0.1) + + > h1 + display block + margin 0 + padding 0 + position absolute + top 260px + left 225px + transform rotate(-12deg) + z-index 2 + color #444 + font-size 24px + line-height 20px + + > .mask + position absolute + top 262px + left 217px + width 126px + height 18px + transform rotate(-12deg) + background #D6D5DA + border-radius 2px 6px 7px 6px diff --git a/src/web/app/desktop/tags/pages/post.tag b/src/web/app/desktop/tags/pages/post.tag new file mode 100644 index 0000000000..81ab9ce006 --- /dev/null +++ b/src/web/app/desktop/tags/pages/post.tag @@ -0,0 +1,25 @@ +mk-post-page + mk-ui@ui: main: mk-post-detail@detail(post={ parent.post }) + +style. + display block + + main + padding 16px + + > mk-post-detail + margin 0 auto + +script. + @mixin \ui-progress + + @post = @opts.post + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.detail.on \post-fetched ~> + @Progress.set 0.5 + + @refs.ui.refs.detail.on \loaded ~> + @Progress.done! diff --git a/src/web/app/desktop/tags/pages/search.tag b/src/web/app/desktop/tags/pages/search.tag new file mode 100644 index 0000000000..a7878ddc0f --- /dev/null +++ b/src/web/app/desktop/tags/pages/search.tag @@ -0,0 +1,14 @@ +mk-search-page + mk-ui@ui: mk-search@search(query={ parent.opts.query }) + +style. + display block + +script. + @mixin \ui-progress + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.search.on \loaded ~> + @Progress.done! diff --git a/src/web/app/desktop/tags/pages/user.tag b/src/web/app/desktop/tags/pages/user.tag new file mode 100644 index 0000000000..d41093c298 --- /dev/null +++ b/src/web/app/desktop/tags/pages/user.tag @@ -0,0 +1,20 @@ +mk-user-page + mk-ui@ui: mk-user@user(user={ parent.user }, page={ parent.opts.page }) + +style. + display block + +script. + @mixin \ui-progress + + @user = @opts.user + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.user.on \user-fetched (user) ~> + @Progress.set 0.5 + document.title = user.name + ' | Misskey' + + @refs.ui.refs.user.on \loaded ~> + @Progress.done! diff --git a/src/web/app/desktop/tags/post-detail-sub.tag b/src/web/app/desktop/tags/post-detail-sub.tag new file mode 100644 index 0000000000..b7aa745737 --- /dev/null +++ b/src/web/app/desktop/tags/post-detail-sub.tag @@ -0,0 +1,141 @@ +mk-post-detail-sub(title={ title }) + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id }) + div.main + header + div.left + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) + | { post.user.name } + span.username + | @{ post.user.username } + div.right + a.time(href={ url }) + mk-time(time={ post.created_at }) + div.body + div.text@text + div.media(if={ post.media }) + virtual(each={ file in post.media }) + img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name }) + +style. + display block + margin 0 + padding 20px 32px + background #fdfdfd + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 4px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + margin-bottom 4px + white-space nowrap + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .right + float right + + > .time + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1em + color #717171 + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + +script. + @mixin \api + @mixin \text + @mixin \date-stringify + @mixin \user-preview + + @post = @opts.post + + @url = CONFIG.url + '/' + @post.user.username + '/' + @post.id + + @title = @date-stringify @post.created_at + + @on \mount ~> + if @post.text? + tokens = @analyze @post.text + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + @like = ~> + if @post.is_liked + @api \posts/likes/delete do + post_id: @post.id + .then ~> + @post.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @post.id + .then ~> + @post.is_liked = true + @update! diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag new file mode 100644 index 0000000000..e071b7c704 --- /dev/null +++ b/src/web/app/desktop/tags/post-detail.tag @@ -0,0 +1,415 @@ +mk-post-detail(title={ title }) + + div.fetching(if={ fetching }) + mk-ellipsis-icon + + div.main(if={ !fetching }) + + button.read-more(if={ p.reply_to && p.reply_to.reply_to_id && context == null }, title='会話をもっと読み込む', onclick={ load-context }, disabled={ loading-context }) + i.fa.fa-ellipsis-v(if={ !loading-context }) + i.fa.fa-spinner.fa-pulse(if={ loading-context }) + + div.context + virtual(each={ post in context }) + mk-post-detail-sub(post={ post }) + + div.reply-to(if={ p.reply_to }) + mk-post-detail-sub(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name } + | がRepost + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ p.user.id }) + header + a.name(href={ CONFIG.url + '/' + p.user.username }, data-user-preview={ p.user.id }) + | { p.user.name } + span.username + | @{ p.user.username } + a.time(href={ url }) + mk-time(time={ p.created_at }) + div.body + div.text@text + div.media(if={ p.media }) + virtual(each={ file in p.media }) + img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name }) + footer + button(onclick={ reply }, title='返信') + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }, title='善哉') + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + div.reposts-and-likes + div.reposts(if={ reposts && reposts.length > 0 }) + header + a { p.repost_count } + p Repost + ol.users + li.user(each={ reposts }) + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }, title={ user.name }, data-user-preview={ user.id }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='') + div.likes(if={ likes && likes.length > 0 }) + header + a { p.likes_count } + p いいね + ol.users + li.user(each={ likes }) + a.avatar-anchor(href={ CONFIG.url + '/' + username }, title={ name }, data-user-preview={ id }) + img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='') + + div.replies + virtual(each={ post in replies }) + mk-post-detail-sub(post={ post }) + +style. + display block + margin 0 + padding 0 + width 640px + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 8px + + > .fetching + padding 64px 0 + + > .main + + > .read-more + display block + margin 0 + padding 10px 0 + width 100% + font-size 1em + text-align center + color #999 + cursor pointer + background #fafafa + outline none + border none + border-bottom solid 1px #eef0f2 + border-radius 6px 6px 0 0 + + &:hover + background #f6f6f6 + + &:active + background #f0f0f0 + + &:disabled + color #ccc + + > .context + > * + border-bottom 1px solid #eef0f2 + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + & + article + padding-top 8px + + > .reply-to + border-bottom 1px solid #eef0f2 + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + width 60px + height 60px + + > .avatar + display block + width 60px + height 60px + margin 0 + border-radius 8px + vertical-align bottom + + > header + position absolute + top 28px + left 108px + width calc(100% - 108px) + + > .name + display inline-block + margin 0 + line-height 24px + color #777 + font-size 18px + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + display block + text-align left + margin 0 + color #ccc + + > .time + position absolute + top 0 + right 32px + font-size 1em + color #c0c0c0 + + > .body + padding 8px 0 + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.5em + color #717171 + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + + > footer + font-size 1.2em + + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + + > .reposts-and-likes + display flex + justify-content center + padding 0 + margin 16px 0 + + &:empty + display none + + > .reposts + > .likes + display flex + flex 1 1 + padding 0 + border-top solid 1px #F2EFEE + + > header + flex 1 1 80px + max-width 80px + padding 8px 5px 0px 10px + + > a + display block + font-size 1.5em + line-height 1.4em + + > p + display block + margin 0 + font-size 0.7em + line-height 1em + font-weight normal + color #a0a2a5 + + > .users + display block + flex 1 1 + margin 0 + padding 10px 10px 10px 5px + list-style none + + > .user + display block + float left + margin 4px + padding 0 + + > .avatar-anchor + display:block + + > .avatar + vertical-align bottom + width 24px + height 24px + border-radius 4px + + > .reposts + .likes + margin-left 16px + + > .replies + > * + border-top 1px solid #eef0f2 + +script. + @mixin \api + @mixin \text + @mixin \user-preview + @mixin \date-stringify + @mixin \NotImplementedException + + @fetching = true + @loading-context = false + @content = null + @post = null + + @on \mount ~> + + @api \posts/show do + post_id: @opts.post + .then (post) ~> + @fetching = false + @post = post + @trigger \loaded + + @is-repost = @post.repost? + @p = if @is-repost then @post.repost else @post + + @title = @date-stringify @p.created_at + + @update! + + if @p.text? + tokens = @analyze @p.text + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + # Get likes + @api \posts/likes do + post_id: @p.id + limit: 8 + .then (likes) ~> + @likes = likes + @update! + + # Get reposts + @api \posts/reposts do + post_id: @p.id + limit: 8 + .then (reposts) ~> + @reposts = reposts + @update! + + # Get replies + @api \posts/replies do + post_id: @p.id + limit: 8 + .then (replies) ~> + @replies = replies + @update! + + @update! + + @reply = ~> + form = document.body.append-child document.create-element \mk-post-form-window + riot.mount form, do + reply: @p + + @repost = ~> + form = document.body.append-child document.create-element \mk-repost-form-window + riot.mount form, do + post: @p + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! + + @load-context = ~> + @loading-context = true + + # Get context + @api \posts/context do + post_id: @p.reply_to_id + .then (context) ~> + @context = context.reverse! + @loading-context = false + @update! diff --git a/src/web/app/desktop/tags/post-form-window.tag b/src/web/app/desktop/tags/post-form-window.tag new file mode 100644 index 0000000000..8727777944 --- /dev/null +++ b/src/web/app/desktop/tags/post-form-window.tag @@ -0,0 +1,60 @@ +mk-post-form-window + + mk-window@window(is-modal={ true }, colored={ true }) + + <yield to="header"> + span(if={ !parent.opts.reply }) 新規投稿 + span(if={ parent.opts.reply }) 返信 + span.files(if={ parent.files.length != 0 }) 添付: { parent.files.length }ファイル + span.uploading-files(if={ parent.uploading-files.length != 0 }) + | { parent.uploading-files.length }個のファイルをアップロード中 + mk-ellipsis + </yield> + + <yield to="content"> + div.ref(if={ parent.opts.reply }) + mk-post-preview(post={ parent.opts.reply }) + div.body + mk-post-form@form(reply={ parent.opts.reply }) + </yield> + +style. + > mk-window + + [data-yield='header'] + > .files + > .uploading-files + margin-left 8px + opacity 0.8 + + &:before + content '(' + + &:after + content ')' + + [data-yield='content'] + > .ref + > mk-post-preview + margin 16px 22px + +script. + @uploading-files = [] + @files = [] + + @on \mount ~> + @refs.window.refs.form.focus! + + @refs.window.on \closed ~> + @unmount! + + @refs.window.refs.form.on \post ~> + @refs.window.close! + + @refs.window.refs.form.on \change-uploading-files (files) ~> + @uploading-files = files + @update! + + @refs.window.refs.form.on \change-files (files) ~> + @files = files + @update! diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag new file mode 100644 index 0000000000..2248587885 --- /dev/null +++ b/src/web/app/desktop/tags/post-form.tag @@ -0,0 +1,430 @@ +mk-post-form(ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }) + textarea@text(disabled={ wait }, class={ withfiles: files.length != 0 }, oninput={ update }, onkeydown={ onkeydown }, onpaste={ onpaste }, placeholder={ opts.reply ? 'この投稿への返信...' : 'いまどうしてる?' }) + div.attaches(if={ files.length != 0 }) + ul.files@attaches + li.file(each={ files }) + div.img(style='background-image: url({ url + "?thumbnail&size=64" })', title={ name }) + img.remove(onclick={ _remove }, src='/_/resources/desktop/remove.png', title='添付取り消し', alt='') + li.add(if={ files.length < 4 }, title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-plus + p.remain + | 残り{ 4 - files.length } + mk-uploader@uploader + button@upload(title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-upload + button@drive(title='ドライブからファイルを添付', onclick={ select-file-from-drive }): i.fa.fa-cloud + p.text-count(class={ over: refs.text.value.length > 300 }) のこり{ 300 - refs.text.value.length }文字 + button@submit(class={ wait: wait }, disabled={ wait || (refs.text.value.length == 0 && files.length == 0) }, onclick={ post }) + | { wait ? '投稿中' : opts.reply ? '返信' : '投稿' } + mk-ellipsis(if={ wait }) + input@file(type='file', accept='image/*', multiple, tabindex='-1', onchange={ change-file }) + div.dropzone(if={ draghover }) + +style. + display block + padding 16px + background lighten($theme-color, 95%) + + &:after + content "" + display block + clear both + + > .attaches + margin 0 + padding 0 + background lighten($theme-color, 98%) + border solid 1px rgba($theme-color, 0.1) + border-top none + border-radius 0 0 4px 4px + transition border-color .3s ease + + > .remain + display block + position absolute + top 8px + right 8px + margin 0 + padding 0 + color rgba($theme-color, 0.4) + + > .files + display block + margin 0 + padding 4px + list-style none + + &:after + content "" + display block + clear both + + > .file + display block + float left + margin 4px + padding 0 + cursor move + + &:hover > .remove + display block + + > .img + width 64px + height 64px + background-size cover + background-position center center + + > .remove + display none + position absolute + top -6px + right -6px + width 16px + height 16px + cursor pointer + + > .add + display block + float left + margin 4px + padding 0 + border dashed 2px rgba($theme-color, 0.2) + cursor pointer + + &:hover + border-color rgba($theme-color, 0.3) + + > i + color rgba($theme-color, 0.4) + + > i + display block + width 60px + height 60px + line-height 60px + text-align center + font-size 1.2em + color rgba($theme-color, 0.2) + + > mk-uploader + margin 8px 0 0 0 + padding 8px + border solid 1px rgba($theme-color, 0.2) + border-radius 4px + + [ref='file'] + display none + + [ref='text'] + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height calc(16px + 12px + 12px) + font-size 16px + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &:disabled + opacity 0.5 + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + &.withfiles + border-bottom solid 1px rgba($theme-color, 0.1) !important + border-radius 4px 4px 0 0 + + &:hover + .attaches + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + .attaches + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + .text-count + pointer-events none + display block + position absolute + bottom 16px + right 138px + margin 0 + line-height 40px + color rgba($theme-color, 0.5) + + &.over + color #ec3828 + + [ref='submit'] + display block + position absolute + bottom 16px + right 16px + cursor pointer + padding 0 + margin 0 + width 110px + height 40px + font-size 1em + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + outline none + border solid 1px lighten($theme-color, 15%) + border-radius 4px + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &.wait + background linear-gradient( + 45deg, + darken($theme-color, 10%) 25%, + $theme-color 25%, + $theme-color 50%, + darken($theme-color, 10%) 50%, + darken($theme-color, 10%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation stripe-bg 1.5s linear infinite + opacity 0.7 + cursor wait + + @keyframes stripe-bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + + [ref='upload'] + [ref='drive'] + display inline-block + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%) + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(0, 0, 0, 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + > .dropzone + position absolute + left 0 + top 0 + width 100% + height 100% + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + +script. + @mixin \api + @mixin \notify + @mixin \autocomplete + @mixin \sortable + + @wait = false + @uploadings = [] + @files = [] + @autocomplete = null + + @in-reply-to-post = @opts.reply + + # https://github.com/riot/riot/issues/2080 + if @in-reply-to-post == '' then @in-reply-to-post = null + + @on \mount ~> + @refs.uploader.on \uploaded (file) ~> + @add-file file + + @refs.uploader.on \change-uploads (uploads) ~> + @trigger \change-uploading-files uploads + + @autocomplete = new @Autocomplete @refs.text + @autocomplete.attach! + + @on \unmount ~> + @autocomplete.detach! + + @focus = ~> + @refs.text.focus! + + @clear = ~> + @refs.text.value = '' + @files = [] + @trigger \change-files + @update! + + @ondragover = (e) ~> + e.stop-propagation! + @draghover = true + # ドラッグされてきたものがファイルだったら + if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + return false + + @ondragenter = (e) ~> + @draghover = true + + @ondragleave = (e) ~> + @draghover = false + + @ondrop = (e) ~> + e.prevent-default! + e.stop-propagation! + @draghover = false + + # ファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @upload file + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + try + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + @add-file obj.file + catch + # ignore + + return false + + @onkeydown = (e) ~> + if (e.which == 10 || e.which == 13) && (e.ctrl-key || e.meta-key) + @post! + + @onpaste = (e) ~> + data = e.clipboard-data + items = data.items + for i from 0 to items.length - 1 + item = items[i] + switch (item.kind) + | \file => + @upload item.get-as-file! + + @select-file = ~> + @refs.file.click! + + @select-file-from-drive = ~> + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + i = riot.mount browser, do + multiple: true + i[0].one \selected (files) ~> + files.for-each @add-file + + @change-file = ~> + files = @refs.file.files + for i from 0 to files.length - 1 + file = files.item i + @upload file + + @upload = (file) ~> + @refs.uploader.upload file + + @add-file = (file) ~> + file._remove = ~> + @files = @files.filter (x) -> x.id != file.id + @trigger \change-files @files + @update! + + @files.push file + @trigger \change-files @files + @update! + + new @Sortable @refs.attaches, do + draggable: \.file + animation: 150ms + + @post = (e) ~> + @wait = true + + files = if @files? and @files.length > 0 + then @files.map (f) -> f.id + else undefined + + @api \posts/create do + text: @refs.text.value + media_ids: files + reply_to_id: if @in-reply-to-post? then @in-reply-to-post.id else undefined + .then (data) ~> + @trigger \post + @notify if @in-reply-to-post? then '返信しました!' else '投稿しました!' + .catch (err) ~> + console.error err + @notify '投稿できませんでした' + .then ~> + @wait = false + @update! diff --git a/src/web/app/desktop/tags/post-preview.tag b/src/web/app/desktop/tags/post-preview.tag new file mode 100644 index 0000000000..f17b152801 --- /dev/null +++ b/src/web/app/desktop/tags/post-preview.tag @@ -0,0 +1,94 @@ +mk-post-preview(title={ title }) + article + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id }) + div.main + header + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) + | { post.user.name } + span.username + | @{ post.user.username } + a.time(href={ CONFIG.url + '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +style. + display block + margin 0 + padding 0 + font-size 0.9em + background #fff + + > article + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 68px) + + > header + margin-bottom 4px + white-space nowrap + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .time + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +script. + @mixin \date-stringify + @mixin \user-preview + + @post = @opts.post + + @title = @date-stringify @post.created_at diff --git a/src/web/app/desktop/tags/post-status-graph.tag b/src/web/app/desktop/tags/post-status-graph.tag new file mode 100644 index 0000000000..ffb081e4f3 --- /dev/null +++ b/src/web/app/desktop/tags/post-status-graph.tag @@ -0,0 +1,72 @@ +mk-post-status-graph + canvas@canv(width={ opts.width }, height={ opts.height }) + +style. + display block + + > canvas + margin 0 auto + +script. + @mixin \api + @mixin \is-promise + + @post = null + @post-promise = if @is-promise @opts.post then @opts.post else Promise.resolve @opts.post + + @on \mount ~> + post <~ @post-promise.then + @post = post + @update! + + @api \aggregation/posts/like do + post_id: @post.id + limit: 30days + .then (likes) ~> + likes = likes.reverse! + + @api \aggregation/posts/repost do + post_id: @post.id + limit: 30days + .then (repost) ~> + repost = repost.reverse! + + @api \aggregation/posts/reply do + post_id: @post.id + limit: 30days + .then (replies) ~> + replies = replies.reverse! + + new Chart @refs.canv, do + type: \bar + data: + labels: likes.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \いいね + type: \line + data: likes.map (x) ~> x.count + line-tension: 0 + border-width: 2 + fill: true + background-color: 'rgba(247, 121, 108, 0.2)' + point-background-color: \#fff + point-radius: 4 + point-border-width: 2 + border-color: \#F7796C + }, + { + label: \返信 + type: \bar + data: replies.map (x) ~> x.count + background-color: \#555 + }, + { + label: \Repost + type: \bar + data: repost.map (x) ~> x.count + background-color: \#a2d61e + } + ] + options: + responsive: false diff --git a/src/web/app/desktop/tags/progress-dialog.tag b/src/web/app/desktop/tags/progress-dialog.tag new file mode 100644 index 0000000000..7c042686e6 --- /dev/null +++ b/src/web/app/desktop/tags/progress-dialog.tag @@ -0,0 +1,92 @@ +mk-progress-dialog + mk-window@window(is-modal={ false }, can-close={ false }, width={ '500px' }) + <yield to="header"> + | { parent.title } + mk-ellipsis + </yield> + <yield to="content"> + div.body + p.init(if={ isNaN(parent.value) }) + | 待機中 + mk-ellipsis + p.percentage(if={ !isNaN(parent.value) }) { Math.floor((parent.value / parent.max) * 100) } + progress(if={ !isNaN(parent.value) && parent.value < parent.max }, value={ isNaN(parent.value) ? 0 : parent.value }, max={ parent.max }) + div.progress.waiting(if={ parent.value >= parent.max }) + </yield> + +style. + display block + + > mk-window + [data-yield='content'] + + > .body + padding 18px 24px 24px 24px + + > .init + display block + margin 0 + text-align center + color rgba(#000, 0.7) + + > .percentage + display block + margin 0 0 4px 0 + text-align center + line-height 16px + color rgba($theme-color, 0.7) + + &:after + content '%' + + > progress + > .progress + display block + margin 0 + width 100% + height 10px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + + > .progress + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation progress-dialog-tag-progress-waiting 1.5s linear infinite + + @keyframes progress-dialog-tag-progress-waiting + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +script. + @title = @opts.title + @value = parse-int @opts.value, 10 + @max = parse-int @opts.max, 10 + + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @update-progress = (value, max) ~> + @value = parse-int value, 10 + @max = parse-int max, 10 + @update! + + @close = ~> + @refs.window.close! diff --git a/src/web/app/desktop/tags/repost-form-window.tag b/src/web/app/desktop/tags/repost-form-window.tag new file mode 100644 index 0000000000..40012f951f --- /dev/null +++ b/src/web/app/desktop/tags/repost-form-window.tag @@ -0,0 +1,38 @@ +mk-repost-form-window + mk-window@window(is-modal={ true }, colored={ true }) + <yield to="header"> + i.fa.fa-retweet + | この投稿をRepostしますか? + </yield> + <yield to="content"> + mk-repost-form@form(post={ parent.opts.post }) + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + +script. + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 27 # Esc + @refs.window.close! + + @on \mount ~> + @refs.window.refs.form.on \cancel ~> + @refs.window.close! + + @refs.window.refs.form.on \posted ~> + @refs.window.close! + + document.add-event-listener \keydown @on-document-keydown + + @refs.window.on \closed ~> + @unmount! + + @on \unmount ~> + document.remove-event-listener \keydown @on-document-keydown diff --git a/src/web/app/desktop/tags/repost-form.tag b/src/web/app/desktop/tags/repost-form.tag new file mode 100644 index 0000000000..37fbad251d --- /dev/null +++ b/src/web/app/desktop/tags/repost-form.tag @@ -0,0 +1,140 @@ +mk-repost-form + mk-post-preview(post={ opts.post }) + div.form(if={ quote }) + textarea@text(disabled={ wait }, placeholder='この投稿を引用...') + footer + a.quote(if={ !quote }, onclick={ onquote }) 引用する... + button.cancel(onclick={ cancel }) キャンセル + button.ok(onclick={ ok }) Repost + +style. + + > mk-post-preview + margin 16px 22px + + > .form + [ref='text'] + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height calc(1em + 12px + 12px) + font-size 1em + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &:disabled + opacity 0.5 + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + > div + padding 16px + + > footer + height 72px + background lighten($theme-color, 95%) + + > .quote + position absolute + bottom 16px + left 28px + line-height 40px + + button + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + > .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + > .ok + right 16px + font-weight bold + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:hover + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active + background $theme-color + border-color $theme-color + +script. + @mixin \api + @mixin \notify + + @wait = false + @quote = false + + @cancel = ~> + @trigger \cancel + + @ok = ~> + @wait = true + @api \posts/create do + repost_id: @opts.post.id + text: if @quote then @refs.text.value else undefined + .then (data) ~> + @trigger \posted + @notify 'Repostしました!' + .catch (err) ~> + console.error err + @notify 'Repostできませんでした' + .then ~> + @wait = false + @update! + + @onquote = ~> + @quote = true diff --git a/src/web/app/desktop/tags/search-posts.tag b/src/web/app/desktop/tags/search-posts.tag new file mode 100644 index 0000000000..9862ff6e4e --- /dev/null +++ b/src/web/app/desktop/tags/search-posts.tag @@ -0,0 +1,88 @@ +mk-search-posts + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-search + | 「{ query }」に関する投稿は見つかりませんでした。 + mk-timeline@timeline + <yield to="footer"> + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + </yield> + +style. + display block + background #fff + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \api + @mixin \get-post-summary + + @query = @opts.query + @is-loading = true + @is-empty = false + @more-loading = false + @page = 0 + + @on \mount ~> + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @api \posts/search do + query: @query + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + @trigger \loaded + .catch (err) ~> + console.error err + + @on \unmount ~> + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @more = ~> + if @more-loading or @is-loading or @timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \posts/search do + query: @query + page: @page + 1 + .then (posts) ~> + @more-loading = false + @page++ + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 16 # 遊び + @more! diff --git a/src/web/app/desktop/tags/search.tag b/src/web/app/desktop/tags/search.tag new file mode 100644 index 0000000000..aec426ac79 --- /dev/null +++ b/src/web/app/desktop/tags/search.tag @@ -0,0 +1,28 @@ +mk-search + header + h1 { query } + mk-search-posts@posts(query={ query }) + +style. + display block + padding-bottom 32px + + > header + width 100% + max-width 600px + margin 0 auto + color #555 + + > mk-search-posts + max-width 600px + margin 0 auto + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + +script. + @query = @opts.query + + @on \mount ~> + @refs.posts.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/desktop/tags/select-file-from-drive-window.tag b/src/web/app/desktop/tags/select-file-from-drive-window.tag new file mode 100644 index 0000000000..5042944837 --- /dev/null +++ b/src/web/app/desktop/tags/select-file-from-drive-window.tag @@ -0,0 +1,160 @@ +mk-select-file-from-drive-window + mk-window@window(is-modal={ true }, width={ '800px' }, height={ '500px' }) + <yield to="header"> + mk-raw(content={ parent.title }) + span.count(if={ parent.multiple && parent.file.length > 0 }) ({ parent.file.length }ファイル選択中) + </yield> + <yield to="content"> + mk-drive-browser@browser(multiple={ parent.multiple }) + div + button.upload(title='PCからドライブにファイルをアップロード', onclick={ parent.upload }): i.fa.fa-upload + button.cancel(onclick={ parent.close }) キャンセル + button.ok(disabled={ parent.multiple && parent.file.length == 0 }, onclick={ parent.ok }) 決定 + </yield> + +style. + > mk-window + [data-yield='header'] + > mk-raw + > i + margin-right 4px + + .count + margin-left 8px + opacity 0.7 + + [data-yield='content'] + > mk-drive-browser + height calc(100% - 72px) + + > div + height 72px + background lighten($theme-color, 95%) + + .upload + display inline-block + position absolute + top 8px + left 16px + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background transparent + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +script. + @file = [] + + @multiple = if @opts.multiple? then @opts.multiple else false + @title = @opts.title || '<i class="fa fa-file-o"></i>ファイルを選択' + + @on \mount ~> + @refs.window.refs.browser.on \selected (file) ~> + @file = file + @ok! + + @refs.window.refs.browser.on \change-selection (files) ~> + @file = files + @update! + + @refs.window.on \closed ~> + @unmount! + + @close = ~> + @refs.window.close! + + @upload = ~> + @refs.window.refs.browser.select-local-file! + + @ok = ~> + @trigger \selected @file + @refs.window.close! diff --git a/src/web/app/desktop/tags/set-avatar-suggestion.tag b/src/web/app/desktop/tags/set-avatar-suggestion.tag new file mode 100644 index 0000000000..68c9c310df --- /dev/null +++ b/src/web/app/desktop/tags/set-avatar-suggestion.tag @@ -0,0 +1,44 @@ +mk-set-avatar-suggestion(onclick={ set }) + p + b アバターを設定 + | してみませんか? + button(onclick={ close }): i.fa.fa-times + +style. + display block + cursor pointer + color #fff + background #a8cad0 + + &:hover + background #70abb5 + + > p + display block + margin 0 auto + padding 8px + max-width 1024px + + > a + font-weight bold + color #fff + + > button + position absolute + top 0 + right 0 + padding 8px + color #fff + +script. + @mixin \i + @mixin \update-avatar + + @set = ~> + @update-avatar @I, (i) ~> + @update-i i + + @close = (e) ~> + e.prevent-default! + e.stop-propagation! + @unmount! diff --git a/src/web/app/desktop/tags/set-banner-suggestion.tag b/src/web/app/desktop/tags/set-banner-suggestion.tag new file mode 100644 index 0000000000..bff0385803 --- /dev/null +++ b/src/web/app/desktop/tags/set-banner-suggestion.tag @@ -0,0 +1,44 @@ +mk-set-banner-suggestion(onclick={ set }) + p + b バナーを設定 + | してみませんか? + button(onclick={ close }): i.fa.fa-times + +style. + display block + cursor pointer + color #fff + background #a8cad0 + + &:hover + background #70abb5 + + > p + display block + margin 0 auto + padding 8px + max-width 1024px + + > a + font-weight bold + color #fff + + > button + position absolute + top 0 + right 0 + padding 8px + color #fff + +script. + @mixin \i + @mixin \update-banner + + @set = ~> + @update-banner @I, (i) ~> + @update-i i + + @close = (e) ~> + e.prevent-default! + e.stop-propagation! + @unmount! diff --git a/src/web/app/desktop/tags/settings-window.tag b/src/web/app/desktop/tags/settings-window.tag new file mode 100644 index 0000000000..e259848718 --- /dev/null +++ b/src/web/app/desktop/tags/settings-window.tag @@ -0,0 +1,26 @@ +mk-settings-window + mk-window@window(is-modal={ true }, width={ '700px' }, height={ '550px' }) + <yield to="header"> + i.fa.fa-cog + | 設定 + </yield> + <yield to="content"> + mk-settings + </yield> + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + overflow auto + +script. + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @close = ~> + @refs.window.close! diff --git a/src/web/app/desktop/tags/settings.tag b/src/web/app/desktop/tags/settings.tag new file mode 100644 index 0000000000..c6c0340912 --- /dev/null +++ b/src/web/app/desktop/tags/settings.tag @@ -0,0 +1,255 @@ +mk-settings + div.nav + p(class={ active: page == 'account' }, onmousedown={ set-page.bind(null, 'account') }) + i.fa.fa-fw.fa-user + | アカウント + p(class={ active: page == 'web' }, onmousedown={ set-page.bind(null, 'web') }) + i.fa.fa-fw.fa-desktop + | Web + p(class={ active: page == 'notification' }, onmousedown={ set-page.bind(null, 'notification') }) + i.fa.fa-fw.fa-bell-o + | 通知 + p(class={ active: page == 'drive' }, onmousedown={ set-page.bind(null, 'drive') }) + i.fa.fa-fw.fa-cloud + | ドライブ + p(class={ active: page == 'apps' }, onmousedown={ set-page.bind(null, 'apps') }) + i.fa.fa-fw.fa-puzzle-piece + | アプリ + p(class={ active: page == 'signin' }, onmousedown={ set-page.bind(null, 'signin') }) + i.fa.fa-fw.fa-sign-in + | ログイン履歴 + p(class={ active: page == 'password' }, onmousedown={ set-page.bind(null, 'password') }) + i.fa.fa-fw.fa-unlock-alt + | パスワード + p(class={ active: page == 'api' }, onmousedown={ set-page.bind(null, 'api') }) + i.fa.fa-fw.fa-key + | API + + div.pages + section.account(show={ page == 'account' }) + h1 アカウント + label.avatar + p アバター + img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, alt='avatar') + button.style-normal(onclick={ avatar }) 画像を選択 + label + p 名前 + input@account-name(type='text', value={ I.name }) + label + p 場所 + input@account-location(type='text', value={ I.location }) + label + p 自己紹介 + textarea@account-bio { I.bio } + button.style-primary(onclick={ update-account }) 保存 + + section.web(show={ page == 'web' }) + h1 デザイン + label + p 壁紙 + button.style-normal(onclick={ wallpaper }) 画像を選択 + section.web(show={ page == 'web' }) + h1 その他 + label.checkbox + input(type='checkbox', checked={ I.data.cache }, onclick={ update-cache }) + p 読み込みを高速化する + p API通信時に新鮮なユーザー情報をキャッシュすることでフェッチのオーバーヘッドを無くします。(実験的) + label.checkbox + input(type='checkbox', checked={ I.data.debug }, onclick={ update-debug }) + p 開発者モード + p デバッグ等の開発者モードを有効にします。 + + section.signin(show={ page == 'signin' }) + h1 ログイン履歴 + mk-signin-history + + section.api(show={ page == 'api' }) + h1 API + p + | Token: + code { I.token } + p APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。 + p アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。 + p + | 万が一このトークンが漏れたりその可能性がある場合は + button.regenerate(onclick={ regenerate-token }) トークンを再生成 + | できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します) + +style. + display block + + input:not([type]) + input[type='text'] + input[type='password'] + input[type='email'] + textarea + padding 8px + width 100% + font-size 16px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + border-color #aeaeae + + &:focus + border-color #aeaeae + + > .nav + position absolute + top 0 + left 0 + width 200px + height 100% + padding 16px 0 0 0 + border-right solid 1px #ddd + + > p + display block + padding 10px 16px + margin 0 + color #666 + cursor pointer + + -ms-user-select none + -moz-user-select none + -webkit-user-select none + user-select none + + transition margin-left 0.2s ease + + > i + margin-right 4px + + &:hover + color #555 + + &.active + margin-left 8px + color $theme-color !important + + > .pages + position absolute + top 0 + left 200px + width calc(100% - 200px) + + > section + padding 32px + + // & + section + // margin-top 16px + + h1 + display block + margin 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + label + display block + margin 16px 0 + + &:after + content "" + display block + clear both + + > p + margin 0 0 8px 0 + font-weight bold + color #373a3c + + &.checkbox + > input + position absolute + top 0 + left 0 + + &:checked + p + color $theme-color + + > p + width calc(100% - 32px) + margin 0 0 0 32px + font-weight bold + + &:last-child + font-weight normal + color #999 + + &.account + > .general + > .avatar + > img + display block + float left + width 64px + height 64px + border-radius 4px + + > button + float left + margin-left 8px + + &.api + code + padding 4px + background #eee + + .regenerate + display inline + color $theme-color + + &:hover + text-decoration underline + +script. + @mixin \i + @mixin \api + @mixin \dialog + @mixin \update-avatar + @mixin \update-wallpaper + + @page = \account + + @set-page = (page) ~> + @page = page + + @avatar = ~> + @update-avatar @I, (i) ~> + @update-i i + + @wallpaper = ~> + @update-wallpaper @I, (i) ~> + @update-i i + + @update-account = ~> + @api \i/update do + name: @refs.account-name.value + location: @refs.account-location.value + bio: @refs.account-bio.value + .then (i) ~> + @update-i i + alert \ok + .catch (err) ~> + console.error err + + @update-cache = ~> + @I.data.cache = !@I.data.cache + @api \i/appdata/set do + data: JSON.stringify do + cache: @I.data.cache + .then ~> + @update-i! + + @update-debug = ~> + @I.data.debug = !@I.data.debug + @api \i/appdata/set do + data: JSON.stringify do + debug: @I.data.debug + .then ~> + @update-i! diff --git a/src/web/app/desktop/tags/signin-history.tag b/src/web/app/desktop/tags/signin-history.tag new file mode 100644 index 0000000000..311f8bfeda --- /dev/null +++ b/src/web/app/desktop/tags/signin-history.tag @@ -0,0 +1,73 @@ +mk-signin-history + div.records(if={ history.length != 0 }) + div(each={ history }) + mk-time(time={ created_at }) + header + i.fa.fa-check(if={ success }) + i.fa.fa-times(if={ !success }) + span.ip { ip } + pre: code { JSON.stringify(headers, null, ' ') } + +style. + display block + + > .records + > div + padding 16px 0 0 0 + border-bottom solid 1px #eee + + > header + + > i + margin-right 8px + + &.fa-check + color #0fda82 + + &.fa-times + color #ff3100 + + > .ip + display inline-block + color #444 + background #f8f8f8 + + > mk-time + position absolute + top 16px + right 0 + color #777 + + > pre + overflow auto + max-height 100px + + > code + white-space pre-wrap + word-break break-all + color #4a535a + +script. + @mixin \api + @mixin \stream + + @history = [] + @fetching = true + + @on \mount ~> + @api \i/signin_history + .then (history) ~> + @history = history + @fetching = false + @update! + .catch (err) ~> + console.error err + + @stream.on \signin @on-signin + + @on \unmount ~> + @stream.off \signin @on-signin + + @on-signin = (signin) ~> + @history.unshift signin + @update! diff --git a/src/web/app/desktop/tags/stream-indicator.tag b/src/web/app/desktop/tags/stream-indicator.tag new file mode 100644 index 0000000000..2eb5889ca6 --- /dev/null +++ b/src/web/app/desktop/tags/stream-indicator.tag @@ -0,0 +1,59 @@ +mk-stream-indicator + p(if={ state == 'initializing' }) + i.fa.fa-spinner.fa-spin + span + | 接続中 + mk-ellipsis + p(if={ state == 'reconnecting' }) + i.fa.fa-spinner.fa-spin + span + | 切断されました 接続中 + mk-ellipsis + p(if={ state == 'connected' }) + i.fa.fa-check + span 接続完了 + +style. + display block + pointer-events none + position fixed + z-index 16384 + bottom 8px + right 8px + margin 0 + padding 6px 12px + font-size 0.9em + color #fff + background rgba(0, 0, 0, 0.8) + + > p + display block + margin 0 + + > i + margin-right 0.25em + +script. + @mixin \stream + + @on \before-mount ~> + @state = @get-stream-state! + + if @state == \connected + @root.style.opacity = 0 + + @stream-state-ev.on \connected ~> + @state = @get-stream-state! + @update! + set-timeout ~> + Velocity @root, { + opacity: 0 + } 200ms \linear + , 1000ms + + @stream-state-ev.on \closed ~> + @state = @get-stream-state! + @update! + Velocity @root, { + opacity: 1 + } 0ms diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag new file mode 100644 index 0000000000..976a6f398f --- /dev/null +++ b/src/web/app/desktop/tags/sub-post-content.tag @@ -0,0 +1,37 @@ +mk-sub-post-content + div.body + a.reply(if={ post.reply_to_id }): i.fa.fa-reply + span@text + a.quote(if={ post.repost_id }, href={ '/post:' + post.repost_id }) RP: ... + details(if={ post.media }) + summary ({ post.media.length }枚の画像) + mk-images-viewer(images={ post.media }) + +style. + display block + word-wrap break-word + + > .body + > .reply + margin-right 6px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + +script. + @mixin \text + @mixin \user-preview + + @post = @opts.post + + @on \mount ~> + if @post.text? + tokens = @analyze @post.text + @refs.text.innerHTML = @compile tokens, false + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e diff --git a/src/web/app/desktop/tags/timeline-post-sub.tag b/src/web/app/desktop/tags/timeline-post-sub.tag new file mode 100644 index 0000000000..39b1ad7f71 --- /dev/null +++ b/src/web/app/desktop/tags/timeline-post-sub.tag @@ -0,0 +1,95 @@ +mk-timeline-post-sub(title={ title }) + article + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id }) + div.main + header + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) + | { post.user.name } + span.username + | @{ post.user.username } + a.created-at(href={ CONFIG.url + '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +script. + @mixin \date-stringify + @mixin \user-preview + + @post = @opts.post + + @title = @date-stringify @post.created_at + +style. + display block + margin 0 + padding 0 + font-size 0.9em + + > article + padding 16px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 14px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 66px) + + > header + margin-bottom 4px + white-space nowrap + line-height 21px + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .created-at + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 diff --git a/src/web/app/desktop/tags/timeline-post.tag b/src/web/app/desktop/tags/timeline-post.tag new file mode 100644 index 0000000000..e23cd6306e --- /dev/null +++ b/src/web/app/desktop/tags/timeline-post.tag @@ -0,0 +1,376 @@ +mk-timeline-post(tabindex='-1', title={ title }, onkeydown={ on-key-down }) + + div.reply-to(if={ p.reply_to }) + mk-timeline-post-sub(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) { post.user.name } + | がRepost + mk-time(time={ post.created_at }) + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ p.user.id }) + div.main + header + a.name(href={ CONFIG.url + '/' + p.user.username }, data-user-preview={ p.user.id }) + | { p.user.name } + span.username + | @{ p.user.username } + a.created-at(href={ url }) + mk-time(time={ p.created_at }) + div.body + div.text + a.reply(if={ p.reply_to }): i.fa.fa-reply + span@text + a.quote(if={ p.repost != null }) RP: + div.media(if={ p.media }) + mk-images-viewer(images={ p.media }) + div.repost(if={ p.repost }) + i.fa.fa-quote-right.fa-flip-horizontal + mk-post-preview.repost(post={ p.repost }) + footer + button(onclick={ reply }, title='返信') + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }, title='善哉') + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + button(onclick={ toggle-detail }, title='詳細') + i.fa.fa-caret-down(if={ !is-detail-opened }) + i.fa.fa-caret-up(if={ is-detail-opened }) + div.detail(if={ is-detail-opened }) + mk-post-status-graph(width='462', height='130', post={ p }) + +style. + display block + margin 0 + padding 0 + background #fff + + &:focus + z-index 1 + + &:after + content "" + pointer-events none + position absolute + top 2px + right 2px + bottom 2px + left 2px + border 2px solid rgba($theme-color, 0.3) + border-radius 4px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + line-height 28px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + > mk-time + position absolute + top 16px + right 32px + font-size 0.9em + line-height 28px + + & + article + padding-top 8px + + > .reply-to + padding 0 16px + background rgba(0, 0, 0, 0.0125) + + > mk-post-preview + background transparent + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + margin-bottom 4px + white-space nowrap + line-height 24px + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .created-at + position absolute + top 0 + right 0 + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + + mk-url-preview + margin-top 8px + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + > .media + > img + display block + max-width 100% + + > .repost + margin 8px 0 + + > i:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 28px 0 0 + padding 0 8px + line-height 32px + font-size 1em + color #ddd + background transparent + border none + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + + &:last-child + position absolute + right 0 + margin 0 + + > .detail + padding-top 4px + background rgba(0, 0, 0, 0.0125) + +style(theme='dark'). + background #0D0D0D + + > article + + &:hover + > .main > footer > button + color #eee + + > .main + > header + > .left + > .name + color #9e9c98 + + > .username + color #41403f + + > .right + > .time + color #4e4d4b + + > .body + > .text + color #9e9c98 + + > footer + > button + color #9e9c98 + + &:hover + color #fff + + > .count + color #eee + +script. + @mixin \api + @mixin \text + @mixin \date-stringify + @mixin \user-preview + @mixin \NotImplementedException + + @post = @opts.post + @is-repost = @post.repost? and !@post.text? + @p = if @is-repost then @post.repost else @post + + @title = @date-stringify @p.created_at + + @url = CONFIG.url + '/' + @p.user.username + '/' + @p.id + @is-detail-opened = false + + @on \mount ~> + if @p.text? + tokens = if @p._highlight? + then @analyze @p._highlight + else @analyze @p.text + + @refs.text.innerHTML = if @p._highlight? + then @compile tokens, true, false + else @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + @reply = ~> + form = document.body.append-child document.create-element \mk-post-form-window + riot.mount form, do + reply: @p + + @repost = ~> + form = document.body.append-child document.create-element \mk-repost-form-window + riot.mount form, do + post: @p + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! + + @toggle-detail = ~> + @is-detail-opened = !@is-detail-opened + @update! + + @on-key-down = (e) ~> + should-be-cancel = true + switch + | e.which == 38 or e.which == 74 or (e.which == 9 and e.shift-key) => # ↑, j or Shift+Tab + focus @root, (e) -> e.previous-element-sibling + | e.which == 40 or e.which == 75 or e.which == 9 => # ↓, k or Tab + focus @root, (e) -> e.next-element-sibling + | e.which == 69 => # e + @repost! + | e.which == 70 or e.which == 76 => # f or l + @like! + | e.which == 82 => # r + @reply! + | _ => + should-be-cancel = false + + if should-be-cancel + e.prevent-default! + + function focus(el, fn) + target = fn el + if target? + if target.has-attribute \tabindex + target.focus! + else + focus target, fn diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag new file mode 100644 index 0000000000..dfd1b7c14e --- /dev/null +++ b/src/web/app/desktop/tags/timeline.tag @@ -0,0 +1,86 @@ +mk-timeline + virtual(each={ post, i in posts }) + mk-timeline-post(post={ post }) + p.date(if={ i != posts.length - 1 && post._date != posts[i + 1]._date }) + span + i.fa.fa-angle-up + | { post._datetext } + span + i.fa.fa-angle-down + | { posts[i + 1]._datetext } + footer(data-yield='footer') + | <yield from="footer"/> + +style. + display block + + > mk-timeline-post + border-bottom solid 1px #eaeaea + + &:first-child + border-top-left-radius 4px + border-top-right-radius 4px + + &:last-of-type + border-bottom none + + > .date + display block + margin 0 + line-height 32px + font-size 14px + text-align center + color #aaa + background #fdfdfd + border-bottom solid 1px #eaeaea + + span + margin 0 16px + + i + margin-right 8px + + > footer + padding 16px + text-align center + color #ccc + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + +style(theme='dark'). + > mk-timeline-post + border-bottom-color #222221 + +script. + @posts = [] + + @set-posts = (posts) ~> + @posts = posts + @update! + + @prepend-posts = (posts) ~> + posts.for-each (post) ~> + @posts.push post + @update! + + @add-post = (post) ~> + @posts.unshift post + @update! + + @clear = ~> + @posts = [] + @update! + + @focus = ~> + @root.children.0.focus! + + @on \update ~> + @posts.for-each (post) ~> + date = (new Date post.created_at).get-date! + month = (new Date post.created_at).get-month! + 1 + post._date = date + post._datetext = month + '月 ' + date + '日' + + @tail = ~> + @posts[@posts.length - 1] diff --git a/src/web/app/desktop/tags/ui-header-account.tag b/src/web/app/desktop/tags/ui-header-account.tag new file mode 100644 index 0000000000..ffb1eeec00 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-account.tag @@ -0,0 +1,219 @@ +mk-ui-header-account + button.header(data-active={ is-open.toString() }, onclick={ toggle }) + span.username + | { I.username } + i.fa.fa-angle-down(if={ !is-open }) + i.fa.fa-angle-up(if={ is-open }) + img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.menu(if={ is-open }) + ul + li: a(href={ '/' + I.username }) + i.fa.fa-user + | プロフィール + i.fa.fa-angle-right + li(onclick={ drive }): p + i.fa.fa-cloud + | ドライブ + i.fa.fa-angle-right + li: a(href='/i>mentions') + i.fa.fa-at + | あなた宛て + i.fa.fa-angle-right + ul + li(onclick={ settings }): p + i.fa.fa-cog + | 設定 + i.fa.fa-angle-right + ul + li(onclick={ signout }): p + i(class='fa fa-power-off') + | サインアウト + i.fa.fa-angle-right + +style. + display block + float left + + > .header + display block + margin 0 + padding 0 + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + &[data-active='true'] + color darken(#9eaba8, 20%) + + > .avatar + $saturate = 150% + filter saturate($saturate) + -webkit-filter saturate($saturate) + -moz-filter saturate($saturate) + -ms-filter saturate($saturate) + + > .username + display block + float left + margin 0 12px 0 16px + max-width 16em + line-height 48px + font-weight bold + font-family Meiryo, sans-serif + text-decoration none + + i + margin-left 8px + + > .avatar + display block + float left + min-width 32px + max-width 32px + min-height 32px + max-height 32px + margin 8px 8px 8px 0 + border-radius 4px + transition filter 100ms ease + + > .menu + display block + position absolute + top 56px + right -2px + width 230px + font-size 0.8em + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + ul + display block + margin 10px 0 + padding 0 + list-style none + + & + ul + padding-top 10px + border-top solid 1px #eee + + > li + display block + margin 0 + padding 0 + + > a + > p + display block + z-index 1 + padding 0 28px + margin 0 + line-height 40px + color #868C8C + cursor pointer + + * + pointer-events none + + > i:first-of-type + margin-right 6px + + > i:last-of-type + display block + position absolute + top 0 + right 8px + z-index 1 + padding 0 20px + font-size 1.2em + line-height 40px + + &:hover, &:active + text-decoration none + background $theme-color + color $theme-color-foreground + +script. + @mixin \i + @mixin \signout + + @is-open = false + + @on \before-unmount ~> + @close! + + @toggle = ~> + if @is-open + @close! + else + @open! + + @open = ~> + @is-open = true + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + + @close = ~> + @is-open = false + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + + @mousedown = (e) ~> + e.prevent-default! + if (!contains @root, e.target) and (@root != e.target) + @close! + return false + + @drive = ~> + @close! + riot.mount document.body.append-child document.create-element \mk-drive-browser-window + + @settings = ~> + @close! + riot.mount document.body.append-child document.create-element \mk-settings-window + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/ui-header-clock.tag b/src/web/app/desktop/tags/ui-header-clock.tag new file mode 100644 index 0000000000..987907a684 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-clock.tag @@ -0,0 +1,82 @@ +mk-ui-header-clock + div.header + time@time + div.content + mk-analog-clock + +style. + display inline-block + overflow visible + + > .header + padding 0 12px + text-align center + font-size 0.5em + + &, * + cursor: default + + &:hover + background #899492 + + & + .content + visibility visible + + > time + color #fff !important + + * + color #fff !important + + &:after + content "" + display block + clear both + + > time + display table-cell + vertical-align middle + height 48px + color #9eaba8 + + > .yyyymmdd + opacity 0.7 + + > .content + visibility hidden + display block + position absolute + top auto + right 0 + z-index 3 + margin 0 + padding 0 + width 256px + background #899492 + +script. + @draw = ~> + now = new Date! + + yyyy = now.get-full-year! + mm = (\0 + (now.get-month! + 1)).slice -2 + dd = (\0 + now.get-date!).slice -2 + yyyymmdd = "<span class='yyyymmdd'>#yyyy/#mm/#dd</span>" + + hh = (\0 + now.get-hours!).slice -2 + mm = (\0 + now.get-minutes!).slice -2 + hhmm = "<span class='hhmm'>#hh:#mm</span>" + + if now.get-seconds! % 2 == 0 + hhmm .= replace \: '<span style=\'visibility:visible\'>:</span>' + else + hhmm .= replace \: '<span style=\'visibility:hidden\'>:</span>' + + @refs.time.innerHTML = "#yyyymmdd<br>#hhmm" + + @on \mount ~> + @draw! + @clock = set-interval @draw, 1000ms + + @on \unmount ~> + clear-interval @clock diff --git a/src/web/app/desktop/tags/ui-header-nav.tag b/src/web/app/desktop/tags/ui-header-nav.tag new file mode 100644 index 0000000000..153c3137b4 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-nav.tag @@ -0,0 +1,113 @@ +mk-ui-header-nav: ul(if={ SIGNIN }) + li.home(class={ active: page == 'home' }): a(href={ CONFIG.url }) + i.fa.fa-home + p ホーム + li.messaging: a(onclick={ messaging }) + i.fa.fa-comments + p メッセージ + i.fa.fa-circle(if={ has-unread-messaging-messages }) + li.info: a(href='https://twitter.com/misskey_xyz', target='_blank') + i.fa.fa-info + p お知らせ + li.tv: a(href='https://misskey.tk', target='_blank') + i.fa.fa-television + p MisskeyTV™ + +style. + display inline-block + margin 0 + padding 0 + line-height 3rem + vertical-align top + + > ul + display inline-block + margin 0 + padding 0 + vertical-align top + line-height 3rem + list-style none + + > li + display inline-block + vertical-align top + height 48px + line-height 48px + + &.active + > a + border-bottom solid 3px $theme-color + + > a + display inline-block + z-index 1 + height 100% + padding 0 24px + font-size 1em + font-variant small-caps + color #9eaba8 + text-decoration none + transition none + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + text-decoration none + + > i:first-child + margin-right 8px + + > i:last-child + margin-left 5px + vertical-align super + font-size 10px + color $theme-color + + @media (max-width 1100px) + margin-left -5px + + > p + display inline + margin 0 + + @media (max-width 1100px) + display none + + @media (max-width 700px) + padding 0 12px + +script. + @mixin \i + @mixin \api + @mixin \stream + + @page = @opts.page + + @on \mount ~> + @stream.on \read_all_messaging_messages @on-read-all-messaging-messages + @stream.on \unread_messaging_message @on-unread-messaging-message + + # Fetch count of unread messaging messages + @api \messaging/unread + .then (count) ~> + if count.count > 0 + @has-unread-messaging-messages = true + @update! + + @on \unmount ~> + @stream.off \read_all_messaging_messages @on-read-all-messaging-messages + @stream.off \unread_messaging_message @on-unread-messaging-message + + @on-read-all-messaging-messages = ~> + @has-unread-messaging-messages = false + @update! + + @on-unread-messaging-message = ~> + @has-unread-messaging-messages = true + @update! + + @messaging = ~> + riot.mount document.body.append-child document.create-element \mk-messaging-window diff --git a/src/web/app/desktop/tags/ui-header-notifications.tag b/src/web/app/desktop/tags/ui-header-notifications.tag new file mode 100644 index 0000000000..495aad5004 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-notifications.tag @@ -0,0 +1,111 @@ +mk-ui-header-notifications + button.header(data-active={ is-open }, onclick={ toggle }) + i.fa.fa-bell-o + div.notifications(if={ is-open }) + mk-notifications + +style. + display block + float left + + > .header + display block + margin 0 + padding 0 + width 32px + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + &[data-active='true'] + color darken(#9eaba8, 20%) + + > i + font-size 1.2em + line-height 48px + + > .notifications + display block + position absolute + top 56px + right -72px + width 300px + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + > mk-notifications + max-height 350px + font-size 1rem + overflow auto + +script. + @is-open = false + + @toggle = ~> + if @is-open + @close! + else + @open! + + @open = ~> + @is-open = true + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + + @close = ~> + @is-open = false + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + + @mousedown = (e) ~> + e.prevent-default! + if (!contains @root, e.target) and (@root != e.target) + @close! + return false + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/ui-header-post-button.tag b/src/web/app/desktop/tags/ui-header-post-button.tag new file mode 100644 index 0000000000..558c987619 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-post-button.tag @@ -0,0 +1,39 @@ +mk-ui-header-post-button + button(onclick={ post }, title='新規投稿') + i.fa.fa-pencil-square-o + +style. + display inline-block + padding 8px + height 100% + vertical-align top + + > button + display inline-block + margin 0 + padding 0 10px + height 100% + font-size 1.2em + font-weight normal + text-decoration none + color $theme-color-foreground + background $theme-color !important + outline none + border none + border-radius 2px + transition background 0.1s ease + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 10%) !important + + &:active + background darken($theme-color, 10%) !important + transition background 0s ease + +script. + @post = (e) ~> + @parent.parent.open-post-form! diff --git a/src/web/app/desktop/tags/ui-header-search.tag b/src/web/app/desktop/tags/ui-header-search.tag new file mode 100644 index 0000000000..24e4e44989 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-search.tag @@ -0,0 +1,37 @@ +mk-ui-header-search + form.search(onsubmit={ onsubmit }) + input@q(type='search', placeholder!=' 検索') + div.result + +style. + + > form + display block + float left + + > input + user-select text + cursor auto + margin 0 + padding 6px 18px + width 14em + height 48px + font-size 1em + line-height calc(48px - 12px) + background transparent + outline none + //border solid 1px #ddd + border none + border-radius 0 + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &::-webkit-input-placeholder + color #9eaba8 + +script. + @mixin \page + + @onsubmit = (e) ~> + e.prevent-default! + @page '/search:' + @refs.q.value diff --git a/src/web/app/desktop/tags/ui-header.tag b/src/web/app/desktop/tags/ui-header.tag new file mode 100644 index 0000000000..b02817cd84 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header.tag @@ -0,0 +1,85 @@ +mk-ui-header + mk-donation(if={ SIGNIN && !I.data.no_donation }) + mk-special-message + div.main + div.backdrop + div.main: div.container + div.left + mk-ui-header-nav(page={ opts.page }) + div.right + mk-ui-header-search + mk-ui-header-account(if={ SIGNIN }) + mk-ui-header-notifications(if={ SIGNIN }) + mk-ui-header-post-button(if={ SIGNIN }) + mk-ui-header-clock + +style. + display block + position fixed + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > .main + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height 48px + backdrop-filter blur(12px) + //background-color rgba(255, 255, 255, 0.75) + background #fff + + &:after + content "" + display block + width 100% + height 48px + background-image url(/_/resources/desktop/header-logo.svg) + background-size 64px + background-position center + background-repeat no-repeat + + > .main + z-index 1024 + margin 0 + padding 0 + background-clip content-box + font-size 0.9rem + user-select none + + > .container + width 100% + max-width 1300px + margin 0 auto + + &:after + content "" + display block + clear both + + > .left + float left + height 3rem + + > .right + float right + height 48px + + @media (max-width 1100px) + > mk-ui-header-search + display none + +style(theme='dark'). + box-shadow 0 1px 0 #222221 + + > .main + + > .backdrop + background #0D0D0D + +script. + @mixin \i diff --git a/src/web/app/desktop/tags/ui-notification.tag b/src/web/app/desktop/tags/ui-notification.tag new file mode 100644 index 0000000000..6e5f948b88 --- /dev/null +++ b/src/web/app/desktop/tags/ui-notification.tag @@ -0,0 +1,41 @@ +mk-ui-notification + p { opts.message } + +style. + display block + position fixed + z-index 10000 + top -64px + left 0 + right 0 + margin 0 auto + width 500px + color rgba(#000, 0.6) + background rgba(#fff, 0.9) + border-radius 0 0 8px 8px + box-shadow 0 2px 4px rgba(#000, 0.2) + + > p + margin 0 + line-height 64px + text-align center + +script. + @on \mount ~> + Velocity @root, { + top: \0px + } { + duration: 500ms + easing: \ease-out + } + + set-timeout ~> + Velocity @root, { + top: \-64px + } { + duration: 500ms + easing: \ease-out + complete: ~> + @unmount! + } + , 6000ms diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag new file mode 100644 index 0000000000..6bced1f9e4 --- /dev/null +++ b/src/web/app/desktop/tags/ui.tag @@ -0,0 +1,37 @@ +mk-ui + div.global@global + mk-ui-header@header(page={ opts.page }) + + mk-set-avatar-suggestion(if={ SIGNIN && I.avatar_id == null }) + mk-set-banner-suggestion(if={ SIGNIN && I.banner_id == null }) + + div.content + <yield /> + + mk-stream-indicator + +style. + display block + +script. + @mixin \i + + @open-post-form = ~> + riot.mount document.body.append-child document.create-element \mk-post-form-window + + @set-root-layout = ~> + @root.style.padding-top = @refs.header.root.client-height + \px + + @on \mount ~> + @set-root-layout! + document.add-event-listener \keydown @onkeydown + + @on \unmount ~> + document.remove-event-listener \keydown @onkeydown + + @onkeydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 80 or e.which == 78 # p or n + e.prevent-default! + @open-post-form! diff --git a/src/web/app/desktop/tags/user-followers-window.tag b/src/web/app/desktop/tags/user-followers-window.tag new file mode 100644 index 0000000000..d18b04446c --- /dev/null +++ b/src/web/app/desktop/tags/user-followers-window.tag @@ -0,0 +1,22 @@ +mk-user-followers-window + mk-window(is-modal={ false }, width={ '400px' }, height={ '550px' }) + <yield to="header"> + img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='') + | { parent.user.name }のフォロワー + </yield> + <yield to="content"> + mk-user-followers(user={ parent.user }) + </yield> + +style. + > mk-window + [data-yield='header'] + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +script. + @user = @opts.user diff --git a/src/web/app/desktop/tags/user-followers.tag b/src/web/app/desktop/tags/user-followers.tag new file mode 100644 index 0000000000..52f9f43836 --- /dev/null +++ b/src/web/app/desktop/tags/user-followers.tag @@ -0,0 +1,19 @@ +mk-user-followers + mk-users-list(fetch={ fetch }, count={ user.followers_count }, you-know-count={ user.followers_you_know_count }, no-users={ 'フォロワーはいないようです。' }) + +style. + display block + height 100% + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/followers do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb diff --git a/src/web/app/desktop/tags/user-following-window.tag b/src/web/app/desktop/tags/user-following-window.tag new file mode 100644 index 0000000000..91f94f08d3 --- /dev/null +++ b/src/web/app/desktop/tags/user-following-window.tag @@ -0,0 +1,22 @@ +mk-user-following-window + mk-window(is-modal={ false }, width={ '400px' }, height={ '550px' }) + <yield to="header"> + img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='') + | { parent.user.name }のフォロー + </yield> + <yield to="content"> + mk-user-following(user={ parent.user }) + </yield> + +style. + > mk-window + [data-yield='header'] + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +script. + @user = @opts.user diff --git a/src/web/app/desktop/tags/user-following.tag b/src/web/app/desktop/tags/user-following.tag new file mode 100644 index 0000000000..0a39f2e4b8 --- /dev/null +++ b/src/web/app/desktop/tags/user-following.tag @@ -0,0 +1,19 @@ +mk-user-following + mk-users-list(fetch={ fetch }, count={ user.following_count }, you-know-count={ user.following_you_know_count }, no-users={ 'フォロー中のユーザーはいないようです。' }) + +style. + display block + height 100% + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/following do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb diff --git a/src/web/app/desktop/tags/user-friends-graph.tag b/src/web/app/desktop/tags/user-friends-graph.tag new file mode 100644 index 0000000000..47c3a15613 --- /dev/null +++ b/src/web/app/desktop/tags/user-friends-graph.tag @@ -0,0 +1,64 @@ +mk-user-friends-graph + canvas@canv(width='750', height='250') + +style. + display block + width 750px + height 250px + +script. + @mixin \api + @mixin \is-promise + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + user <~ @user-promise.then + @user = user + @update! + + @api \aggregation/users/followers do + user_id: @user.id + limit: 30days + .then (followers) ~> + followers = followers.reverse! + + @api \aggregation/users/following do + user_id: @user.id + limit: 30days + .then (following) ~> + following = following.reverse! + + new Chart @refs.canv, do + type: \line + data: + labels: following.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \フォロー + data: following.map (x) ~> x.count + line-tension: 0 + border-width: 2 + fill: true + background-color: 'rgba(127, 221, 64, 0.2)' + point-background-color: \#fff + point-radius: 4 + point-border-width: 2 + border-color: \#7fdd40 + }, + { + label: \フォロワー + data: followers.map (x) ~> x.count + line-tension: 0 + border-width: 2 + fill: true + background-color: 'rgba(255, 99, 132, 0.2)' + point-background-color: \#fff + point-radius: 4 + point-border-width: 2 + border-color: \#FF6384 + } + ] + options: + responsive: false diff --git a/src/web/app/desktop/tags/user-graphs.tag b/src/web/app/desktop/tags/user-graphs.tag new file mode 100644 index 0000000000..f7f0fcd5e0 --- /dev/null +++ b/src/web/app/desktop/tags/user-graphs.tag @@ -0,0 +1,36 @@ +mk-user-graphs + section + h1 投稿 + mk-user-posts-graph(user={ opts.user }) + + section + h1 フォロー/フォロワー + mk-user-friends-graph(user={ opts.user }) + + section + h1 いいね + mk-user-likes-graph(user={ opts.user }) + +style. + display block + + > section + margin 16px 0 + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + + > h1 + margin 0 0 8px 0 + padding 0 16px + line-height 40px + font-size 1em + color #666 + border-bottom solid 1px #eee + + > *:not(h1) + margin 0 auto 16px auto + +script. + @on \mount ~> + @trigger \loaded diff --git a/src/web/app/desktop/tags/user-header.tag b/src/web/app/desktop/tags/user-header.tag new file mode 100644 index 0000000000..5abd79ff1c --- /dev/null +++ b/src/web/app/desktop/tags/user-header.tag @@ -0,0 +1,143 @@ +mk-user-header(data-is-dark-background={ user.banner_url != null }) + div.banner@banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }, onclick={ on-update-banner }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=150' }, alt='avatar') + div.title + p.name(href={ CONFIG.url + '/' + user.username }) { user.name } + p.username @{ user.username } + p.location(if={ user.location }) + i.fa.fa-map-marker + | { user.location } + footer + a(href={ '/' + user.username }) 投稿 + a(href={ '/' + user.username + '/media' }) メディア + a(href={ '/' + user.username + '/graphs' }) グラフ + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + +style. + $footer-height = 58px + + display block + background #fff + + &[data-is-dark-background] + > .banner + background-color #383838 + + > .title + color #fff + background linear-gradient(transparent, rgba(0, 0, 0, 0.7)) + + > .name + text-shadow 0 0 8px #000 + + > .banner + height 280px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + bottom 16px + left 16px + z-index 2 + width 150px + height 150px + margin 0 + border solid 3px #fff + border-radius 8px + box-shadow 1px 1px 3px rgba(0, 0, 0, 0.2) + + > .title + position absolute + bottom $footer-height + left 0 + width 100% + padding 0 0 8px 195px + color #656565 + font-family '游ゴシック', 'YuGothic', 'ヒラギノ角ゴ ProN W3', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'メイリオ', sans-serif + + > .name + display block + margin 0 + line-height 40px + font-weight bold + font-size 2em + + > .username + > .location + display inline-block + margin 0 16px 0 0 + line-height 20px + opacity 0.8 + + > i + margin-right 4px + + > footer + z-index 1 + height $footer-height + padding-left 195px + background #fff + + > a + display inline-block + margin 0 + width 100px + line-height $footer-height + color #555 + + > button + display block + position absolute + top 0 + right 0 + margin 8px + padding 0 + width $footer-height - 16px + line-height $footer-height - 16px - 2px + font-size 1.2em + color #777 + border solid 1px #eee + border-radius 4px + + &:hover + color #555 + border solid 1px #ddd + +script. + @mixin \i + @mixin \update-banner + @mixin \NotImplementedException + + @user = @opts.user + + @on \mount ~> + window.add-event-listener \load @scroll + window.add-event-listener \scroll @scroll + window.add-event-listener \resize @scroll + + @on \unmount ~> + window.remove-event-listener \load @scroll + window.remove-event-listener \scroll @scroll + window.remove-event-listener \resize @scroll + + @scroll = ~> + top = window.scroll-y + height = 280px + + pos = 50 - ((top / height) * 50) + @refs.banner.style.background-position = 'center ' + pos + '%' + + blur = top / 32 + if blur <= 10 + @refs.banner.style.filter = 'blur(' + blur + 'px)' + + @on-update-banner = ~> + if not @SIGNIN or @I.id != @user.id + return + + @update-banner @I, (i) ~> + @user.banner_url = i.banner_url + @update! diff --git a/src/web/app/desktop/tags/user-home.tag b/src/web/app/desktop/tags/user-home.tag new file mode 100644 index 0000000000..4bf0260ff6 --- /dev/null +++ b/src/web/app/desktop/tags/user-home.tag @@ -0,0 +1,40 @@ +mk-user-home + div.side + mk-user-profile(user={ user }) + mk-user-photos(user={ user }) + main + mk-user-timeline@tl(user={ user }) + +style. + display flex + justify-content center + + > * + > * + display block + //border solid 1px #eaeaea + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + &:not(:last-child) + margin-bottom 16px + + > main + flex 1 1 560px + max-width 560px + margin 0 + padding 16px 0 16px 16px + + > .side + flex 1 1 270px + max-width 270px + margin 0 + padding 16px 0 16px 0 + +script. + @user = @opts.user + + @on \mount ~> + @refs.tl.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/desktop/tags/user-likes-graph.tag b/src/web/app/desktop/tags/user-likes-graph.tag new file mode 100644 index 0000000000..e9d1428713 --- /dev/null +++ b/src/web/app/desktop/tags/user-likes-graph.tag @@ -0,0 +1,39 @@ +mk-user-likes-graph + canvas@canv(width='750', height='250') + +style. + display block + width 750px + height 250px + +script. + @mixin \api + @mixin \is-promise + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + user <~ @user-promise.then + @user = user + @update! + + @api \aggregation/users/like do + user_id: @user.id + limit: 30days + .then (likes) ~> + likes = likes.reverse! + + new Chart @refs.canv, do + type: \bar + data: + labels: likes.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \いいねした数 + data: likes.map (x) ~> x.count + background-color: \#F7796C + } + ] + options: + responsive: false diff --git a/src/web/app/desktop/tags/user-photos.tag b/src/web/app/desktop/tags/user-photos.tag new file mode 100644 index 0000000000..61a840ee61 --- /dev/null +++ b/src/web/app/desktop/tags/user-photos.tag @@ -0,0 +1,85 @@ +mk-user-photos + p.title + i.fa.fa-camera + | フォト + p.initializing(if={ initializing }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + div.stream(if={ !initializing && images.length > 0 }) + virtual(each={ image in images }) + div.img(style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }) + p.empty(if={ !initializing && images.length == 0 }) + | 写真はありません + +style. + display block + background #fff + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + > .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + background-clip content-box + border solid 2px transparent + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \is-promise + + @images = [] + @initializing = true + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @update! + + @api \users/posts do + user_id: @user.id + with_media: true + limit: 9posts + .then (posts) ~> + @initializing = false + posts.for-each (post) ~> + post.media.for-each (image) ~> + if @images.length < 9 + @images.push image + @update! diff --git a/src/web/app/desktop/tags/user-posts-graph.tag b/src/web/app/desktop/tags/user-posts-graph.tag new file mode 100644 index 0000000000..75f4ac4a67 --- /dev/null +++ b/src/web/app/desktop/tags/user-posts-graph.tag @@ -0,0 +1,68 @@ +mk-user-posts-graph + canvas@canv(width='750', height='250') + +style. + display block + width 750px + height 250px + +script. + @mixin \api + @mixin \is-promise + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + user <~ @user-promise.then + @user = user + @update! + + @api \aggregation/users/post do + user_id: @user.id + limit: 30days + .then (data) ~> + data = data.reverse! + new Chart @refs.canv, do + type: \line + data: + labels: data.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \投稿 + data: data.map (x) ~> x.posts + line-tension: 0 + point-radius: 0 + background-color: \#555 + border-color: \transparent + }, + { + label: \Repost + data: data.map (x) ~> x.reposts + line-tension: 0 + point-radius: 0 + background-color: \#a2d61e + border-color: \transparent + }, + { + label: \返信 + data: data.map (x) ~> x.replies + line-tension: 0 + point-radius: 0 + background-color: \#F7796C + border-color: \transparent + } + ] + options: + responsive: false + scales: + x-axes: [ + { + stacked: true + } + ] + y-axes: [ + { + stacked: true + } + ] diff --git a/src/web/app/desktop/tags/user-preview.tag b/src/web/app/desktop/tags/user-preview.tag new file mode 100644 index 0000000000..f299e6236e --- /dev/null +++ b/src/web/app/desktop/tags/user-preview.tag @@ -0,0 +1,143 @@ +mk-user-preview + virtual(if={ user != null }) + div.banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=512)' : '' }) + a.avatar(href={ CONFIG.url + '/' + user.username }, target='_blank'): img(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.title + p.name { user.name } + p.username @{ user.username } + div.bio { user.bio } + div.status + div + p 投稿 + a { user.posts_count } + div + p フォロー + a { user.following_count } + div + p フォロワー + a { user.followers_count } + mk-follow-button(if={ SIGNIN && user.id != I.id }, user={ user-promise }) + +style. + display block + position absolute + z-index 2048 + width 250px + background #fff + background-clip content-box + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + overflow hidden + + // https://github.com/riot/riot/issues/2081 + > virtual + display block + position relative + + > .banner + height 84px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + top 62px + left 13px + + > img + display block + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + + > .title + display block + padding 8px 0 8px 85px + + > .name + display block + margin 0 + font-weight bold + line-height 16px + color #656565 + + > .username + display block + margin 0 + line-height 16px + font-size 0.8em + color #999 + + > .bio + padding 0 16px + font-size 0.7em + color #555 + + > .status + padding 8px 16px + + > div + display inline-block + width 33% + + > p + margin 0 + font-size 0.7em + color #aaa + + > a + font-size 1em + color $theme-color + + > mk-follow-button + position absolute + top 92px + right 8px + +script. + @mixin \i + @mixin \api + + @u = @opts.user + @user = null + @user-promise = + if typeof @u == \string + new Promise (resolve, reject) ~> + @api \users/show do + user_id: if @u.0 == \@ then undefined else @u + username: if @u.0 == \@ then @u.substr 1 else undefined + .then (user) ~> + resolve user + else + Promise.resolve @u + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @update! + + Velocity @root, { + opacity: 0 + 'margin-top': \-8px + } 0ms + Velocity @root, { + opacity: 1 + 'margin-top': 0 + } { + duration: 200ms + easing: \ease-out + } + + @close = ~> + Velocity @root, { + opacity: 0 + 'margin-top': \-8px + } { + duration: 200ms + easing: \ease-out + complete: ~> @unmount! + } diff --git a/src/web/app/desktop/tags/user-profile.tag b/src/web/app/desktop/tags/user-profile.tag new file mode 100644 index 0000000000..195aefcdf7 --- /dev/null +++ b/src/web/app/desktop/tags/user-profile.tag @@ -0,0 +1,72 @@ +mk-user-profile + div.friend-form(if={ SIGNIN && I.id != user.id }) + mk-big-follow-button(user={ user }) + p.followed(if={ user.is_followed }) フォローされています + div.bio(if={ user.bio != '' }) { user.bio } + div.friends + p.following + i.fa.fa-angle-right + a(onclick={ show-following }) { user.following_count } + | 人を + b フォロー + p.followers + i.fa.fa-angle-right + a(onclick={ show-followers }) { user.followers_count } + | 人の + b フォロワー + +style. + display block + background #fff + + > *:first-child + border-top none !important + + > .friend-form + padding 16px + border-top solid 1px #eee + + > mk-big-follow-button + width 100% + + > .followed + margin 12px 0 0 0 + padding 0 + text-align center + line-height 24px + font-size 0.8em + color #71afc7 + background #eefaff + border-radius 4px + + > .bio + padding 16px + color #555 + border-top solid 1px #eee + + > .friends + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 8px 0 + + > i + margin-left 8px + margin-right 8px + +script. + @mixin \i + + @user = @opts.user + + @show-following = ~> + window = document.body.append-child document.create-element \mk-user-following-window + riot.mount window, do + user: @user + + @show-followers = ~> + window = document.body.append-child document.create-element \mk-user-followers-window + riot.mount window, do + user: @user diff --git a/src/web/app/desktop/tags/user-timeline.tag b/src/web/app/desktop/tags/user-timeline.tag new file mode 100644 index 0000000000..ced90e2e84 --- /dev/null +++ b/src/web/app/desktop/tags/user-timeline.tag @@ -0,0 +1,142 @@ +mk-user-timeline + header + span(data-is-active={ mode == 'default' }, onclick={ set-mode.bind(this, 'default') }) 投稿 + span(data-is-active={ mode == 'with-replies' }, onclick={ set-mode.bind(this, 'with-replies') }) 投稿と返信 + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-comments-o + | このユーザーはまだ何も投稿していないようです。 + mk-timeline@timeline + <yield to="footer"> + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + </yield> + +style. + display block + background #fff + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \api + @mixin \is-promise + @mixin \get-post-summary + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @is-loading = true + @is-empty = false + @more-loading = false + @unread-count = 0 + @mode = \default + + @on \mount ~> + document.add-event-listener \visibilitychange @window-on-visibilitychange, false + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @user-promise.then (user) ~> + @user = user + @update! + + @fetch ~> + @trigger \loaded + + @on \unmount ~> + document.remove-event-listener \visibilitychange @window-on-visibilitychange + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @fetch = (cb) ~> + @api \users/posts do + user_id: @user.id + with_replies: @mode == \with-replies + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + if cb? then cb! + .catch (err) ~> + console.error err + if cb? then cb! + + @more = ~> + if @more-loading or @is-loading or @refs.timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \users/posts do + user_id: @user.id + with_replies: @mode == \with-replies + max_id: @refs.timeline.tail!.id + .then (posts) ~> + @more-loading = false + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-stream-post = (post) ~> + @is-empty = false + @update! + @refs.timeline.add-post post + + if document.hidden + @unread-count++ + document.title = '(' + @unread-count + ') ' + @get-post-summary post + + @window-on-visibilitychange = ~> + if !document.hidden + @unread-count = 0 + document.title = 'Misskey' + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 16 # 遊び + @more! + + @set-mode = (mode) ~> + @update do + mode: mode + @fetch! diff --git a/src/web/app/desktop/tags/user.tag b/src/web/app/desktop/tags/user.tag new file mode 100644 index 0000000000..4d022e68c4 --- /dev/null +++ b/src/web/app/desktop/tags/user.tag @@ -0,0 +1,45 @@ +mk-user + div.user(if={ !fetching }) + header + mk-user-header(user={ user }) + div.body + mk-user-home(if={ page == 'home' }, user={ user }) + mk-user-graphs(if={ page == 'graphs' }, user={ user }) + +style. + display block + background #fff + + > .user + > header + max-width 560px + 270px + margin 0 auto + padding 0 16px + + > mk-user-header + border solid 1px rgba(0, 0, 0, 0.075) + border-top none + border-radius 0 0 6px 6px + overflow hidden + + > .body + max-width 560px + 270px + margin 0 auto + padding 0 16px + +script. + @mixin \api + + @username = @opts.user + @page = if @opts.page? then @opts.page else \home + @fetching = true + @user = null + + @on \mount ~> + @api \users/show do + username: @username + .then (user) ~> + @fetching = false + @user = user + @update! + @trigger \loaded diff --git a/src/web/app/desktop/tags/users-list.tag b/src/web/app/desktop/tags/users-list.tag new file mode 100644 index 0000000000..9ae96eed9f --- /dev/null +++ b/src/web/app/desktop/tags/users-list.tag @@ -0,0 +1,139 @@ +mk-users-list + nav: div + span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) + | すべて + span { opts.count } + // ↓ https://github.com/riot/riot/issues/2080 + span(if={ SIGNIN && opts.you-know-count != '' }, data-is-active={ mode == 'iknow' }, onclick={ set-mode.bind(this, 'iknow') }) + | 知り合い + span { opts.you-know-count } + + div.users(if={ !fetching && users.length != 0 }) + div(each={ users }): mk-list-user(user={ this }) + + button.more(if={ !fetching && next != null }, onclick={ more }, disabled={ more-fetching }) + span(if={ !more-fetching }) もっと + span(if={ more-fetching }) + | 読み込み中 + mk-ellipsis + + p.no(if={ !fetching && users.length == 0 }) + | { opts.no-users } + p.fetching(if={ fetching }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + height 100% + background #fff + + > nav + z-index 1 + box-shadow 0 1px 0 rgba(#000, 0.1) + + > div + display flex + justify-content center + margin 0 auto + max-width 600px + + > span + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + color #657786 + border-bottom solid 2px transparent + cursor pointer + + * + pointer-events none + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + cursor default + + > span + display inline-block + margin-left 4px + padding 2px 5px + font-size 12px + line-height 1 + color #888 + background #eee + border-radius 20px + + > .users + height calc(100% - 54px) + overflow auto + + > * + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + > * + max-width 600px + margin 0 auto + + > .no + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \i + + @limit = 30users + @mode = \all + + @fetching = true + @more-fetching = false + + @on \mount ~> + @fetch ~> + @trigger \loaded + + @fetch = (cb) ~> + @fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + null + @users = obj.users + @next = obj.next + @fetching = false + @update! + if cb? then cb! + + @more = ~> + @more-fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + @cursor + @users = @users.concat obj.users + @next = obj.next + @more-fetching = false + @update! + + @set-mode = (mode) ~> + @update do + mode: mode + + @fetch! diff --git a/src/web/app/desktop/tags/window.tag b/src/web/app/desktop/tags/window.tag new file mode 100644 index 0000000000..9732a6c552 --- /dev/null +++ b/src/web/app/desktop/tags/window.tag @@ -0,0 +1,515 @@ +mk-window(data-flexible={ is-flexible }, data-colored={ opts.colored }, ondragover={ ondragover }) + div.bg@bg(show={ is-modal }, onclick={ bg-click }) + div.main@main(tabindex='-1', data-is-modal={ is-modal }, onmousedown={ on-body-mousedown }, onkeydown={ on-keydown }) + div.body + header@header(onmousedown={ on-header-mousedown }) + h1(data-yield='header') + | <yield from="header"/> + button.close(if={ can-close }, onmousedown={ repel-move }, onclick={ close }, title='閉じる'): i.fa.fa-times + div.content(data-yield='content') + | <yield from="content"/> + div.handle.top(if={ can-resize }, onmousedown={ on-top-handle-mousedown }) + div.handle.right(if={ can-resize }, onmousedown={ on-right-handle-mousedown }) + div.handle.bottom(if={ can-resize }, onmousedown={ on-bottom-handle-mousedown }) + div.handle.left(if={ can-resize }, onmousedown={ on-left-handle-mousedown }) + div.handle.top-left(if={ can-resize }, onmousedown={ on-top-left-handle-mousedown }) + div.handle.top-right(if={ can-resize }, onmousedown={ on-top-right-handle-mousedown }) + div.handle.bottom-right(if={ can-resize }, onmousedown={ on-bottom-right-handle-mousedown }) + div.handle.bottom-left(if={ can-resize }, onmousedown={ on-bottom-left-handle-mousedown }) + +style. + display block + + > .bg + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 2048 + top 15% + left 0 + margin 0 + opacity 0 + pointer-events none + + &:focus + &:not([data-is-modal]) + > .body + box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > .handle + $size = 8px + + position absolute + + &.top + top -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.right + top 0 + right -($size) + width $size + height 100% + cursor ew-resize + + &.bottom + bottom -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.left + top 0 + left -($size) + width $size + height 100% + cursor ew-resize + + &.top-left + top -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.top-right + top -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + &.bottom-right + bottom -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.bottom-left + bottom -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + > .body + height 100% + overflow hidden + background #fff + border-radius 6px + box-shadow 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > header + z-index 128 + overflow hidden + cursor move + background #fff + border-radius 6px 6px 0 0 + box-shadow 0 1px 0 rgba(#000, 0.1) + + &, * + user-select none + + > h1 + pointer-events none + display block + margin 0 + height 40px + text-align center + font-size 1em + line-height 40px + font-weight normal + color #666 + + > .close + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color rgba(#000, 0.4) + border none + outline none + background transparent + + &:hover + color rgba(#000, 0.6) + + &:active + color darken(#000, 30%) + + > i + padding 0 + width 40px + line-height 40px + + > .content + height 100% + + &:not([flexible]) + > .main > .body > .content + height calc(100% - 40px) + + &[data-colored] + + > .main > .body + + > header + box-shadow 0 1px 0 rgba($theme-color, 0.1) + + > h1 + color #d0b4ac + + > .close + color rgba($theme-color, 0.4) + + &:hover + color rgba($theme-color, 0.6) + + &:active + color darken($theme-color, 30%) + +script. + @min-height = 40px + @min-width = 200px + + @is-modal = if @opts.is-modal? then @opts.is-modal else false + @can-close = if @opts.can-close? then @opts.can-close else true + @is-flexible = !@opts.height? + @can-resize = not @is-flexible + + @on \mount ~> + @refs.main.style.width = @opts.width || \530px + @refs.main.style.height = @opts.height || \auto + + @refs.main.style.top = \15% + @refs.main.style.left = (window.inner-width / 2) - (@refs.main.offset-width / 2) + \px + + @refs.header.add-event-listener \contextmenu (e) ~> + e.prevent-default! + + window.add-event-listener \resize @on-browser-resize + + @open! + + @on \unmount ~> + window.remove-event-listener \resize @on-browser-resize + + @on-browser-resize = ~> + position = @refs.main.get-bounding-client-rect! + browser-width = window.inner-width + browser-height = window.inner-height + window-width = @refs.main.offset-width + window-height = @refs.main.offset-height + + if position.left < 0 + @refs.main.style.left = 0 + + if position.top < 0 + @refs.main.style.top = 0 + + if position.left + window-width > browser-width + @refs.main.style.left = browser-width - window-width + \px + + if position.top + window-height > browser-height + @refs.main.style.top = browser-height - window-height + \px + + @open = ~> + @trigger \opening + + @top! + + if @is-modal + @refs.bg.style.pointer-events = \auto + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 1 + } { + queue: false + duration: 100ms + easing: \linear + } + + @refs.main.style.pointer-events = \auto + Velocity @refs.main, \finish true + Velocity @refs.main, {scale: 1.1} 0ms + Velocity @refs.main, { + opacity: 1 + scale: 1 + } { + queue: false + duration: 200ms + easing: \ease-out + } + + #@refs.main.focus! + + set-timeout ~> + @trigger \opened + , 300ms + + @close = ~> + @trigger \closing + + if @is-modal + @refs.bg.style.pointer-events = \none + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 0 + } { + queue: false + duration: 300ms + easing: \linear + } + + @refs.main.style.pointer-events = \none + Velocity @refs.main, \finish true + Velocity @refs.main, { + opacity: 0 + scale: 0.8 + } { + queue: false + duration: 300ms + easing: [ 0.5, -0.5, 1, 0.5 ] + } + + set-timeout ~> + @trigger \closed + , 300ms + + # 最前面へ移動します + @top = ~> + z = 0 + + ws = document.query-selector-all \mk-window + ws.for-each (w) !~> + if w == @root then return + m = w.query-selector ':scope > .main' + mz = Number(document.default-view.get-computed-style m, null .z-index) + if mz > z then z := mz + + if z > 0 + @refs.main.style.z-index = z + 1 + if @is-modal then @refs.bg.style.z-index = z + 1 + + @repel-move = (e) ~> + e.stop-propagation! + return true + + @bg-click = ~> + if @can-close + @close! + + @on-body-mousedown = (e) ~> + @top! + true + + # ヘッダー掴み時 + @on-header-mousedown = (e) ~> + e.prevent-default! + + if not contains @refs.main, document.active-element + @refs.main.focus! + + position = @refs.main.get-bounding-client-rect! + + click-x = e.client-x + click-y = e.client-y + move-base-x = click-x - position.left + move-base-y = click-y - position.top + browser-width = window.inner-width + browser-height = window.inner-height + window-width = @refs.main.offset-width + window-height = @refs.main.offset-height + + # 動かした時 + drag-listen (me) ~> + move-left = me.client-x - move-base-x + move-top = me.client-y - move-base-y + + # 上はみ出し + if move-top < 0 + move-top = 0 + + # 左はみ出し + if move-left < 0 + move-left = 0 + + # 下はみ出し + if move-top + window-height > browser-height + move-top = browser-height - window-height + + # 右はみ出し + if move-left + window-width > browser-width + move-left = browser-width - window-width + + @refs.main.style.left = move-left + \px + @refs.main.style.top = move-top + \px + + # 上ハンドル掴み時 + @on-top-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-y + height = parse-int((get-computed-style @refs.main, '').height, 10) + top = parse-int((get-computed-style @refs.main, '').top, 10) + + # 動かした時 + drag-listen (me) ~> + move = me.client-y - base + if top + move > 0 + if height + -move > @min-height + @apply-transform-height height + -move + @apply-transform-top top + move + else # 最小の高さより小さくなろうとした時 + @apply-transform-height @min-height + @apply-transform-top top + (height - @min-height) + else # 上のはみ出し時 + @apply-transform-height top + height + @apply-transform-top 0 + + # 右ハンドル掴み時 + @on-right-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-x + width = parse-int((get-computed-style @refs.main, '').width, 10) + left = parse-int((get-computed-style @refs.main, '').left, 10) + browser-width = window.inner-width + + # 動かした時 + drag-listen (me) ~> + move = me.client-x - base + if left + width + move < browser-width + if width + move > @min-width + @apply-transform-width width + move + else # 最小の幅より小さくなろうとした時 + @apply-transform-width @min-width + else # 右のはみ出し時 + @apply-transform-width browser-width - left + + # 下ハンドル掴み時 + @on-bottom-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-y + height = parse-int((get-computed-style @refs.main, '').height, 10) + top = parse-int((get-computed-style @refs.main, '').top, 10) + browser-height = window.inner-height + + # 動かした時 + drag-listen (me) ~> + move = me.client-y - base + if top + height + move < browser-height + if height + move > @min-height + @apply-transform-height height + move + else # 最小の高さより小さくなろうとした時 + @apply-transform-height @min-height + else # 下のはみ出し時 + @apply-transform-height browser-height - top + + # 左ハンドル掴み時 + @on-left-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-x + width = parse-int((get-computed-style @refs.main, '').width, 10) + left = parse-int((get-computed-style @refs.main, '').left, 10) + + # 動かした時 + drag-listen (me) ~> + move = me.client-x - base + if left + move > 0 + if width + -move > @min-width + @apply-transform-width width + -move + @apply-transform-left left + move + else # 最小の幅より小さくなろうとした時 + @apply-transform-width @min-width + @apply-transform-left left + (width - @min-width) + else # 左のはみ出し時 + @apply-transform-width left + width + @apply-transform-left 0 + + # 左上ハンドル掴み時 + @on-top-left-handle-mousedown = (e) ~> + @on-top-handle-mousedown e + @on-left-handle-mousedown e + + # 右上ハンドル掴み時 + @on-top-right-handle-mousedown = (e) ~> + @on-top-handle-mousedown e + @on-right-handle-mousedown e + + # 右下ハンドル掴み時 + @on-bottom-right-handle-mousedown = (e) ~> + @on-bottom-handle-mousedown e + @on-right-handle-mousedown e + + # 左下ハンドル掴み時 + @on-bottom-left-handle-mousedown = (e) ~> + @on-bottom-handle-mousedown e + @on-left-handle-mousedown e + + # 高さを適用 + @apply-transform-height = (height) ~> + @refs.main.style.height = height + \px + + # 幅を適用 + @apply-transform-width = (width) ~> + @refs.main.style.width = width + \px + + # Y座標を適用 + @apply-transform-top = (top) ~> + @refs.main.style.top = top + \px + + # X座標を適用 + @apply-transform-left = (left) ~> + @refs.main.style.left = left + \px + + function drag-listen fn + window.add-event-listener \mousemove fn + window.add-event-listener \mouseleave drag-clear.bind null fn + window.add-event-listener \mouseup drag-clear.bind null fn + + function drag-clear fn + window.remove-event-listener \mousemove fn + window.remove-event-listener \mouseleave drag-clear + window.remove-event-listener \mouseup drag-clear + + @ondragover = (e) ~> + e.data-transfer.drop-effect = \none + + @on-keydown = (e) ~> + if e.which == 27 # Esc + if @can-close + e.prevent-default! + e.stop-propagation! + @close! + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/dev/router.ls b/src/web/app/dev/router.ls new file mode 100644 index 0000000000..ac408b36ed --- /dev/null +++ b/src/web/app/dev/router.ls @@ -0,0 +1,51 @@ +# Router +#================================ + +route = require \page +page = null + +module.exports = (me) ~> + + # Routing + #-------------------------------- + + route \/ index + route \/apps apps + route \/app/new new-app + route \/app/:app app + route \* not-found + + # Handlers + #-------------------------------- + + function index + mount document.create-element \mk-index + + function apps + mount document.create-element \mk-apps-page + + function new-app + mount document.create-element \mk-new-app-page + + function app ctx + document.create-element \mk-app-page + ..set-attribute \app ctx.params.app + .. |> mount + + function not-found + mount document.create-element \mk-not-found + + # Exec + #-------------------------------- + + route! + +# Mount +#================================ + +riot = require \riot + +function mount content + if page? then page.unmount! + body = document.get-element-by-id \app + page := riot.mount body.append-child content .0 diff --git a/src/web/app/dev/script.js b/src/web/app/dev/script.js new file mode 100644 index 0000000000..407f4e84c0 --- /dev/null +++ b/src/web/app/dev/script.js @@ -0,0 +1,15 @@ +/** + * Developer Center + */ + +require('./tags.ls'); +const boot = require('../boot.ls'); +const route = require('./router.ls'); + +/** + * Boot + */ +boot(me => { + // Start routing + route(me); +}); diff --git a/src/web/app/dev/style.styl b/src/web/app/dev/style.styl new file mode 100644 index 0000000000..a7e51b8943 --- /dev/null +++ b/src/web/app/dev/style.styl @@ -0,0 +1,10 @@ +@import "../base" + +html + background-color #fff + +#init + background #100f0f + + > p + color $theme-color diff --git a/src/web/app/dev/tags.ls b/src/web/app/dev/tags.ls new file mode 100644 index 0000000000..7783402634 --- /dev/null +++ b/src/web/app/dev/tags.ls @@ -0,0 +1,5 @@ +require './tags/pages/index.tag' +require './tags/pages/apps.tag' +require './tags/pages/app.tag' +require './tags/pages/new-app.tag' +require './tags/new-app-form.tag' diff --git a/src/web/app/dev/tags/new-app-form.tag b/src/web/app/dev/tags/new-app-form.tag new file mode 100644 index 0000000000..443bf2bfff --- /dev/null +++ b/src/web/app/dev/tags/new-app-form.tag @@ -0,0 +1,260 @@ +mk-new-app-form + form(onsubmit={ onsubmit }, autocomplete='off') + section.name: label + p.caption + | アプリケーション名 + input@name( + type='text' + placeholder='ex) Misskey for iOS' + autocomplete='off' + required) + + section.nid: label + p.caption + | Named ID + input@nid( + type='text' + pattern='^[a-zA-Z0-9\-]{3,30}$' + placeholder='ex) misskey-for-ios' + autocomplete='off' + required + onkeyup={ on-change-nid }) + + p.info(if={ nid-state == 'wait' }, style='color:#999') + i.fa.fa-fw.fa-spinner.fa-pulse + | 確認しています... + p.info(if={ nid-state == 'ok' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 利用できます + p.info(if={ nid-state == 'unavailable' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 既に利用されています + p.info(if={ nid-state == 'error' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 通信エラー + p.info(if={ nid-state == 'invalid-format' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | a~z、A~Z、0~9、-(ハイフン)が使えます + p.info(if={ nid-state == 'min-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 3文字以上でお願いします! + p.info(if={ nid-state == 'max-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 30文字以内でお願いします + + section.description: label + p.caption + | アプリの概要 + textarea@description( + placeholder='ex) Misskey iOSクライアント。' + autocomplete='off' + required) + + section.callback: label + p.caption + | コールバックURL (オプション) + input@cb( + type='url' + placeholder='ex) https://your.app.example.com/callback.php' + autocomplete='off') + + section.permission + p.caption + | 権限 + div@permission + label + input(type='checkbox', value='account-read') + p アカウントの情報を見る。 + label + input(type='checkbox', value='account-write') + p アカウントの情報を操作する。 + label + input(type='checkbox', value='post-write') + p 投稿する。 + label + input(type='checkbox', value='like-write') + p いいねしたりいいね解除する。 + label + input(type='checkbox', value='following-write') + p フォローしたりフォロー解除する。 + label + input(type='checkbox', value='drive-read') + p ドライブを見る。 + label + input(type='checkbox', value='drive-write') + p ドライブを操作する。 + label + input(type='checkbox', value='notification-read') + p 通知を見る。 + label + input(type='checkbox', value='notification-write') + p 通知を操作する。 + p + i.fa.fa-exclamation-triangle + | アプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーキーはすべて無効になります。 + + button(onclick={ onsubmit }) + | アプリ作成 + +style. + display block + overflow hidden + + > form + + section + display block + margin 16px 0 + + .caption + margin 0 0 4px 0 + color #616161 + font-size 0.95em + + > i + margin-right 0.25em + color #96adac + + .info + display block + margin 4px 0 + font-size 0.8em + + > i + margin-right 0.3em + + section.permission + div + padding 8px 0 + max-height 160px + overflow auto + background #fff + border solid 1px #cecece + border-radius 4px + + label + display block + padding 0 12px + line-height 32px + cursor pointer + + &:hover + > p + color #999 + + [type='checkbox']:checked + p + color #000 + + [type='checkbox'] + margin-right 4px + + [type='checkbox']:checked + p + color #111 + + > p + display inline + color #aaa + user-select none + + > p:last-child + margin 6px + font-size 0.8em + color #999 + + > i + margin-right 4px + + [type=text] + [type=url] + textarea + user-select text + display inline-block + cursor auto + padding 8px 12px + margin 0 + width 100% + font-size 1em + color #333 + background #fff + outline none + border solid 1px #cecece + border-radius 4px + + &:hover + border-color #bbb + + &:focus + border-color $theme-color + + &:disabled + opacity 0.5 + + > button + margin 20px 0 32px 0 + width 100% + font-size 1em + color #111 + border-radius 3px + +script. + @mixin \api + + @nid-state = null + + @on-change-nid = ~> + nid = @refs.nid.value + + if nid == '' + @nid-state = null + @update! + return + + err = switch + | not nid.match /^[a-zA-Z0-9\-]+$/ => \invalid-format + | nid.length < 3chars => \min-range + | nid.length > 30chars => \max-range + | _ => null + + if err? + @nid-state = err + @update! + else + @nid-state = \wait + @update! + + @api \app/name_id/available do + name_id: nid + .then (result) ~> + if result.available + @nid-state = \ok + else + @nid-state = \unavailable + @update! + .catch (err) ~> + @nid-state = \error + @update! + + @onsubmit = ~> + name = @refs.name.value + nid = @refs.nid.value + description = @refs.description.value + cb = @refs.cb.value + permission = [] + + @refs.permission.query-selector-all \input .for-each (el) ~> + if el.checked then permission.push el.value + + locker = document.body.append-child document.create-element \mk-locker + + @api \app/create do + name: name + name_id: nid + description: description + callback_url: cb + permission: permission.join \, + .then ~> + location.href = '/apps' + .catch ~> + alert 'アプリの作成に失敗しました。再度お試しください。' + + locker.parent-node.remove-child locker diff --git a/src/web/app/dev/tags/pages/app.tag b/src/web/app/dev/tags/pages/app.tag new file mode 100644 index 0000000000..aa9ba68f3f --- /dev/null +++ b/src/web/app/dev/tags/pages/app.tag @@ -0,0 +1,24 @@ +mk-app-page + p(if={ fetching }) 読み込み中 + main(if={ !fetching }) + header + h1 { app.name } + div.body + p App Secret + input(value={ app.secret }, readonly) + +style. + display block + +script. + @mixin \api + + @fetching = true + + @on \mount ~> + @api \app/show do + app_id: @opts.app + .then (app) ~> + @app = app + @fetching = false + @update! diff --git a/src/web/app/dev/tags/pages/apps.tag b/src/web/app/dev/tags/pages/apps.tag new file mode 100644 index 0000000000..f46a9d3282 --- /dev/null +++ b/src/web/app/dev/tags/pages/apps.tag @@ -0,0 +1,26 @@ +mk-apps-page + h1 アプリを管理 + a(href='/app/new') アプリ作成 + div.apps + p(if={ fetching }) 読み込み中 + virtual(if={ !fetching }) + p(if={ apps.length == 0 }) アプリなし + ul(if={ apps.length > 0 }) + li(each={ app in apps }) + a(href={ '/app/' + app.id }) + p.name { app.name } + +style. + display block + +script. + @mixin \api + + @fetching = true + + @on \mount ~> + @api \my/apps + .then (apps) ~> + @fetching = false + @apps = apps + @update! diff --git a/src/web/app/dev/tags/pages/index.tag b/src/web/app/dev/tags/pages/index.tag new file mode 100644 index 0000000000..7bc57fbb00 --- /dev/null +++ b/src/web/app/dev/tags/pages/index.tag @@ -0,0 +1,5 @@ +mk-index + a(href='/apps') アプリ + +style. + display block diff --git a/src/web/app/dev/tags/pages/new-app.tag b/src/web/app/dev/tags/pages/new-app.tag new file mode 100644 index 0000000000..8c19e39f4b --- /dev/null +++ b/src/web/app/dev/tags/pages/new-app.tag @@ -0,0 +1,33 @@ +mk-new-app-page + main + header + h1 新しいアプリを作成 + p MisskeyのAPIを利用したアプリケーションを作成できます。 + mk-new-app-form + +style. + display block + padding 64px 0 + + > main + width 100% + max-width 700px + margin 0 auto + + > header + margin 0 0 16px 0 + padding 0 0 16px 0 + border-bottom solid 1px #282827 + + > h1 + margin 0 0 12px 0 + padding 0 + line-height 32px + font-size 32px + font-weight normal + color #000 + + > p + margin 0 + line-height 16px + color #9a9894 diff --git a/src/web/app/dev/view.pug b/src/web/app/dev/view.pug new file mode 100644 index 0000000000..aea2f2adb8 --- /dev/null +++ b/src/web/app/dev/view.pug @@ -0,0 +1,5 @@ +extends ../base + +block head + link(rel='stylesheet', href='/_/resources/dev/style.css') + script(src='/_/resources/dev/script.js', async, defer) diff --git a/src/web/app/init.styl b/src/web/app/init.styl new file mode 100644 index 0000000000..972997725d --- /dev/null +++ b/src/web/app/init.styl @@ -0,0 +1,56 @@ +@charset 'utf-8' + +html + font-family sans-serif + +body > noscript > div + position fixed + z-index 32768 + top 0 + left 0 + width 100% + height 100% + text-align center + background #fff + + > p + display block + margin 32px + font-size 2em + color #555 + +#init + position fixed + z-index 16384 + top 0 + left 0 + width 100% + height 100% + text-align center + background #fff + cursor wait + + > p + display block + user-select none + margin 32px + font-size 4em + color #555 + + > span + animation init 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes init + 0%, 80%, 100% + opacity 1 + 40% + opacity 0 diff --git a/src/web/app/mobile/mixins.ls b/src/web/app/mobile/mixins.ls new file mode 100644 index 0000000000..902774f91a --- /dev/null +++ b/src/web/app/mobile/mixins.ls @@ -0,0 +1,19 @@ +riot = require \riot + +module.exports = (me) ~> + if me? + (require './scripts/stream.ls') me + + require './scripts/ui.ls' + + riot.mixin \open-post-form do + open-post-form: (opts) -> + app = document.get-element-by-id \app + app.style.display = \none + form = document.body.append-child document.create-element \mk-post-form + form = riot.mount form, opts .0 + form.on \cancel recover + form.on \post recover + + function recover + app.style.display = \block diff --git a/src/web/app/mobile/router.ls b/src/web/app/mobile/router.ls new file mode 100644 index 0000000000..33ae3e82da --- /dev/null +++ b/src/web/app/mobile/router.ls @@ -0,0 +1,110 @@ +# Router +#================================ + +riot = require \riot +route = require \page +page = null + +module.exports = (me) ~> + + # Routing + #-------------------------------- + + route \/ index + route \/i/notifications notifications + route \/i/drive drive + route \/i/drive/folder/:folder drive + route \/i/drive/file/:file drive + route \/post/new new-post + route \/post::post post + route \/search::query search + route \/:user user.bind null \posts + route \/:user/graphs user.bind null \graphs + route \/:user/followers user-followers + route \/:user/following user-following + route \/:user/:post post + route \* not-found + + # Handlers + #-------------------------------- + + # / + function index + if me? then home! else entrance! + + # ホーム + function home + mount document.create-element \mk-home-page + + # 玄関 + function entrance + mount document.create-element \mk-entrance + + # 通知 + function notifications + mount document.create-element \mk-notifications-page + + # 新規投稿 + function new-post + mount document.create-element \mk-new-post-page + + # 検索 + function search ctx + document.create-element \mk-search-page + ..set-attribute \query ctx.params.query + .. |> mount + + # ユーザー + function user page, ctx + document.create-element \mk-user-page + ..set-attribute \user ctx.params.user + ..set-attribute \page page + .. |> mount + + # フォロー一覧 + function user-following ctx + document.create-element \mk-user-following-page + ..set-attribute \user ctx.params.user + .. |> mount + + # フォロワー一覧 + function user-followers ctx + document.create-element \mk-user-followers-page + ..set-attribute \user ctx.params.user + .. |> mount + + # 投稿詳細ページ + function post ctx + document.create-element \mk-post-page + ..set-attribute \post ctx.params.post + .. |> mount + + # ドライブ + function drive ctx + p = document.create-element \mk-drive-page + if ctx.params.folder then p.set-attribute \folder ctx.params.folder + if ctx.params.file then p.set-attribute \file ctx.params.file + mount p + + # not found + function not-found + mount document.create-element \mk-not-found + + # Register mixin + #-------------------------------- + + riot.mixin \page do + page: route + + # Exec + #-------------------------------- + + route! + +# Mount +#================================ + +function mount content + if page? then page.unmount! + body = document.get-element-by-id \app + page := riot.mount body.append-child content .0 diff --git a/src/web/app/mobile/script.js b/src/web/app/mobile/script.js new file mode 100644 index 0000000000..1c269a57d9 --- /dev/null +++ b/src/web/app/mobile/script.js @@ -0,0 +1,20 @@ +/** + * Mobile Client + */ + +require('./tags.ls'); +require('./scripts/sp-slidemenu.js'); +const boot = require('../boot.ls'); +const mixins = require('./mixins.ls'); +const route = require('./router.ls'); + +/** + * Boot + */ +boot(me => { + // Register mixins + mixins(me); + + // Start routing + route(me); +}); diff --git a/src/web/app/mobile/scripts/sp-slidemenu.js b/src/web/app/mobile/scripts/sp-slidemenu.js new file mode 100644 index 0000000000..f2dcae9cef --- /dev/null +++ b/src/web/app/mobile/scripts/sp-slidemenu.js @@ -0,0 +1,839 @@ +/** + * sp-slidemenu.js + * + * @version 0.1.0 + * @url https://github.com/be-hase/sp-slidemenu + * + * Copyright 2013 be-hase.com, Inc. + * Licensed under the MIT License: + * http://www.opensource.org/licenses/mit-license.php + */ + +/** + * CUSTOMIZED BY SYUILO + */ + +; (function(window, document, undefined) { + "use strict"; + var div, PREFIX, support, gestureStart, EVENTS, ANIME_SPEED, SLIDE_STATUS, SCROLL_STATUS, THRESHOLD, EVENT_MOE_TIME, rclass, ITEM_CLICK_CLASS_NAME; + div = document.createElement('div'); + PREFIX = ['webkit', 'moz', 'o', 'ms']; + support = SpSlidemenu.support = {}; + support.transform3d = hasProp([ + 'perspectiveProperty', + 'WebkitPerspective', + 'MozPerspective', + 'OPerspective', + 'msPerspective' + ]); + support.transform = hasProp([ + 'transformProperty', + 'WebkitTransform', + 'MozTransform', + 'OTransform', + 'msTransform' + ]); + support.transition = hasProp([ + 'transitionProperty', + 'WebkitTransitionProperty', + 'MozTransitionProperty', + 'OTransitionProperty', + 'msTransitionProperty' + ]); + support.addEventListener = 'addEventListener' in window; + support.msPointer = window.navigator.msPointerEnabled; + support.cssAnimation = (support.transform3d || support.transform) && support.transition; + support.touch = 'ontouchend' in window; + EVENTS = { + start: { + touch: 'touchstart', + mouse: 'mousedown' + }, + move: { + touch: 'touchmove', + mouse: 'mousemove' + }, + end: { + touch: 'touchend', + mouse: 'mouseup' + } + }; + gestureStart = false; + if (support.addEventListener) { + document.addEventListener('gesturestart', function() { + gestureStart = true; + }); + document.addEventListener('gestureend', function() { + gestureStart = false; + }); + } + ANIME_SPEED = { + slider: 200, + scrollOverBack: 400 + }; + SLIDE_STATUS = { + close: 0, + open: 1, + progress: 2 + }; + THRESHOLD = 10; + EVENT_MOE_TIME = 50; + rclass = /[\t\r\n\f]/g; + ITEM_CLICK_CLASS_NAME = 'menu-item'; + /* + [MEMO] + SpSlidemenu properties which is not function is ... + -- element -- + element: main + element: slidemenu + element: button + element: slidemenuBody + element: slidemenuContent + element: slidemenuHeader + -- options -- + bool: disableCssAnimation + bool: disabled3d + -- animation -- + bool: useCssAnimation + bool: use3d + -- slide -- + int: slideWidth + string: htmlOverflowX + string: bodyOverflowX + int: buttonStartPageX + int: buttonStartPageY + -- scroll -- + bool: scrollTouchStarted + bool: scrollMoveReady + int: scrollStartPageX + int: scrollStartPageY + int: scrollBasePageY + int: scrollTimeForVelocity + int: scrollCurrentY + int: scrollMoveEventCnt + int: scrollAnimationTimer + int: scrollOverTimer + int: scrollMaxY + */ + function SpSlidemenu(main, slidemenu, button, options) { + if (this instanceof SpSlidemenu) { + return this.init(main, slidemenu, button, options); + } else { + return new SpSlidemenu(main, slidemenu, button, options); + } + } + SpSlidemenu.prototype.init = function(main, slidemenu, button, options) { + var _this = this; + // find and set element. + _this.setElement(main, slidemenu, button); + if (!_this.main || !_this.slidemenu || !_this.button || !_this.slidemenuBody || !_this.slidemenuContent) { + throw new Error('Element not found. Please set correctly.'); + } + // options + options = options || {}; + _this.disableCssAnimation = (options.disableCssAnimation === undefined) ? false : options.disableCssAnimation; + _this.disable3d = (options.disable3d === undefined) ? false : options.disable3d; + _this.direction = 'left'; + if (options.direction === 'right') { + _this.direction = 'right'; + } + // animation + _this.useCssAnimation = support.cssAnimation; + if (_this.disableCssAnimation === true) { + _this.useCssAnimation = false; + } + _this.use3d = support.transform3d; + if (_this.disable3d === true) { + _this.use3d = false; + } + // slide + _this.slideWidth = (getDimentions(_this.slidemenu)).width; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.close; + _this.htmlOverflowX = ''; + _this.bodyOverflowX = ''; + // scroll + _this.scrollCurrentY = 0; + _this.scrollAnimationTimer = false; + _this.scrollOverTimer = false; + // set default style. + _this.setDefaultStyle(); + // bind some method for callback. + _this.bindMethods(); + // add event + addTouchEvent('start', _this.button, _this.buttonTouchStart, false); + addTouchEvent('move', _this.button, blockEvent, false); + addTouchEvent('end', _this.button, _this.buttonTouchEnd, false); + addTouchEvent('start', _this.slidemenuContent, _this.scrollTouchStart, false); + addTouchEvent('move', _this.slidemenuContent, _this.scrollTouchMove, false); + addTouchEvent('end', _this.slidemenuContent, _this.scrollTouchEnd, false); + _this.slidemenuContent.addEventListener('click', _this.itemClick, false); + // window size change + window.addEventListener('resize', debounce(_this.setHeight, 100), false); + return _this; + }; + SpSlidemenu.prototype.bindMethods = function() { + var _this, funcs; + _this = this; + funcs = [ + 'setHeight', + 'slideOpen', 'slideOpenEnd', 'slideClose', 'slideCloseEnd', + 'buttonTouchStart', 'buttonTouchEnd', 'mainTouchStart', + 'scrollTouchStart', 'scrollTouchMove', 'scrollTouchEnd', 'scrollInertiaMove', 'scrollOverBack', 'scrollOver', + 'itemClick' + ]; + funcs.forEach(function(func) { + _this[func] = bind(_this[func], _this); + }); + }; + SpSlidemenu.prototype.setElement = function(main, slidemenu, button) { + var _this = this; + _this.main = main; + if (typeof main === 'string') { + _this.main = document.querySelector(main); + } + _this.slidemenu = slidemenu; + if (typeof slidemenu === 'string') { + _this.slidemenu = document.querySelector(slidemenu); + } + _this.button = button; + if (typeof button === 'string') { + _this.button = document.querySelector(button); + } + if (!_this.slidemenu) { + return; + } + _this.slidemenuBody = _this.slidemenu.querySelector('.body'); + _this.slidemenuContent = _this.slidemenu.querySelector('.content'); + _this.slidemenuHeader = _this.slidemenu.querySelector('.header'); + }; + SpSlidemenu.prototype.setDefaultStyle = function() { + var _this = this; + if (support.msPointer) { + _this.slidemenuContent.style.msTouchAction = 'none'; + } + _this.setHeight(); + if (_this.useCssAnimation) { + setStyles(_this.main, { + transitionProperty: getCSSName('transform'), + transitionTimingFunction: 'ease-in-out', + transitionDuration: ANIME_SPEED.slider + 'ms', + transitionDelay: '0ms', + transform: _this.getTranslateX(0) + }); + setStyles(_this.slidemenu, { + transitionProperty: 'visibility', + transitionTimingFunction: 'linear', + transitionDuration: '0ms', + transitionDelay: ANIME_SPEED.slider + 'ms' + }); + setStyles(_this.slidemenuContent, { + transitionProperty: getCSSName('transform'), + transitionTimingFunction: 'ease-in-out', + transitionDuration: '0ms', + transitionDelay: '0ms', + transform: _this.getTranslateY(0) + }); + } else { + setStyles(_this.main, { + position: 'relative', + left: '0px' + }); + setStyles(_this.slidemenuContent, { + top: '0px' + }); + } + }; + SpSlidemenu.prototype.setHeight = function(event) { + var _this, browserHeight; + _this = this; + browserHeight = getBrowserHeight(); + setStyles(_this.main, { + minHeight: browserHeight + 'px' + }); + setStyles(_this.slidemenu, { + height: browserHeight + 'px' + }); + }; + SpSlidemenu.prototype.buttonTouchStart = function(event) { + var _this = this; + event.preventDefault(); + event.stopPropagation(); + switch (_this.main.SpSlidemenuStatus) { + case SLIDE_STATUS.progress: + break; + case SLIDE_STATUS.open: + case SLIDE_STATUS.close: + _this.buttonStartPageX = getPage(event, 'pageX'); + _this.buttonStartPageY = getPage(event, 'pageY'); + break; + } + }; + SpSlidemenu.prototype.buttonTouchEnd = function(event) { + var _this = this; + event.preventDefault(); + event.stopPropagation(); + if (_this.shouldTrigerNext(event)) { + switch (_this.main.SpSlidemenuStatus) { + case SLIDE_STATUS.progress: + break; + case SLIDE_STATUS.open: + _this.slideClose(event); + break; + case SLIDE_STATUS.close: + _this.slideOpen(event); + break; + } + } + }; + SpSlidemenu.prototype.mainTouchStart = function(event) { + var _this = this; + event.preventDefault(); + event.stopPropagation(); + _this.slideClose(event); + }; + SpSlidemenu.prototype.shouldTrigerNext = function(event) { + var _this = this, + buttonEndPageX = getPage(event, 'pageX'), + buttonEndPageY = getPage(event, 'pageY'), + deltaX = Math.abs(buttonEndPageX - _this.buttonStartPageX), + deltaY = Math.abs(buttonEndPageY - _this.buttonStartPageY); + return deltaX < 20 && deltaY < 20; + }; + SpSlidemenu.prototype.slideOpen = function(event) { + var _this = this, toX; + + /// Misskey Original + document.body.setAttribute('data-nav-open', 'true'); + + if (_this.direction === 'left') { + toX = _this.slideWidth; + } else { + toX = -_this.slideWidth; + } + _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress; + //set event + addTouchEvent('move', document, blockEvent, false); + // change style + _this.htmlOverflowX = document.documentElement.style['overflowX']; + _this.bodyOverflowX = document.body.style['overflowX']; + document.documentElement.style['overflowX'] = document.body.style['overflowX'] = 'hidden'; + if (_this.useCssAnimation) { + setStyles(_this.main, { + transform: _this.getTranslateX(toX) + }); + setStyles(_this.slidemenu, { + transitionProperty: 'z-index', + visibility: 'visible', + zIndex: '1' + }); + } else { + animate(_this.main, _this.direction, toX, ANIME_SPEED.slider); + setStyles(_this.slidemenu, { + visibility: 'visible' + }); + } + // set callback + setTimeout(_this.slideOpenEnd, ANIME_SPEED.slider + EVENT_MOE_TIME); + }; + SpSlidemenu.prototype.slideOpenEnd = function() { + var _this = this; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.open; + // change style + if (_this.useCssAnimation) { + } else { + setStyles(_this.slidemenu, { + zIndex: '1' + }); + } + // add event + addTouchEvent('start', _this.main, _this.mainTouchStart, false); + }; + SpSlidemenu.prototype.slideClose = function(event) { + var _this = this; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress; + + /// Misskey Original + document.body.setAttribute('data-nav-open', 'false'); + + //event + removeTouchEvent('start', _this.main, _this.mainTouchStart, false); + // change style + if (_this.useCssAnimation) { + setStyles(_this.main, { + transform: _this.getTranslateX(0) + }); + setStyles(_this.slidemenu, { + transitionProperty: 'visibility', + visibility: 'hidden', + zIndex: '-1' + }); + } else { + animate(_this.main, _this.direction, 0, ANIME_SPEED.slider); + setStyles(_this.slidemenu, { + zIndex: '-1' + }); + } + // set callback + setTimeout(_this.slideCloseEnd, ANIME_SPEED.slider + EVENT_MOE_TIME); + }; + SpSlidemenu.prototype.slideCloseEnd = function() { + var _this = this; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.close; + // change style + document.documentElement.style['overflowX'] = _this.htmlOverflowX; + document.body.style['overflowX'] = _this.bodyOverflowX; + if (_this.useCssAnimation) { + } else { + setStyles(_this.slidemenu, { + visibility: 'hidden' + }); + } + // set event + removeTouchEvent('move', document, blockEvent, false); + }; + SpSlidemenu.prototype.scrollTouchStart = function(event) { + var _this = this; + if (gestureStart) { + return; + } + if (_this.scrollOverTimer !== false) { + clearTimeout(_this.scrollOverTimer); + } + _this.scrollCurrentY = _this.getScrollCurrentY(); + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'ease-in-out', + transitionDuration: '0ms', + transform: _this.getTranslateY(_this.scrollCurrentY) + }); + } else { + _this.stopScrollAnimate(); + setStyles(_this.slidemenuContent, { + top: _this.scrollCurrentY + 'px' + }); + } + _this.scrollOverTimer = false; + _this.scrollAnimationTimer = false; + _this.scrollTouchStarted = true; + _this.scrollMoveReady = false; + _this.scrollMoveEventCnt = 0; + _this.scrollMaxY = _this.calcMaxY(); + _this.scrollStartPageX = getPage(event, 'pageX'); + _this.scrollStartPageY = getPage(event, 'pageY'); + _this.scrollBasePageY = _this.scrollStartPageY; + _this.scrollTimeForVelocity = event.timeStamp; + _this.scrollPageYForVelocity = _this.scrollStartPageY; + _this.slidemenuContent.removeEventListener('click', blockEvent, true); + }; + SpSlidemenu.prototype.scrollTouchMove = function(event) { + var _this, pageX, pageY, distY, newY, deltaX, deltaY; + _this = this; + if (!_this.scrollTouchStarted || gestureStart) { + return; + } + pageX = getPage(event, 'pageX'); + pageY = getPage(event, 'pageY'); + if (_this.scrollMoveReady) { + event.preventDefault(); + event.stopPropagation(); + distY = pageY - _this.scrollBasePageY; + newY = _this.scrollCurrentY + distY; + if (newY > 0 || newY < _this.scrollMaxY) { + newY = Math.round(_this.scrollCurrentY + distY / 3); + } + _this.scrollSetY(newY); + if (_this.scrollMoveEventCnt % THRESHOLD === 0) { + _this.scrollPageYForVelocity = pageY; + _this.scrollTimeForVelocity = event.timeStamp; + } + _this.scrollMoveEventCnt++; + } else { + deltaX = Math.abs(pageX - _this.scrollStartPageX); + deltaY = Math.abs(pageY - _this.scrollStartPageY); + if (deltaX > 5 || deltaY > 5) { + _this.scrollMoveReady = true; + _this.slidemenuContent.addEventListener('click', blockEvent, true); + } + } + _this.scrollBasePageY = pageY; + }; + SpSlidemenu.prototype.scrollTouchEnd = function(event) { + var _this, speed, deltaY, deltaTime; + _this = this; + if (!_this.scrollTouchStarted) { + return; + } + _this.scrollTouchStarted = false; + _this.scrollMaxY = _this.calcMaxY(); + if (_this.scrollCurrentY > 0 || _this.scrollCurrentY < _this.scrollMaxY) { + _this.scrollOverBack(); + return; + } + deltaY = getPage(event, 'pageY') - _this.scrollPageYForVelocity; + deltaTime = event.timeStamp - _this.scrollTimeForVelocity; + speed = deltaY / deltaTime; + if (Math.abs(speed) >= 0.01) { + _this.scrollInertia(speed); + } + }; + SpSlidemenu.prototype.scrollInertia = function(speed) { + var _this, directionToTop, maxTo, distanceMaxTo, stopTime, canMove, to, duration, speedAtboundary, nextTo; + _this = this; + if (speed > 0) { + directionToTop = true; + maxTo = 0; + } else { + directionToTop = false; + maxTo = _this.scrollMaxY; + } + distanceMaxTo = Math.abs(_this.scrollCurrentY - maxTo); + speed = Math.abs(750 * speed); + if (speed > 1000) { + speed = 1000; + } + stopTime = speed / 500; + canMove = (speed * stopTime) - ((500 * Math.pow(stopTime, 2)) / 2); + if (canMove <= distanceMaxTo) { + if (directionToTop) { + to = _this.scrollCurrentY + canMove; + } else { + to = _this.scrollCurrentY - canMove; + } + duration = stopTime * 1000; + _this.scrollInertiaMove(to, duration, false); + } else { + to = maxTo; + speedAtboundary = Math.sqrt((2 * 500 * distanceMaxTo) + Math.pow(speed, 2)); + duration = (speedAtboundary - speed) / 500 * 1000; + _this.scrollInertiaMove(to, duration, true, speedAtboundary, directionToTop); + } + }; + SpSlidemenu.prototype.scrollInertiaMove = function(to, duration, isOver, speed, directionToTop) { + var _this = this, stopTime, canMove; + _this.scrollCurrentY = to; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)', + transitionDuration: duration + 'ms', + transform: _this.getTranslateY(to) + }); + } else { + _this.scrollAnimate(to, duration); + } + if (!isOver) { + return; + } + stopTime = speed / 7500; + canMove = (speed * stopTime) - ((7500 * Math.pow(stopTime, 2)) / 2); + if (directionToTop) { + to = _this.scrollCurrentY + canMove; + } else { + to = _this.scrollCurrentY - canMove; + } + duration = stopTime * 1000; + _this.scrollOver(to, duration); + }; + SpSlidemenu.prototype.scrollOver = function(to, duration) { + var _this; + _this = this; + _this.scrollCurrentY = to; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)', + transitionDuration: duration + 'ms', + transform: _this.getTranslateY(to) + }); + } else { + _this.scrollAnimate(to, duration); + } + _this.scrollOverTimer = setTimeout(_this.scrollOverBack, duration); + }; + SpSlidemenu.prototype.scrollOverBack = function() { + var _this, to; + _this = this; + if (_this.scrollCurrentY >= 0) { + to = 0; + } else { + to = _this.scrollMaxY; + } + _this.scrollCurrentY = to; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'ease-out', + transitionDuration: ANIME_SPEED.scrollOverBack + 'ms', + transform: _this.getTranslateY(to) + }); + } else { + _this.scrollAnimate(to, ANIME_SPEED.scrollOverBack); + } + }; + SpSlidemenu.prototype.scrollSetY = function(y) { + var _this = this; + _this.scrollCurrentY = y; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'ease-in-out', + transitionDuration: '0ms', + transform: _this.getTranslateY(y) + }); + } else { + _this.slidemenuContent.style.top = y + 'px'; + } + }; + SpSlidemenu.prototype.scrollAnimate = function(to, transitionDuration) { + var _this = this; + _this.stopScrollAnimate(); + _this.scrollAnimationTimer = animate(_this.slidemenuContent, 'top', to, transitionDuration); + }; + SpSlidemenu.prototype.stopScrollAnimate = function() { + var _this = this; + if (_this.scrollAnimationTimer !== false) { + clearInterval(_this.scrollAnimationTimer); + } + }; + SpSlidemenu.prototype.itemClick = function(event) { + var elem = event.target || event.srcElement; + if (hasClass(elem, ITEM_CLICK_CLASS_NAME)) { + this.slideClose(); + } + }; + SpSlidemenu.prototype.calcMaxY = function(x) { + var _this, contentHeight, bodyHeight, headerHeight; + _this = this; + contentHeight = _this.slidemenuContent.offsetHeight; + bodyHeight = _this.slidemenuBody.offsetHeight; + headerHeight = 0; + if (_this.slidemenuHeader) { + headerHeight = _this.slidemenuHeader.offsetHeight; + } + if (contentHeight > bodyHeight) { + return -(contentHeight - bodyHeight + headerHeight); + } else { + return 0; + } + }; + SpSlidemenu.prototype.getScrollCurrentY = function() { + var ret = 0; + if (this.useCssAnimation) { + getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'transform').split(',').forEach(function(value) { + var number = parseInt(value, 10); + if (!isNaN(number) && number !== 0 && number !== 1) { + ret = number; + } + }); + } else { + var number = parseInt(getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'top'), 10); + if (!isNaN(number) && number !== 0 && number !== 1) { + ret = number; + } + } + return ret; + }; + SpSlidemenu.prototype.getTranslateX = function(x) { + var _this = this; + return _this.use3d ? 'translate3d(' + x + 'px, 0px, 0px)' : 'translate(' + x + 'px, 0px)'; + }; + SpSlidemenu.prototype.getTranslateY = function(y) { + var _this = this; + return _this.use3d ? 'translate3d(0px, ' + y + 'px, 0px)' : 'translate(0px, ' + y + 'px)'; + }; + //Utility Function + function hasProp(props) { + return some(props, function(prop) { + return div.style[prop] !== undefined; + }); + } + function upperCaseFirst(str) { + return str.charAt(0).toUpperCase() + str.substr(1); + } + function some(ary, callback) { + var i, len; + for (i = 0, len = ary.length; i < len; i++) { + if (callback(ary[i], i)) { + return true; + } + } + return false; + } + function setStyle(elem, prop, val) { + var style = elem.style; + if (!setStyle.cache) { + setStyle.cache = {}; + } + if (setStyle.cache[prop] !== undefined) { + style[setStyle.cache[prop]] = val; + return; + } + if (style[prop] !== undefined) { + setStyle.cache[prop] = prop; + style[prop] = val; + return; + } + some(PREFIX, function(_prefix) { + var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop); + if (style[_prop] !== undefined) { + //setStyle.cache[prop] = _prop; + style[_prop] = val; + return true; + } + }); + } + function setStyles(elem, styles) { + var style, prop; + for (prop in styles) { + if (styles.hasOwnProperty(prop)) { + setStyle(elem, prop, styles[prop]); + } + } + } + function getStyle(style, prop) { + var ret; + if (style[prop] !== undefined) { + return style[prop]; + } + some(PREFIX, function(_prefix) { + var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop); + if (style[_prop] !== undefined) { + ret = style[_prop]; + return true; + } + }); + return ret; + } + function getCSSName(prop) { + var ret; + if (!getCSSName.cache) { + getCSSName.cache = {}; + } + if (getCSSName.cache[prop] !== undefined) { + return getCSSName.cache[prop]; + } + if (div.style[prop] !== undefined) { + getCSSName.cache[prop] = prop; + return prop; + } + some(PREFIX, function(_prefix) { + var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop); + if (div.style[_prop] !== undefined) { + ret = '-' + _prefix + '-' + prop; + return true; + } + }); + getCSSName.cache[prop] = ret; + return ret; + } + function bind(func, context) { + var nativeBind, slice, args; + nativeBind = Function.prototype.bind; + slice = Array.prototype.slice; + if (func.bind === nativeBind && nativeBind) { + return nativeBind.apply(func, slice.call(arguments, 1)); + } + args = slice.call(arguments, 2); + return function() { + return func.apply(context, args.concat(slice.call(arguments))); + }; + } + function blockEvent(event) { + event.preventDefault(); + event.stopPropagation(); + } + function getDimentions(element) { + var previous, key, properties, result; + previous = {}; + properties = { + position: 'absolute', + visibility: 'hidden', + display: 'block' + }; + for (key in properties) { + previous[key] = element.style[key]; + element.style[key] = properties[key]; + } + result = { + width: element.offsetWidth, + height: element.offsetHeight + }; + for (key in properties) { + element.style[key] = previous[key]; + } + return result; + } + function getPage(event, page) { + return event.changedTouches ? event.changedTouches[0][page] : event[page]; + } + function addTouchEvent(eventType, element, listener, useCapture) { + useCapture = useCapture || false; + if (support.touch) { + element.addEventListener(EVENTS[eventType].touch, listener, { passive: useCapture }); + } else { + element.addEventListener(EVENTS[eventType].mouse, listener, { passive: useCapture }); + } + } + function removeTouchEvent(eventType, element, listener, useCapture) { + useCapture = useCapture || false; + if (support.touch) { + element.removeEventListener(EVENTS[eventType].touch, listener, useCapture); + } else { + element.removeEventListener(EVENTS[eventType].mouse, listener, useCapture); + } + } + function hasClass(elem, className) { + className = " " + className + " "; + if (elem.nodeType === 1 && (" " + elem.className + " ").replace(rclass, " ").indexOf(className) >= 0) { + return true; + } + return false; + } + function animate(elem, prop, to, transitionDuration) { + var begin, from, duration, easing, timer; + begin = +new Date(); + from = parseInt(elem.style[prop], 10); + to = parseInt(to, 10); + duration = parseInt(transitionDuration, 10); + easing = function(time, duration) { + return -(time /= duration) * (time - 2); + }; + timer = setInterval(function() { + var time, pos, now; + time = new Date() - begin; + if (time > duration) { + clearInterval(timer); + now = to; + } else { + pos = easing(time, duration); + now = pos * (to - from) + from; + } + elem.style[prop] = now + 'px'; + }, 10); + return timer; + } + function getBrowserHeight() { + if (window.innerHeight) { + return window.innerHeight; + } + else if (document.documentElement && document.documentElement.clientHeight !== 0) { + return document.documentElement.clientHeight; + } + else if (document.body) { + return document.body.clientHeight; + } + return 0; + } + function debounce(func, wait, immediate) { + var timeout, result; + return function() { + var context = this, args = arguments; + var later = function() { + timeout = null; + if (!immediate) result = func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) result = func.apply(context, args); + return result; + }; + } + window.SpSlidemenu = SpSlidemenu; +})(window, window.document); diff --git a/src/web/app/mobile/scripts/stream.ls b/src/web/app/mobile/scripts/stream.ls new file mode 100644 index 0000000000..b7810b49ae --- /dev/null +++ b/src/web/app/mobile/scripts/stream.ls @@ -0,0 +1,13 @@ +# Stream +#================================ + +stream = require '../../common/scripts/stream.ls' +riot = require \riot + +module.exports = (me) ~> + s = stream me + + riot.mixin \stream do + stream: s.event + get-stream-state: s.get-state + stream-state-ev: s.state-ev diff --git a/src/web/app/mobile/scripts/ui.ls b/src/web/app/mobile/scripts/ui.ls new file mode 100644 index 0000000000..aa94a8b052 --- /dev/null +++ b/src/web/app/mobile/scripts/ui.ls @@ -0,0 +1,6 @@ +riot = require \riot + +ui = riot.observable! + +riot.mixin \ui do + ui: ui diff --git a/src/web/app/mobile/style.styl b/src/web/app/mobile/style.styl new file mode 100644 index 0000000000..bc7859844a --- /dev/null +++ b/src/web/app/mobile/style.styl @@ -0,0 +1,12 @@ +@import "../base" + +body[data-nav-open='true'] + #hamburger + > i + -webkit-transform rotate(-90deg) + transform rotate(-90deg) + +#wait + top auto + bottom 15px + left 15px diff --git a/src/web/app/mobile/tags.ls b/src/web/app/mobile/tags.ls new file mode 100644 index 0000000000..5805cb6b28 --- /dev/null +++ b/src/web/app/mobile/tags.ls @@ -0,0 +1,44 @@ +require './tags/ui.tag' +require './tags/ui-header.tag' +require './tags/ui-nav.tag' +require './tags/stream-indicator.tag' +require './tags/page/entrance.tag' +require './tags/page/entrance/signin.tag' +require './tags/page/entrance/signup.tag' +require './tags/page/home.tag' +require './tags/page/drive.tag' +require './tags/page/notifications.tag' +require './tags/page/user.tag' +require './tags/page/user-followers.tag' +require './tags/page/user-following.tag' +require './tags/page/post.tag' +require './tags/page/new-post.tag' +require './tags/page/search.tag' +require './tags/home.tag' +require './tags/home-timeline.tag' +require './tags/timeline.tag' +require './tags/timeline-post.tag' +require './tags/timeline-post-sub.tag' +require './tags/post-preview.tag' +require './tags/sub-post-content.tag' +require './tags/images-viewer.tag' +require './tags/drive.tag' +require './tags/drive-selector.tag' +require './tags/drive/file.tag' +require './tags/drive/folder.tag' +require './tags/drive/file-viewer.tag' +require './tags/post-form.tag' +require './tags/notification.tag' +require './tags/notifications.tag' +require './tags/notify.tag' +require './tags/notification-preview.tag' +require './tags/search.tag' +require './tags/search-posts.tag' +require './tags/post-detail.tag' +require './tags/user.tag' +require './tags/user-timeline.tag' +require './tags/follow-button.tag' +require './tags/user-preview.tag' +require './tags/users-list.tag' +require './tags/user-following.tag' +require './tags/user-followers.tag' diff --git a/src/web/app/mobile/tags/drive-selector.tag b/src/web/app/mobile/tags/drive-selector.tag new file mode 100644 index 0000000000..442299026e --- /dev/null +++ b/src/web/app/mobile/tags/drive-selector.tag @@ -0,0 +1,75 @@ +mk-drive-selector + div.body + header + h1 + | ファイルを選択 + span.count(if={ files.length > 0 }) ({ files.length }) + button.close(onclick={ cancel }): i.fa.fa-times + button.ok(onclick={ ok }): i.fa.fa-check + mk-drive@browser(select={ true }, multiple={ opts.multiple }) + +style. + display block + + > .body + position fixed + z-index 2048 + top 0 + left 0 + right 0 + margin 0 auto + width 100% + max-width 500px + height 100% + overflow hidden + background #fff + box-shadow 0 0 16px rgba(#000, 0.3) + + > header + border-bottom solid 1px #eee + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .count + margin-left 4px + opacity 0.5 + + > .close + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > mk-drive + height calc(100% - 42px) + overflow scroll + +script. + @files = [] + + @on \mount ~> + @refs.browser.on \change-selected (files) ~> + @files = files + @update! + + @cancel = ~> + @trigger \canceled + @unmount! + + @ok = ~> + @trigger \selected @files + @unmount! diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag new file mode 100644 index 0000000000..fcc78d1e68 --- /dev/null +++ b/src/web/app/mobile/tags/drive.tag @@ -0,0 +1,338 @@ +mk-drive + nav + p(onclick={ go-root }) + i.fa.fa-cloud + | ドライブ + virtual(each={ folder in hierarchy-folders }) + span: i.fa.fa-angle-right + p(onclick={ _move }) { folder.name } + span(if={ folder != null }): i.fa.fa-angle-right + p(if={ folder != null }) { folder.name } + div.browser(if={ file == null }, class={ loading: loading }) + div.folders(if={ folders.length > 0 }) + virtual(each={ folder in folders }) + mk-drive-folder(folder={ folder }) + p(if={ more-folders }) + | もっと読み込む + div.files(if={ files.length > 0 }) + virtual(each={ file in files }) + mk-drive-file(file={ file }) + p(if={ more-files }) + | もっと読み込む + div.empty(if={ files.length == 0 && folders.length == 0 && !loading }) + p(if={ !folder == null }) + | ドライブには何もありません。 + p(if={ folder != null }) + | このフォルダーは空です + div.loading(if={ loading }). + <div class="spinner"> + <div class="dot1"></div> + <div class="dot2"></div> + </div> + mk-drive-file-viewer(if={ file != null }, file={ file }) + +style. + display block + background #fff + + > nav + display block + width 100% + padding 10px 12px + overflow auto + white-space nowrap + font-size 0.9em + color #555 + background #fff + border-bottom solid 1px #dfdfdf + + > p + display inline + margin 0 + padding 0 + + &:last-child + font-weight bold + + > i + margin-right 4px + + > span + margin 0 8px + opacity 0.5 + + > .browser + &.loading + opacity 0.5 + + > .folders + > mk-drive-folder + border-bottom solid 1px #eee + + > .files + > mk-drive-file + border-bottom solid 1px #eee + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .loading + .spinner + margin 100px auto + width 40px + height 40px + text-align center + + animation sk-rotate 2.0s infinite linear + + .dot1, .dot2 + width 60% + height 60% + display inline-block + position absolute + top 0 + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + + animation sk-bounce 2.0s infinite ease-in-out + + .dot2 + top auto + bottom 0 + animation-delay -1.0s + + @keyframes sk-rotate { 100% { transform: rotate(360deg); }} + + @keyframes sk-bounce { + 0%, 100% { + transform: scale(0.0); + } 50% { + transform: scale(1.0); + } + } + +script. + @mixin \api + @mixin \stream + + @files = [] + @folders = [] + @hierarchy-folders = [] + @selected-files = [] + + # 現在の階層(フォルダ) + # * null でルートを表す + @folder = null + + @file = null + + @is-select-mode = @opts.select? and @opts.select + @multiple = if @opts.multiple? then @opts.multiple else false + + @on \mount ~> + @stream.on \drive_file_created @on-stream-drive-file-created + @stream.on \drive_file_updated @on-stream-drive-file-updated + @stream.on \drive_folder_created @on-stream-drive-folder-created + @stream.on \drive_folder_updated @on-stream-drive-folder-updated + + # Riotのバグでnullを渡しても""になる + # https://github.com/riot/riot/issues/2080 + #if @opts.folder? + if @opts.folder? and @opts.folder != '' + @cd @opts.folder + else + @load! + + @on \unmount ~> + @stream.off \drive_file_created @on-stream-drive-file-created + @stream.off \drive_file_updated @on-stream-drive-file-updated + @stream.off \drive_folder_created @on-stream-drive-folder-created + @stream.off \drive_folder_updated @on-stream-drive-folder-updated + + @on-stream-drive-file-created = (file) ~> + @add-file file, true + + @on-stream-drive-file-updated = (file) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + @remove-file file + else + @add-file file, true + + @on-stream-drive-folder-created = (folder) ~> + @add-folder folder, true + + @on-stream-drive-folder-updated = (folder) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + @remove-folder folder + else + @add-folder folder, true + + @_move = (ev) ~> + @move ev.item.folder + + @move = (target-folder) ~> + @cd target-folder, true + + @cd = (target-folder, is-move) ~> + if target-folder? and typeof target-folder == \object + target-folder = target-folder.id + + if target-folder == null + @go-root! + return + + @loading = true + @update! + + @api \drive/folders/show do + folder_id: target-folder + .then (folder) ~> + @folder = folder + @hierarchy-folders = [] + + x = (f) ~> + @hierarchy-folders.unshift f + if f.parent? + x f.parent + + if folder.parent? + x folder.parent + + @update! + if is-move then @trigger \move @folder + @trigger \cd @folder + @load! + .catch (err, text-status) -> + console.error err + + @add-folder = (folder, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + return + + if (@folders.some (f) ~> f.id == folder.id) + return + + if unshift + @folders.unshift folder + else + @folders.push folder + + @update! + + @add-file = (file, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + return + + if (@files.some (f) ~> f.id == file.id) + exist = (@files.map (f) -> f.id).index-of file.id + @files[exist] = file + @update! + return + + if unshift + @files.unshift file + else + @files.push file + + @update! + + @remove-folder = (folder) ~> + if typeof folder == \object + folder = folder.id + @folders = @folders.filter (f) -> f.id != folder + @update! + + @remove-file = (file) ~> + if typeof file == \object + file = file.id + @files = @files.filter (f) -> f.id != file + @update! + + @go-root = ~> + if @folder != null + @folder = null + @hierarchy-folders = [] + @update! + @trigger \move-root + @load! + + @load = ~> + @folders = [] + @files = [] + @more-folders = false + @more-files = false + @loading = true + @update! + + @trigger \begin-load + + load-folders = null + load-files = null + + folders-max = 20 + files-max = 20 + + # フォルダ一覧取得 + @api \drive/folders do + folder_id: if @folder? then @folder.id else null + limit: folders-max + 1 + .then (folders) ~> + if folders.length == folders-max + 1 + @more-folders = true + folders.pop! + load-folders := folders + complete! + .catch (err, text-status) ~> + console.error err + + # ファイル一覧取得 + @api \drive/files do + folder_id: if @folder? then @folder.id else null + limit: files-max + 1 + .then (files) ~> + if files.length == files-max + 1 + @more-files = true + files.pop! + load-files := files + complete! + .catch (err, text-status) ~> + console.error err + + flag = false + complete = ~> + if flag + load-folders.for-each (folder) ~> + @add-folder folder + load-files.for-each (file) ~> + @add-file file + @loading = false + @update! + + @trigger \loaded + else + flag := true + @trigger \load-mid + + @choose-file = (file) ~> + if @is-select-mode + exist = @selected-files.some (f) ~> f.id == file.id + if exist + @selected-files = (@selected-files.filter (f) ~> f.id != file.id) + else + @selected-files.push file + @update! + @trigger \change-selected @selected-files + else + @file = file + @update! + @trigger \open-file @file diff --git a/src/web/app/mobile/tags/drive/file-viewer.tag b/src/web/app/mobile/tags/drive/file-viewer.tag new file mode 100644 index 0000000000..8ce89a06f4 --- /dev/null +++ b/src/web/app/mobile/tags/drive/file-viewer.tag @@ -0,0 +1,8 @@ +mk-drive-file-viewer + p.name { file.name } + +style. + display block + +script. + @file = @opts.file diff --git a/src/web/app/mobile/tags/drive/file.tag b/src/web/app/mobile/tags/drive/file.tag new file mode 100644 index 0000000000..ec271441a5 --- /dev/null +++ b/src/web/app/mobile/tags/drive/file.tag @@ -0,0 +1,130 @@ +mk-drive-file(onclick={ onclick }, data-is-selected={ is-selected }) + div.container + div.thumbnail(style={ 'background-image: url(' + file.url + '?thumbnail&size=128)' }) + div.body + p.name { file.name } + // + if file.tags.length > 0 + ul.tags + each tag in file.tags + li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name + footer + p.type + mk-file-type-icon(file={ file }) + | { file.type } + p.separator + p.data-size { bytes-to-size(file.datasize) } + p.separator + p.created-at + i.fa.fa-clock-o + mk-time(time={ file.created_at }) + +style. + display block + + &, * + user-select none + + * + pointer-events none + + > .container + max-width 500px + margin 0 auto + padding 16px + + &:after + content "" + display block + clear both + + > .thumbnail + display block + float left + width 64px + height 64px + background-size cover + background-position center center + + > .body + display block + float left + width calc(100% - 74px) + margin-left 10px + + > .name + display block + margin 0 + padding 0 + font-size 0.9em + font-weight bold + color #555 + text-overflow ellipsis + word-wrap break-word + + > .tags + display block + margin 4px 0 0 0 + padding 0 + list-style none + font-size 0.5em + + > .tag + display inline-block + margin 0 5px 0 0 + padding 1px 5px + border-radius 2px + + > footer + display block + margin 4px 0 0 0 + font-size 0.7em + + > .separator + display inline + margin 0 + padding 0 4px + color #CDCDCD + + > .type + display inline + margin 0 + padding 0 + color #9D9D9D + + > mk-file-type-icon + margin-right 4px + + > .data-size + display inline + margin 0 + padding 0 + color #9D9D9D + + > .created-at + display inline + margin 0 + padding 0 + color #BDBDBD + + > i + margin-right 2px + + &[data-is-selected] + background $theme-color + + &, * + color #fff !important + +script. + @mixin \bytes-to-size + + @browser = @parent + @file = @opts.file + @is-selected = @browser.selected-files.some (f) ~> f.id == @file.id + + @browser.on \change-selected (selects) ~> + @is-selected = selects.some (f) ~> f.id == @file.id + + @onclick = ~> + @browser.choose-file @file diff --git a/src/web/app/mobile/tags/drive/folder.tag b/src/web/app/mobile/tags/drive/folder.tag new file mode 100644 index 0000000000..ef3a72ea93 --- /dev/null +++ b/src/web/app/mobile/tags/drive/folder.tag @@ -0,0 +1,45 @@ +mk-drive-folder(onclick={ onclick }) + div.container + p.name + i.fa.fa-folder + | { folder.name } + i.fa.fa-angle-right + +style. + display block + color #777 + + &, * + user-select none + + * + pointer-events none + + > .container + max-width 500px + margin 0 auto + padding 16px + + > .name + display block + margin 0 + padding 0 + + > i + margin-right 6px + + > i + position absolute + top 0 + bottom 0 + right 8px + margin auto 0 auto 0 + width 1em + height 1em + +script. + @browser = @parent + @folder = @opts.folder + + @onclick = ~> + @browser.move @folder diff --git a/src/web/app/mobile/tags/follow-button.tag b/src/web/app/mobile/tags/follow-button.tag new file mode 100644 index 0000000000..7cedbbee88 --- /dev/null +++ b/src/web/app/mobile/tags/follow-button.tag @@ -0,0 +1,108 @@ +mk-follow-button + button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following }, + onclick={ onclick }, + disabled={ wait }) + i.fa.fa-minus(if={ !wait && user.is_following }) + i.fa.fa-plus(if={ !wait && !user.is_following }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait }) + | { user.is_following ? 'フォロー解除' : 'フォロー' } + div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw + +style. + display block + + > button + > .init + display block + user-select none + cursor pointer + padding 0 16px + margin 0 + height inherit + font-size 16px + outline none + border solid 1px $theme-color + border-radius 4px + + * + pointer-events none + + &.follow + color $theme-color + background transparent + + &:hover + background rgba($theme-color, 0.1) + + &:active + background rgba($theme-color, 0.2) + + &.unfollow + color $theme-color-foreground + background $theme-color + + &.wait + cursor wait !important + opacity 0.7 + + &.init + cursor wait !important + opacity 0.7 + + > i + margin-right 4px + +script. + @mixin \api + @mixin \is-promise + @mixin \stream + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @init = true + @wait = false + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @init = false + @update! + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @on-stream-follow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @on-stream-unfollow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @onclick = ~> + @wait = true + if @user.is_following + @api \following/delete do + user_id: @user.id + .then ~> + @user.is_following = false + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! + else + @api \following/create do + user_id: @user.id + .then ~> + @user.is_following = true + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag new file mode 100644 index 0000000000..1754bb2b07 --- /dev/null +++ b/src/web/app/mobile/tags/home-timeline.tag @@ -0,0 +1,40 @@ +mk-home-timeline + mk-timeline@timeline(init={ init }, more={ more }, empty={ '表示する投稿がありません。誰かしらをフォローするなどしましょう。' }) + +style. + display block + +script. + @mixin \api + @mixin \stream + + @init = new Promise (res, rej) ~> + @api \posts/timeline + .then (posts) ~> + res posts + @trigger \loaded + + @on \mount ~> + @stream.on \post @on-stream-post + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \post @on-stream-post + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @more = ~> + @api \posts/timeline do + max_id: @refs.timeline.tail!.id + + @on-stream-post = (post) ~> + @is-empty = false + @update! + @refs.timeline.add-post post + + @on-stream-follow = ~> + @fetch! + + @on-stream-unfollow = ~> + @fetch! diff --git a/src/web/app/mobile/tags/home.tag b/src/web/app/mobile/tags/home.tag new file mode 100644 index 0000000000..ebcf8f0bb2 --- /dev/null +++ b/src/web/app/mobile/tags/home.tag @@ -0,0 +1,17 @@ +mk-home + mk-home-timeline@tl + +style. + display block + + > mk-home-timeline + max-width 600px + margin 0 auto + + @media (min-width 500px) + padding 16px + +script. + @on \mount ~> + @refs.tl.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/images-viewer.tag b/src/web/app/mobile/tags/images-viewer.tag new file mode 100644 index 0000000000..f9d774a124 --- /dev/null +++ b/src/web/app/mobile/tags/images-viewer.tag @@ -0,0 +1,25 @@ +mk-images-viewer + div.image@view(onclick={ click }) + img@img(src={ image.url + '?thumbnail&size=512' }, alt={ image.name }, title={ image.name }) + +style. + display block + padding 8px + overflow hidden + box-shadow 0 0 4px rgba(0, 0, 0, 0.2) + border-radius 4px + + > .image + + > img + display block + max-height 256px + max-width 100% + margin 0 auto + +script. + @images = @opts.images + @image = @images.0 + + @click = ~> + window.open @image.url diff --git a/src/web/app/mobile/tags/notification-preview.tag b/src/web/app/mobile/tags/notification-preview.tag new file mode 100644 index 0000000000..ee936df7ab --- /dev/null +++ b/src/web/app/mobile/tags/notification-preview.tag @@ -0,0 +1,117 @@ +mk-notification-preview(class={ notification.type }) + div.main(if={ notification.type == 'like' }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-thumbs-o-up + | { notification.user.name } + p.post-ref { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'repost' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-retweet + | { notification.post.user.name } + p.post-ref { get-post-summary(notification.post.repost) } + + div.main(if={ notification.type == 'quote' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-quote-left + | { notification.post.user.name } + p.post-preview { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'follow' }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-user-plus + | { notification.user.name } + + div.main(if={ notification.type == 'reply' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-reply + | { notification.post.user.name } + p.post-preview { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'mention' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-at + | { notification.post.user.name } + p.post-preview { get-post-summary(notification.post) } + +style. + display block + margin 0 + padding 8px + color #fff + + > .main + word-wrap break-word + + &:after + content "" + display block + clear both + + img + display block + float left + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i + margin-right 4px + + .post-ref + + &:before, &:after + font-family FontAwesome + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &:before + content "\f10d" + + &:after + content "\f10e" + + &.like + .text p i + color #FFAC33 + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #fff + +script. + @mixin \get-post-summary + @notification = @opts.notification diff --git a/src/web/app/mobile/tags/notification.tag b/src/web/app/mobile/tags/notification.tag new file mode 100644 index 0000000000..afcc7441b4 --- /dev/null +++ b/src/web/app/mobile/tags/notification.tag @@ -0,0 +1,142 @@ +mk-notification(class={ notification.type }) + mk-time(time={ notification.created_at }) + + div.main(if={ notification.type == 'like' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-thumbs-o-up + a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'repost' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-retweet + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post.repost) } + + div.main(if={ notification.type == 'quote' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-quote-left + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'follow' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-user-plus + a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name } + + div.main(if={ notification.type == 'reply' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-reply + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'mention' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-at + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + +style. + display block + margin 0 + padding 16px + + > mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size 12px + + > .main + word-wrap break-word + + &:after + content "" + display block + clear both + + .avatar-anchor + display block + float left + + img + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i + margin-right 4px + + .post-preview + color rgba(0, 0, 0, 0.7) + + .post-ref + color rgba(0, 0, 0, 0.7) + + &:before, &:after + font-family FontAwesome + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &:before + content "\f10d" + + &:after + content "\f10e" + + &.like + .text p i + color #FFAC33 + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #555 + + .post-preview + color rgba(0, 0, 0, 0.7) + +script. + @mixin \get-post-summary + @notification = @opts.notification diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag new file mode 100644 index 0000000000..7510d59967 --- /dev/null +++ b/src/web/app/mobile/tags/notifications.tag @@ -0,0 +1,98 @@ +mk-notifications + div.notifications(if={ notifications.length != 0 }) + virtual(each={ notification, i in notifications }) + mk-notification(notification={ notification }) + + p.date(if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date }) + span + i.fa.fa-angle-up + | { notification._datetext } + span + i.fa.fa-angle-down + | { notifications[i + 1]._datetext } + + p.empty(if={ notifications.length == 0 && !loading }) + | ありません! + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > .notifications + margin 0 auto + max-width 500px + + > mk-notification + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color #aaa + background #fdfdfd + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + span + margin 0 16px + + i + margin-right 8px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \stream + @mixin \get-post-summary + + @notifications = [] + @loading = true + + @on \mount ~> + @api \i/notifications + .then (notifications) ~> + @notifications = notifications + @loading = false + @update! + @trigger \loaded + .catch (err, text-status) -> + console.error err + + @stream.on \notification @on-notification + + @on \unmount ~> + @stream.off \notification @on-notification + + @on-notification = (notification) ~> + @notifications.unshift notification + @update! + + @on \update ~> + @notifications.for-each (notification) ~> + date = (new Date notification.created_at).get-date! + month = (new Date notification.created_at).get-month! + 1 + notification._date = date + notification._datetext = month + '月 ' + date + '日' diff --git a/src/web/app/mobile/tags/notify.tag b/src/web/app/mobile/tags/notify.tag new file mode 100644 index 0000000000..9dd93ccf25 --- /dev/null +++ b/src/web/app/mobile/tags/notify.tag @@ -0,0 +1,35 @@ +mk-notify + mk-notification-preview(notification={ opts.notification }) + +style. + display block + position fixed + z-index 1024 + bottom -64px + left 0 + width 100% + height 64px + pointer-events none + -webkit-backdrop-filter blur(2px) + backdrop-filter blur(2px) + background-color rgba(#000, 0.5) + +script. + @on \mount ~> + Velocity @root, { + bottom: \0px + } { + duration: 500ms + easing: \ease-out + } + + set-timeout ~> + Velocity @root, { + bottom: \-64px + } { + duration: 500ms + easing: \ease-out + complete: ~> + @unmount! + } + , 6000ms diff --git a/src/web/app/mobile/tags/page/drive.tag b/src/web/app/mobile/tags/page/drive.tag new file mode 100644 index 0000000000..9bef7e6648 --- /dev/null +++ b/src/web/app/mobile/tags/page/drive.tag @@ -0,0 +1,46 @@ +mk-drive-page + mk-ui@ui: mk-drive@browser(folder={ parent.opts.folder }, file={ parent.opts.file }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @on \mount ~> + document.title = 'Misskey Drive' + @ui.trigger \title '<i class="fa fa-cloud"></i>ドライブ' + + @refs.ui.refs.browser.on \begin-load ~> + @Progress.start! + + @refs.ui.refs.browser.on \loaded-mid ~> + @Progress.set 0.5 + + @refs.ui.refs.browser.on \loaded ~> + @Progress.done! + + @refs.ui.refs.browser.on \move-root ~> + @ui.trigger \title '<i class="fa fa-cloud"></i>ドライブ' + + # Rewrite URL + history.push-state null null '/i/drive' + + @refs.ui.refs.browser.on \cd (folder) ~> + # TODO: escape html characters in folder.name + @ui.trigger \title '<i class="fa fa-folder-open"></i>' + folder.name + + @refs.ui.refs.browser.on \move (folder) ~> + # Rewrite URL + history.push-state null null '/i/drive/folder/' + folder.id + + @refs.ui.refs.browser.on \open-file (file) ~> + # TODO: escape html characters in file.name + @ui.trigger \title '<mk-file-type-icon class="icon"></mk-file-type-icon>' + file.name + + # Rewrite URL + history.push-state null null '/i/drive/file/' + file.id + + riot.mount \mk-file-type-icon do + file: file diff --git a/src/web/app/mobile/tags/page/entrance.tag b/src/web/app/mobile/tags/page/entrance.tag new file mode 100644 index 0000000000..67d8bc9bbf --- /dev/null +++ b/src/web/app/mobile/tags/page/entrance.tag @@ -0,0 +1,57 @@ +mk-entrance + main + img(src='/_/resources/title.svg', alt='Misskey') + + mk-entrance-signin(if={ mode == 'signin' }) + mk-entrance-signup(if={ mode == 'signup' }) + div.introduction(if={ mode == 'introduction' }) + mk-introduction + button(onclick={ signin }) わかった + + footer + mk-copyright + +style. + display block + height 100% + + > main + display block + + > img + display block + width 130px + height 120px + margin 0 auto + + > .introduction + max-width 300px + margin 0 auto + color #666 + + > button + display block + margin 16px auto 0 auto + + > footer + > mk-copyright + margin 0 + text-align center + line-height 64px + font-size 10px + color rgba(#000, 0.5) + +script. + @mode = \signin + + @signup = ~> + @mode = \signup + @update! + + @signin = ~> + @mode = \signin + @update! + + @introduction = ~> + @mode = \introduction + @update! diff --git a/src/web/app/mobile/tags/page/entrance/signin.tag b/src/web/app/mobile/tags/page/entrance/signin.tag new file mode 100644 index 0000000000..484c414e8e --- /dev/null +++ b/src/web/app/mobile/tags/page/entrance/signin.tag @@ -0,0 +1,45 @@ +mk-entrance-signin + mk-signin + div.divider: span or + button.signup(onclick={ parent.signup }) 新規登録 + a.introduction(onclick={ parent.introduction }) Misskeyについて + +style. + display block + margin 0 auto + padding 0 8px + max-width 350px + text-align center + + > .signup + padding 16px + width 100% + font-size 1em + color #fff + background $theme-color + border-radius 3px + + > .divider + padding 16px 0 + text-align center + + &:after + content "" + display block + position absolute + top 50% + width 100% + height 1px + border-top solid 1px rgba(0, 0, 0, 0.1) + + > * + z-index 1 + padding 0 8px + color rgba(0, 0, 0, 0.5) + background #fdfdfd + + > .introduction + display inline-block + margin-top 16px + font-size 12px + color #666 diff --git a/src/web/app/mobile/tags/page/entrance/signup.tag b/src/web/app/mobile/tags/page/entrance/signup.tag new file mode 100644 index 0000000000..a28f85e634 --- /dev/null +++ b/src/web/app/mobile/tags/page/entrance/signup.tag @@ -0,0 +1,35 @@ +mk-entrance-signup + mk-signup + button.cancel(type='button', onclick={ parent.signin }, title='キャンセル'): i.fa.fa-times + +style. + display block + margin 0 auto + padding 0 8px + max-width 350px + + > .cancel + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + box-shadow none + background transparent + transition opacity 0.1s ease + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px diff --git a/src/web/app/mobile/tags/page/home.tag b/src/web/app/mobile/tags/page/home.tag new file mode 100644 index 0000000000..c8d7729652 --- /dev/null +++ b/src/web/app/mobile/tags/page/home.tag @@ -0,0 +1,40 @@ +mk-home-page + mk-ui@ui: mk-home@home + +style. + display block + +script. + @mixin \i + @mixin \ui + @mixin \ui-progress + @mixin \stream + @mixin \get-post-summary + + @unread-count = 0 + + @on \mount ~> + document.title = 'Misskey' + @ui.trigger \title '<i class="fa fa-home"></i>ホーム' + + @Progress.start! + + @stream.on \post @on-stream-post + document.add-event-listener \visibilitychange @window-on-visibilitychange, false + + @refs.ui.refs.home.on \loaded ~> + @Progress.done! + + @on \unmount ~> + @stream.off \post @on-stream-post + document.remove-event-listener \visibilitychange @window-on-visibilitychange + + @on-stream-post = (post) ~> + if document.hidden and post.user_id !== @I.id + @unread-count++ + document.title = '(' + @unread-count + ') ' + @get-post-summary post + + @window-on-visibilitychange = ~> + if !document.hidden + @unread-count = 0 + document.title = 'Misskey' diff --git a/src/web/app/mobile/tags/page/new-post.tag b/src/web/app/mobile/tags/page/new-post.tag new file mode 100644 index 0000000000..21e00fc1f9 --- /dev/null +++ b/src/web/app/mobile/tags/page/new-post.tag @@ -0,0 +1,5 @@ +mk-new-post-page + mk-post-form@form + +style. + display block diff --git a/src/web/app/mobile/tags/page/notifications.tag b/src/web/app/mobile/tags/page/notifications.tag new file mode 100644 index 0000000000..9fb34dcd75 --- /dev/null +++ b/src/web/app/mobile/tags/page/notifications.tag @@ -0,0 +1,18 @@ +mk-notifications-page + mk-ui@ui: mk-notifications@notifications + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @on \mount ~> + document.title = 'Misskey | 通知' + @ui.trigger \title '<i class="fa fa-bell-o"></i>通知' + + @Progress.start! + + @refs.ui.refs.notifications.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/post.tag b/src/web/app/mobile/tags/page/post.tag new file mode 100644 index 0000000000..1dc74d267a --- /dev/null +++ b/src/web/app/mobile/tags/page/post.tag @@ -0,0 +1,31 @@ +mk-post-page + mk-ui@ui: main: mk-post-detail@post(post={ parent.post }) + +style. + display block + + main + background #fff + + > mk-post-detail + width 100% + max-width 500px + margin 0 auto + +script. + @mixin \ui + @mixin \ui-progress + + @post = @opts.post + + @on \mount ~> + document.title = 'Misskey' + @ui.trigger \title '<i class="fa fa-sticky-note-o"></i>投稿' + + @Progress.start! + + @refs.ui.refs.post.on \post-fetched ~> + @Progress.set 0.5 + + @refs.ui.refs.post.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/search.tag b/src/web/app/mobile/tags/page/search.tag new file mode 100644 index 0000000000..20de271f73 --- /dev/null +++ b/src/web/app/mobile/tags/page/search.tag @@ -0,0 +1,19 @@ +mk-search-page + mk-ui@ui: mk-search@search(query={ parent.opts.query }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @on \mount ~> + document.title = '検索: ' + @opts.query + ' | Misskey' + # TODO: クエリをHTMLエスケープ + @ui.trigger \title '<i class="fa fa-search"></i>' + @opts.query + + @Progress.start! + + @refs.ui.refs.search.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/user-followers.tag b/src/web/app/mobile/tags/page/user-followers.tag new file mode 100644 index 0000000000..e7e9a6fd1e --- /dev/null +++ b/src/web/app/mobile/tags/page/user-followers.tag @@ -0,0 +1,31 @@ +mk-user-followers-page + mk-ui@ui: mk-user-followers@list(if={ !parent.fetching }, user={ parent.user }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + @mixin \api + + @fetching = true + @user = null + + @on \mount ~> + @Progress.start! + + @api \users/show do + username: @opts.user + .then (user) ~> + @user = user + @fetching = false + + document.title = user.name + 'のフォロワー | Misskey' + # TODO: ユーザー名をエスケープ + @ui.trigger \title '<img src="' + user.avatar_url + '?thumbnail&size=64">' + user.name + 'のフォロー' + + @update! + + @refs.ui.refs.list.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/user-following.tag b/src/web/app/mobile/tags/page/user-following.tag new file mode 100644 index 0000000000..a74ba97b72 --- /dev/null +++ b/src/web/app/mobile/tags/page/user-following.tag @@ -0,0 +1,31 @@ +mk-user-following-page + mk-ui@ui: mk-user-following@list(if={ !parent.fetching }, user={ parent.user }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + @mixin \api + + @fetching = true + @user = null + + @on \mount ~> + @Progress.start! + + @api \users/show do + username: @opts.user + .then (user) ~> + @user = user + @fetching = false + + document.title = user.name + 'のフォロー | Misskey' + # TODO: ユーザー名をエスケープ + @ui.trigger \title '<img src="' + user.avatar_url + '?thumbnail&size=64">' + user.name + 'のフォロー' + + @update! + + @refs.ui.refs.list.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/user.tag b/src/web/app/mobile/tags/page/user.tag new file mode 100644 index 0000000000..9667abfd14 --- /dev/null +++ b/src/web/app/mobile/tags/page/user.tag @@ -0,0 +1,20 @@ +mk-user-page + mk-ui@ui: mk-user@user(user={ parent.user }, page={ parent.opts.page }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @user = @opts.user + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.user.on \loaded (user) ~> + @Progress.done! + document.title = user.name + ' | Misskey' + # TODO: ユーザー名をエスケープ + @ui.trigger \title '<i class="fa fa-user"></i>' + user.name diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag new file mode 100644 index 0000000000..c7eb091ce6 --- /dev/null +++ b/src/web/app/mobile/tags/post-detail.tag @@ -0,0 +1,415 @@ +mk-post-detail + + div.fetching(if={ fetching }) + mk-ellipsis-icon + + div.main(if={ !fetching }) + + button.read-more(if={ p.reply_to && p.reply_to.reply_to_id && context == null }, onclick={ load-context }, disabled={ loading-context }) + i.fa.fa-ellipsis-v(if={ !loading-context }) + i.fa.fa-spinner.fa-pulse(if={ loading-context }) + + div.context + virtual(each={ post in context }) + mk-post-preview(post={ post }) + + div.reply-to(if={ p.reply_to }) + mk-post-preview(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name } + | がRepost + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + header + a.name(href={ CONFIG.url + '/' + p.user.username }) + | { p.user.name } + span.username + | @{ p.user.username } + div.body + div.text@text + div.media(if={ p.media }) + virtual(each={ file in p.media }) + img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name }) + a.time(href={ url }) + mk-time(time={ p.created_at }, mode='detail') + footer + button(onclick={ reply }, title='返信') + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }, title='善哉') + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + div.reposts-and-likes + div.reposts(if={ reposts && reposts.length > 0 }) + header + a { p.repost_count } + p Repost + ol.users + li.user(each={ reposts }) + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }, title={ user.name }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='') + div.likes(if={ likes && likes.length > 0 }) + header + a { p.likes_count } + p いいね + ol.users + li.user(each={ likes }) + a.avatar-anchor(href={ CONFIG.url + '/' + username }, title={ name }) + img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='') + + div.replies + virtual(each={ post in replies }) + mk-post-detail-sub(post={ post }) + +style. + display block + margin 0 + padding 0 + + > .fetching + padding 64px 0 + + > .main + + > .read-more + display block + margin 0 + padding 10px 0 + width 100% + font-size 1em + text-align center + color #999 + cursor pointer + background #fafafa + outline none + border none + border-bottom solid 1px #eef0f2 + border-radius 6px 6px 0 0 + box-shadow none + + &:hover + background #f6f6f6 + + &:active + background #f0f0f0 + + &:disabled + color #ccc + + > .context + > * + border-bottom 1px solid #eef0f2 + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + & + article + padding-top 8px + + > .reply-to + border-bottom 1px solid #eef0f2 + + > article + padding 14px 16px 9px 16px + + @media (min-width 500px) + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + + > .avatar + display block + width 54px + height 54px + margin 0 + border-radius 8px + vertical-align bottom + + @media (min-width 500px) + width 60px + height 60px + + > header + position absolute + top 18px + left 80px + width calc(100% - 80px) + + @media (min-width 500px) + top 28px + left 108px + width calc(100% - 108px) + + > .name + display inline-block + margin 0 + color #777 + font-size 16px + font-weight bold + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + display block + text-align left + margin 0 + color #ccc + + > .body + padding 8px 0 + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 16px + color #717171 + + @media (min-width 500px) + font-size 24px + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + + > .time + font-size 16px + color #c0c0c0 + + > footer + font-size 1.2em + + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + + > .reposts-and-likes + display flex + justify-content center + padding 0 + margin 16px 0 + + &:empty + display none + + > .reposts + > .likes + display flex + flex 1 1 + padding 0 + border-top solid 1px #F2EFEE + + > header + flex 1 1 80px + max-width 80px + padding 8px 5px 0px 10px + + > a + display block + font-size 1.5em + line-height 1.4em + + > p + display block + margin 0 + font-size 0.7em + line-height 1em + font-weight normal + color #a0a2a5 + + > .users + display block + flex 1 1 + margin 0 + padding 10px 10px 10px 5px + list-style none + + > .user + display block + float left + margin 4px + padding 0 + + > .avatar-anchor + display:block + + > .avatar + vertical-align bottom + width 24px + height 24px + border-radius 4px + + > .reposts + .likes + margin-left 16px + + > .replies + > * + border-top 1px solid #eef0f2 + +script. + @mixin \api + @mixin \text + @mixin \get-post-summary + @mixin \open-post-form + + @fetching = true + @loading-context = false + @content = null + @post = null + + @on \mount ~> + @api \posts/show do + post_id: @opts.post + .then (post) ~> + @post = post + @is-repost = @post.repost? + @p = if @is-repost then @post.repost else @post + @summary = @get-post-summary @p + @trigger \loaded + @fetching = false + @update! + + if @p.text? + tokens = @analyze @p.text + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + # Get likes + @api \posts/likes do + post_id: @p.id + limit: 8 + .then (likes) ~> + @likes = likes + @update! + + # Get reposts + @api \posts/reposts do + post_id: @p.id + limit: 8 + .then (reposts) ~> + @reposts = reposts + @update! + + # Get replies + @api \posts/replies do + post_id: @p.id + limit: 8 + .then (replies) ~> + @replies = replies + @update! + + @reply = ~> + @open-post-form do + reply: @p + + @repost = ~> + text = window.prompt '「' + @summary + '」をRepost' + if text? + @api \posts/create do + repost_id: @p.id + text: if text == '' then undefined else text + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! + + @load-context = ~> + @loading-context = true + + # Get context + @api \posts/context do + post_id: @p.reply_to_id + .then (context) ~> + @context = context.reverse! + @loading-context = false + @update! diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag new file mode 100644 index 0000000000..759a0820b8 --- /dev/null +++ b/src/web/app/mobile/tags/post-form.tag @@ -0,0 +1,254 @@ +mk-post-form + header: div + button.cancel(onclick={ cancel }): i.fa.fa-times + div + span.text-count(class={ over: refs.text.value.length > 300 }) { 300 - refs.text.value.length } + button.submit(onclick={ post }) 投稿 + div.form + mk-post-preview(if={ opts.reply }, post={ opts.reply }) + textarea@text(disabled={ wait }, oninput={ update }, onkeypress={ onkeypress }, onpaste={ onpaste }, placeholder={ opts.reply ? 'この投稿への返信...' : 'いまどうしてる?' }) + div.attaches(if={ files.length != 0 }) + ul.files@attaches + li.file(each={ files }) + div.img(style='background-image: url({ url + "?thumbnail&size=64" })', title={ name }) + li.add(if={ files.length < 4 }, title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-plus + mk-uploader@uploader + button@upload(onclick={ select-file }): i.fa.fa-upload + button@drive(onclick={ select-file-from-drive }): i.fa.fa-cloud + input@file(type='file', accept='image/*', multiple, onchange={ change-file }) + +style. + display block + padding-top 50px + + > header + position fixed + z-index 1000 + top 0 + left 0 + width 100% + height 50px + background #fff + + > div + max-width 500px + margin 0 auto + + > .cancel + width 50px + line-height 50px + font-size 24px + color #555 + + > div + position absolute + top 0 + right 0 + + > .text-count + line-height 50px + color #657786 + + > .submit + margin 8px + padding 0 16px + line-height 34px + color $theme-color-foreground + background $theme-color + border-radius 4px + + &:disabled + opacity 0.7 + + > .form + max-width 500px + margin 0 auto + + > mk-post-preview + padding 16px + + > .attaches + + > .files + display block + margin 0 + padding 4px + list-style none + + &:after + content "" + display block + clear both + + > .file + display block + float left + margin 4px + padding 0 + cursor move + + &:hover > .remove + display block + + > .img + width 64px + height 64px + background-size cover + background-position center center + + > .remove + display none + position absolute + top -6px + right -6px + width 16px + height 16px + cursor pointer + + > .add + display block + float left + margin 4px + padding 0 + border dashed 2px rgba($theme-color, 0.2) + cursor pointer + + &:hover + border-color rgba($theme-color, 0.3) + + > i + color rgba($theme-color, 0.4) + + > i + display block + width 60px + height 60px + line-height 60px + text-align center + font-size 1.2em + color rgba($theme-color, 0.2) + + > mk-uploader + margin 8px 0 0 0 + padding 8px + + > [ref='file'] + display none + + > [ref='text'] + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height 80px + font-size 16px + color #333 + border none + border-bottom solid 1px #ddd + border-radius 0 + + &:disabled + opacity 0.5 + + > [ref='upload'] + > [ref='drive'] + display inline-block + padding 0 + margin 0 + width 48px + height 48px + font-size 20px + color #657786 + background transparent + outline none + border none + border-radius 0 + box-shadow none + +script. + @mixin \api + + @wait = false + @uploadings = [] + @files = [] + + @on \mount ~> + @refs.uploader.on \uploaded (file) ~> + @add-file file + + @refs.uploader.on \change-uploads (uploads) ~> + @trigger \change-uploading-files uploads + + @refs.text.focus! + + @onkeypress = (e) ~> + if (e.char-code == 10 || e.char-code == 13) && e.ctrl-key + @post! + else + return true + + @onpaste = (e) ~> + data = e.clipboard-data + items = data.items + for i from 0 to items.length - 1 + item = items[i] + switch (item.kind) + | \file => + @upload item.get-as-file! + return true + + @select-file = ~> + @refs.file.click! + + @select-file-from-drive = ~> + browser = document.body.append-child document.create-element \mk-drive-selector + browser = riot.mount browser, do + multiple: true + .0 + browser.on \selected (files) ~> + files.for-each @add-file + + @change-file = ~> + files = @refs.file.files + for i from 0 to files.length - 1 + file = files.item i + @upload file + + @upload = (file) ~> + @refs.uploader.upload file + + @add-file = (file) ~> + file._remove = ~> + @files = @files.filter (x) -> x.id != file.id + @trigger \change-files @files + @update! + + @files.push file + @trigger \change-files @files + @update! + + @post = ~> + @wait = true + + files = if @files? and @files.length > 0 + then @files.map (f) -> f.id + else undefined + + @api \posts/create do + text: @refs.text.value + media_ids: files + reply_to_id: if @opts.reply? then @opts.reply.id else undefined + .then (data) ~> + @trigger \post + @unmount! + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + @wait = false + @update! + + @cancel = ~> + @trigger \cancel + @unmount! diff --git a/src/web/app/mobile/tags/post-preview.tag b/src/web/app/mobile/tags/post-preview.tag new file mode 100644 index 0000000000..e15b2be244 --- /dev/null +++ b/src/web/app/mobile/tags/post-preview.tag @@ -0,0 +1,89 @@ +mk-post-preview + article + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.main + header + a.name(href={ CONFIG.url + '/' + post.user.username }) + | { post.user.name } + span.username + | @{ post.user.username } + a.time(href={ CONFIG.url + '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +style. + display block + margin 0 + padding 0 + font-size 0.9em + background #fff + + > article + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + margin-bottom 4px + white-space nowrap + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .time + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +script. + @post = @opts.post diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag new file mode 100644 index 0000000000..4b1b12af27 --- /dev/null +++ b/src/web/app/mobile/tags/search-posts.tag @@ -0,0 +1,29 @@ +mk-search-posts + mk-timeline(init={ init }, more={ more }, empty={ '「' + query + '」に関する投稿は見つかりませんでした。' }) + +style. + display block + background #fff + +script. + @mixin \api + + @max = 30 + @offset = 0 + + @query = @opts.query + @with-media = @opts.with-media + + @init = new Promise (res, rej) ~> + @api \posts/search do + query: @query + .then (posts) ~> + res posts + @trigger \loaded + + @more = ~> + @offset += @max + @api \posts/search do + query: @query + max: @max + offset: @offset diff --git a/src/web/app/mobile/tags/search.tag b/src/web/app/mobile/tags/search.tag new file mode 100644 index 0000000000..bf2299cc9b --- /dev/null +++ b/src/web/app/mobile/tags/search.tag @@ -0,0 +1,12 @@ +mk-search + mk-search-posts@posts(query={ query }) + +style. + display block + +script. + @query = @opts.query + + @on \mount ~> + @refs.posts.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/stream-indicator.tag b/src/web/app/mobile/tags/stream-indicator.tag new file mode 100644 index 0000000000..2eb5889ca6 --- /dev/null +++ b/src/web/app/mobile/tags/stream-indicator.tag @@ -0,0 +1,59 @@ +mk-stream-indicator + p(if={ state == 'initializing' }) + i.fa.fa-spinner.fa-spin + span + | 接続中 + mk-ellipsis + p(if={ state == 'reconnecting' }) + i.fa.fa-spinner.fa-spin + span + | 切断されました 接続中 + mk-ellipsis + p(if={ state == 'connected' }) + i.fa.fa-check + span 接続完了 + +style. + display block + pointer-events none + position fixed + z-index 16384 + bottom 8px + right 8px + margin 0 + padding 6px 12px + font-size 0.9em + color #fff + background rgba(0, 0, 0, 0.8) + + > p + display block + margin 0 + + > i + margin-right 0.25em + +script. + @mixin \stream + + @on \before-mount ~> + @state = @get-stream-state! + + if @state == \connected + @root.style.opacity = 0 + + @stream-state-ev.on \connected ~> + @state = @get-stream-state! + @update! + set-timeout ~> + Velocity @root, { + opacity: 0 + } 200ms \linear + , 1000ms + + @stream-state-ev.on \closed ~> + @state = @get-stream-state! + @update! + Velocity @root, { + opacity: 1 + } 0ms diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag new file mode 100644 index 0000000000..595f63d794 --- /dev/null +++ b/src/web/app/mobile/tags/sub-post-content.tag @@ -0,0 +1,36 @@ +mk-sub-post-content + div.body + a.reply(if={ post.reply_to_id }): i.fa.fa-reply + span@text + a.quote(if={ post.repost_id }, href={ '/post:' + post.repost_id }) RP: ... + details(if={ post.media }) + summary ({ post.media.length }枚の画像) + mk-images-viewer(images={ post.media }) + +style. + display block + word-wrap break-word + + > .body + > .reply + margin-right 6px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + +script. + @mixin \text + + @post = @opts.post + + @on \mount ~> + if @post.text? + tokens = @analyze @post.text + @refs.text.innerHTML = @compile tokens, false + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e diff --git a/src/web/app/mobile/tags/timeline-post-sub.tag b/src/web/app/mobile/tags/timeline-post-sub.tag new file mode 100644 index 0000000000..920503ebcc --- /dev/null +++ b/src/web/app/mobile/tags/timeline-post-sub.tag @@ -0,0 +1,99 @@ +mk-timeline-post-sub + article + a.avatar-anchor(href={ '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=96' }, alt='avatar') + div.main + header + a.name(href={ '/' + post.user.username }) + | { post.user.name } + span.username + | @{ post.user.username } + a.created-at(href={ '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +style. + display block + margin 0 + padding 0 + font-size 0.9em + + > article + padding 16px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 8px + vertical-align bottom + + @media (min-width 500px) + width 52px + height 52px + + > .main + float left + width calc(100% - 54px) + + @media (min-width 500px) + width calc(100% - 68px) + + > header + margin-bottom 4px + white-space nowrap + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .created-at + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +script. + @post = @opts.post diff --git a/src/web/app/mobile/tags/timeline-post.tag b/src/web/app/mobile/tags/timeline-post.tag new file mode 100644 index 0000000000..a71fab26f0 --- /dev/null +++ b/src/web/app/mobile/tags/timeline-post.tag @@ -0,0 +1,296 @@ +mk-timeline-post(class={ repost: is-repost }) + + div.reply-to(if={ p.reply_to }) + mk-timeline-post-sub(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name } + | がRepost + mk-time(time={ post.created_at }) + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=96' }, alt='avatar') + div.main + header + a.name(href={ CONFIG.url + '/' + p.user.username }) + | { p.user.name } + span.username + | @{ p.user.username } + a.created-at(href={ url }) + mk-time(time={ p.created_at }) + div.body + div.text + a.reply(if={ p.reply_to }): i.fa.fa-reply + soan@text + a.quote(if={ p.repost != null }) RP: + div.media(if={ p.media }) + mk-images-viewer(images={ p.media }) + div.repost(if={ p.repost }) + i.fa.fa-quote-right.fa-flip-horizontal + mk-post-preview.repost(post={ p.repost }) + footer + button(onclick={ reply }) + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }) + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + +style. + display block + margin 0 + padding 0 + font-size 12px + + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 8px 16px + line-height 28px + + @media (min-width 500px) + padding 16px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + > mk-time + position absolute + top 8px + right 16px + font-size 0.9em + line-height 28px + + @media (min-width 500px) + top 16px + + & + article + padding-top 8px + + > .reply-to + background rgba(0, 0, 0, 0.0125) + + > mk-post-preview + background transparent + + > article + padding 14px 16px 9px 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 6px + vertical-align bottom + + @media (min-width 500px) + width 58px + height 58px + border-radius 8px + + > .main + float left + width calc(100% - 58px) + + @media (min-width 500px) + width calc(100% - 74px) + + > header + white-space nowrap + + @media (min-width 500px) + margin-bottom 2px + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .created-at + position absolute + top 0 + right 0 + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + + mk-url-preview + margin-top 8px + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + > .media + > img + display block + max-width 100% + + > .repost + margin 8px 0 + + > i:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + +script. + @mixin \api + @mixin \text + @mixin \get-post-summary + @mixin \open-post-form + + @post = @opts.post + @is-repost = @post.repost? and !@post.text? + @p = if @is-repost then @post.repost else @post + @summary = @get-post-summary @p + @url = CONFIG.url + '/' + @p.user.username + '/' + @p.id + + @on \mount ~> + if @p.text? + tokens = if @p._highlight? + then @analyze @p._highlight + else @analyze @p.text + + @refs.text.innerHTML = if @p._highlight? + then @compile tokens, true, false + else @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + @reply = ~> + @open-post-form do + reply: @p + + @repost = ~> + text = window.prompt '「' + @summary + '」をRepost' + if text? + @api \posts/create do + repost_id: @p.id + text: if text == '' then undefined else text + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag new file mode 100644 index 0000000000..7114824872 --- /dev/null +++ b/src/web/app/mobile/tags/timeline.tag @@ -0,0 +1,128 @@ +mk-timeline + div.init(if={ init }) + i.fa.fa-spinner.fa-pulse + | 読み込んでいます + div.empty(if={ !init && posts.length == 0 }) + i.fa.fa-comments-o + | { opts.empty || '表示するものがありません' } + virtual(each={ post, i in posts }) + mk-timeline-post(post={ post }) + p.date(if={ i != posts.length - 1 && post._date != posts[i + 1]._date }) + span + i.fa.fa-angle-up + | { post._datetext } + span + i.fa.fa-angle-down + | { posts[i + 1]._datetext } + footer(if={ !init }) + button(if={ can-fetch-more }, onclick={ more }, disabled={ fetching }) + span(if={ !fetching }) もっとみる + span(if={ fetching }) + | 読み込み中 + mk-ellipsis + +style. + display block + background #fff + background-clip content-box + overflow hidden + + > .init + padding 64px 0 + text-align center + color #999 + + > i + margin-right 4px + + > .empty + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + + > mk-timeline-post + border-bottom solid 1px #eaeaea + + &:last-of-type + border-bottom none + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.9em + color #aaa + background #fdfdfd + border-bottom solid 1px #eaeaea + + span + margin 0 16px + + i + margin-right 8px + + > footer + text-align center + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + + > button + margin 0 + padding 16px + width 100% + color $theme-color + + &:disabled + opacity 0.7 + +script. + @posts = [] + @init = true + @fetching = false + @can-fetch-more = true + + @on \mount ~> + @opts.init.then (posts) ~> + @init = false + @set-posts posts + + @on \update ~> + @posts.for-each (post) ~> + date = (new Date post.created_at).get-date! + month = (new Date post.created_at).get-month! + 1 + post._date = date + post._datetext = month + '月 ' + date + '日' + + @more = ~> + if @init or @fetching or @posts.length == 0 then return + @fetching = true + @update! + @opts.more!.then (posts) ~> + @fetching = false + @prepend-posts posts + + @set-posts = (posts) ~> + @posts = posts + @update! + + @prepend-posts = (posts) ~> + posts.for-each (post) ~> + @posts.push post + @update! + + @add-post = (post) ~> + @posts.unshift post + @update! + + @tail = ~> + @posts[@posts.length - 1] diff --git a/src/web/app/mobile/tags/ui-header.tag b/src/web/app/mobile/tags/ui-header.tag new file mode 100644 index 0000000000..7105d065f8 --- /dev/null +++ b/src/web/app/mobile/tags/ui-header.tag @@ -0,0 +1,98 @@ +mk-ui-header + mk-special-message + div.main + div.backdrop + div.content + button.nav#hamburger: i.fa.fa-bars + h1@title Misskey + button.post(onclick={ post }): i.fa.fa-pencil + +style. + $height = 48px + + display block + position fixed + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 0 rgba(#000, 0.075) + + > .main + color rgba(#000, 0.6) + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height $height + -webkit-backdrop-filter blur(12px) + backdrop-filter blur(12px) + background-color rgba(#fff, 0.75) + + > .content + z-index 1024 + + > h1 + display block + margin 0 auto + padding 0 + width 100% + max-width calc(100% - 112px) + text-align center + font-size 1.1em + font-weight normal + line-height $height + white-space nowrap + overflow hidden + text-overflow ellipsis + + > i + margin-right 8px + + > img + display inline-block + vertical-align bottom + width ($height - 16px) + height ($height - 16px) + margin 8px + border-radius 6px + + > .nav + display block + position absolute + top 0 + left 0 + width $height + font-size 1.4em + line-height $height + border-right solid 1px rgba(#000, 0.1) + + > i + transition all 0.2s ease + + > .post + display block + position absolute + top 0 + right 0 + width $height + text-align center + font-size 1.4em + color inherit + line-height $height + border-left solid 1px rgba(#000, 0.1) + +script. + @mixin \ui + @mixin \open-post-form + + @on \mount ~> + @opts.ready! + + @ui.one \title (title) ~> + if @refs.title? + @refs.title.innerHTML = title + + @post = ~> + @open-post-form! diff --git a/src/web/app/mobile/tags/ui-nav.tag b/src/web/app/mobile/tags/ui-nav.tag new file mode 100644 index 0000000000..2c551b30ad --- /dev/null +++ b/src/web/app/mobile/tags/ui-nav.tag @@ -0,0 +1,169 @@ +mk-ui-nav + div.body: div.content + a.me(if={ SIGNIN }, href={ CONFIG.url + '/' + I.username }) + img.avatar(src={ I.avatar_url + '?thumbnail&size=128' }, alt='avatar') + p.name { I.name } + div.links + ul + li.post: a(href='/i/post') + i.icon.fa.fa-pencil-square-o + | 新規投稿 + i.angle.fa.fa-angle-right + ul + li.home: a(href='/') + i.icon.fa.fa-home + | ホーム + i.angle.fa.fa-angle-right + li.mentions: a(href='/i/mentions') + i.icon.fa.fa-at + | あなた宛て + i.angle.fa.fa-angle-right + li.notifications: a(href='/i/notifications') + i.icon.fa.fa-bell-o + | 通知 + i.angle.fa.fa-angle-right + li.messaging: a + i.icon.fa.fa-comments-o + | メッセージ + i.angle.fa.fa-angle-right + ul + li.settings: a(onclick={ search }) + i.icon.fa.fa-search + | 検索 + i.angle.fa.fa-angle-right + ul + li.settings: a(href='/i/drive') + i.icon.fa.fa-cloud + | ドライブ + i.angle.fa.fa-angle-right + li.settings: a(href='/i/upload') + i.icon.fa.fa-upload + | アップロード + i.angle.fa.fa-angle-right + ul + li.settings: a(href='/i/settings') + i.icon.fa.fa-cog + | 設定 + i.angle.fa.fa-angle-right + p.about + a Misskeyについて + +style. + display block + position fixed + top 0 + left 0 + z-index -1 + width 240px + color #fff + background #313538 + visibility hidden + + .body + height 100% + overflow hidden + + .content + min-height 100% + + .me + display block + margin 0 + padding 16px + + .avatar + display inline + max-width 64px + border-radius 32px + vertical-align middle + + .name + display block + margin 0 16px + position absolute + top 0 + left 80px + padding 0 + width calc(100% - 112px) + color #fff + line-height 96px + overflow hidden + text-overflow ellipsis + white-space nowrap + + ul + display block + margin 16px 0 + padding 0 + list-style none + + &:first-child + margin-top 0 + + li + display block + font-size 1em + line-height 1em + border-top solid 1px rgba(0, 0, 0, 0.2) + background #353A3E + background-clip content-box + + &:last-child + border-bottom solid 1px rgba(0, 0, 0, 0.2) + + a + display block + padding 0 20px + line-height 3rem + line-height calc(1rem + 30px) + color #eee + text-decoration none + + > .icon + margin-right 0.5em + + > .angle + position absolute + top 0 + right 0 + padding 0 20px + font-size 1.2em + line-height calc(1rem + 30px) + color #ccc + + > .unread-count + position absolute + height calc(0.9em + 10px) + line-height calc(0.9em + 10px) + top 0 + bottom 0 + right 38px + margin auto 0 + padding 0px 8px + min-width 2em + font-size 0.9em + text-align center + color #fff + background rgba(255, 255, 255, 0.1) + border-radius 1em + + .about + margin 1em 1em 2em 1em + text-align center + font-size 0.6em + opacity 0.3 + + a + color #fff + +script. + @mixin \i + @mixin \page + + @on \mount ~> + @opts.ready! + + @search = ~> + query = window.prompt \検索 + if query? and query != '' + @page '/search:' + query diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag new file mode 100644 index 0000000000..81dfac80ca --- /dev/null +++ b/src/web/app/mobile/tags/ui.tag @@ -0,0 +1,50 @@ +mk-ui + div.global@global + mk-ui-header@header(ready={ ready }) + mk-ui-nav@nav(ready={ ready }) + + div.content@main + <yield /> + + mk-stream-indicator + +style. + display block + + > .global + > .content + background #fff + +script. + @mixin \stream + + @ready-count = 0 + + #@ui.on \notification (text) ~> + # alert text + + @on \mount ~> + @stream.on \notification @on-stream-notification + @ready! + + @on \unmount ~> + @stream.off \notification @on-stream-notification + @slide.slide-close! + + @ready = ~> + @ready-count++ + + if @ready-count == 2 + @slide = SpSlidemenu @refs.main, @refs.nav.root, \#hamburger {direction: \left} + @init-view-position! + + @init-view-position = ~> + top = @refs.header.root.offset-height + @refs.main.style.padding-top = top + \px + @refs.nav.root.style.margin-top = top + \px + @refs.nav.root.query-selector '.body > .content' .style.padding-bottom = top + \px + + @on-stream-notification = (notification) ~> + el = document.body.append-child document.create-element \mk-notify + riot.mount el, do + notification: notification diff --git a/src/web/app/mobile/tags/user-followers.tag b/src/web/app/mobile/tags/user-followers.tag new file mode 100644 index 0000000000..7004398268 --- /dev/null +++ b/src/web/app/mobile/tags/user-followers.tag @@ -0,0 +1,22 @@ +mk-user-followers + mk-users-list@list(fetch={ fetch }, count={ user.followers_count }, you-know-count={ user.followers_you_know_count }, no-users={ 'フォロワーはいないようです。' }) + +style. + display block + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/followers do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb + + @on \mount ~> + @refs.list.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/user-following.tag b/src/web/app/mobile/tags/user-following.tag new file mode 100644 index 0000000000..c122acd607 --- /dev/null +++ b/src/web/app/mobile/tags/user-following.tag @@ -0,0 +1,22 @@ +mk-user-following + mk-users-list@list(fetch={ fetch }, count={ user.following_count }, you-know-count={ user.following_you_know_count }, no-users={ 'フォロー中のユーザーはいないようです。' }) + +style. + display block + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/following do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb + + @on \mount ~> + @refs.list.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/user-preview.tag b/src/web/app/mobile/tags/user-preview.tag new file mode 100644 index 0000000000..4f5fbc1520 --- /dev/null +++ b/src/web/app/mobile/tags/user-preview.tag @@ -0,0 +1,103 @@ +mk-user-preview + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.main + header + div.left + a.name(href={ CONFIG.url + '/' + user.username }) + | { user.name } + span.username + | @{ user.username } + div.body + div.bio { user.bio } + +style. + display block + margin 0 + padding 16px + font-size 12px + + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 6px + vertical-align bottom + + @media (min-width 500px) + width 58px + height 58px + border-radius 8px + + > .main + float left + width calc(100% - 58px) + + @media (min-width 500px) + width calc(100% - 74px) + + > header + white-space nowrap + + @media (min-width 500px) + margin-bottom 2px + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .body + + > .bio + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + +script. + @user = @opts.user diff --git a/src/web/app/mobile/tags/user-timeline.tag b/src/web/app/mobile/tags/user-timeline.tag new file mode 100644 index 0000000000..7aa23d2150 --- /dev/null +++ b/src/web/app/mobile/tags/user-timeline.tag @@ -0,0 +1,28 @@ +mk-user-timeline + mk-timeline(init={ init }, more={ more }, empty={ with-media ? 'メディア付き投稿はありません。' : 'このユーザーはまだ投稿していないようです。' }) + +style. + display block + max-width 600px + margin 0 auto + background #fff + +script. + @mixin \api + + @user = @opts.user + @with-media = @opts.with-media + + @init = new Promise (res, rej) ~> + @api \users/posts do + user_id: @user.id + with_media: @with-media + .then (posts) ~> + res posts + @trigger \loaded + + @more = ~> + @api \users/posts do + user_id: @user.id + with_media: @with-media + max_id: @refs.timeline.tail!.id diff --git a/src/web/app/mobile/tags/user.tag b/src/web/app/mobile/tags/user.tag new file mode 100644 index 0000000000..8f4c04cf9c --- /dev/null +++ b/src/web/app/mobile/tags/user.tag @@ -0,0 +1,198 @@ +mk-user + div.user(if={ !fetching }) + header + div.banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }) + div.body + div.top + a.avatar: img(src={ user.avatar_url + '?thumbnail&size=160' }, alt='avatar') + mk-follow-button(if={ SIGNIN && I.id != user.id }, user={ user }) + + div.title + h1 { user.name } + span.username @{ user.username } + span.followed(if={ user.is_followed }) フォローされています + + div.bio { user.bio } + + div.info + p.location(if={ user.location }) + i.fa.fa-map-marker + | { user.location } + + div.friends + a(href='{ user.username }/following') + b { user.following_count } + i フォロー + a(href='{ user.username }/followers') + b { user.followers_count } + i フォロワー + nav + a(data-is-active={ page == 'posts' }, onclick={ go-posts }) 投稿 + a(data-is-active={ page == 'media' }, onclick={ go-media }) メディア + a(data-is-active={ page == 'graphs' }, onclick={ go-graphs }) グラフ + a(data-is-active={ page == 'likes' }, onclick={ go-likes }) いいね + + div.body + mk-user-timeline(if={ page == 'posts' }, user={ user }) + mk-user-timeline(if={ page == 'media' }, user={ user }, with-media={ true }) + mk-user-graphs(if={ page == 'graphs' }, user={ user }) + +style. + display block + + > .user + > header + > .banner + padding-bottom 33.3% + background-color #f5f5f5 + background-size cover + background-position center + + > .body + padding 8px + margin 0 auto + max-width 600px + + > .top + &:after + content '' + display block + clear both + + > .avatar + display block + float left + width 25% + height 40px + + > img + display block + position absolute + left -2px + bottom -2px + width 100% + border 2px solid #fff + border-radius 6px + + @media (min-width 500px) + left -4px + bottom -4px + border 4px solid #fff + border-radius 12px + + > mk-follow-button + float right + height 40px + + > .title + margin 8px 0 + + > h1 + margin 0 + line-height 22px + font-size 20px + color #222 + + > .username + display inline-block + line-height 20px + font-size 16px + font-weight bold + color #657786 + + > .followed + margin-left 8px + padding 2px 4px + font-size 12px + color #657786 + background #f8f8f8 + border-radius 4px + + > .bio + margin 8px 0 + color #333 + + > .info + margin 8px 0 + + > .location + display inline + margin 0 + color #555 + + > i + margin-right 4px + + > .friends + > a + color #657786 + + &:first-child + margin-right 16px + + > b + margin-right 4px + font-size 16px + color #14171a + + > i + font-size 14px + + > nav + display flex + justify-content center + margin 0 auto + max-width 600px + border-bottom solid 1px #ddd + + > a + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + text-decoration none + color #657786 + border-bottom solid 2px transparent + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + + > .body + @media (min-width 500px) + padding 16px 0 0 0 + +script. + @mixin \i + @mixin \api + + @username = @opts.user + @page = if @opts.page? then @opts.page else \posts + @fetching = true + + @on \mount ~> + @api \users/show do + username: @username + .then (user) ~> + @fetching = false + @user = user + @trigger \loaded user + @update! + + @go-posts = ~> + @page = \posts + @update! + + @go-media = ~> + @page = \media + @update! + + @go-graphs = ~> + @page = \graphs + @update! + + @go-likes = ~> + @page = \likes + @update! diff --git a/src/web/app/mobile/tags/users-list.tag b/src/web/app/mobile/tags/users-list.tag new file mode 100644 index 0000000000..3e29a0a4cc --- /dev/null +++ b/src/web/app/mobile/tags/users-list.tag @@ -0,0 +1,125 @@ +mk-users-list + nav + span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) + | すべて + span { opts.count } + // ↓ https://github.com/riot/riot/issues/2080 + span(if={ SIGNIN && opts.you-know-count != '' }, data-is-active={ mode == 'iknow' }, onclick={ set-mode.bind(this, 'iknow') }) + | 知り合い + span { opts.you-know-count } + + div.users(if={ !fetching && users.length != 0 }) + mk-user-preview(each={ users }, user={ this }) + + button.more(if={ !fetching && next != null }, onclick={ more }, disabled={ more-fetching }) + span(if={ !more-fetching }) もっと + span(if={ more-fetching }) + | 読み込み中 + mk-ellipsis + + p.no(if={ !fetching && users.length == 0 }) + | { opts.no-users } + p.fetching(if={ fetching }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > nav + display flex + justify-content center + margin 0 auto + max-width 600px + border-bottom solid 1px #ddd + + > span + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + color #657786 + border-bottom solid 2px transparent + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + + > span + display inline-block + margin-left 4px + padding 2px 5px + font-size 12px + line-height 1 + color #888 + background #eee + border-radius 20px + + > .users + > * + max-width 600px + margin 0 auto + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + > .no + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \i + + @limit = 30users + @mode = \all + + @fetching = true + @more-fetching = false + + @on \mount ~> + @fetch ~> + @trigger \loaded + + @fetch = (cb) ~> + @fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + null + @users = obj.users + @next = obj.next + @fetching = false + @update! + if cb? then cb! + + @more = ~> + @more-fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + @cursor + @users = @users.concat obj.users + @next = obj.next + @more-fetching = false + @update! + + @set-mode = (mode) ~> + @update do + mode: mode + + @fetch! diff --git a/src/web/app/reset.styl b/src/web/app/reset.styl new file mode 100644 index 0000000000..cf872337cf --- /dev/null +++ b/src/web/app/reset.styl @@ -0,0 +1,27 @@ +* + position relative + box-sizing border-box + background-clip padding-box !important + +input:not([type]) +input[type='text'] +input[type='password'] +input[type='email'] +textarea +button +progress + -webkit-appearance none + -moz-appearance none + appearance none + box-shadow none + +button + margin 0 + padding 0 + background transparent + border none + cursor pointer + color inherit + + * + pointer-events none diff --git a/src/web/apple-touch-icon.ts b/src/web/apple-touch-icon.ts new file mode 100644 index 0000000000..32e1840405 --- /dev/null +++ b/src/web/apple-touch-icon.ts @@ -0,0 +1,8 @@ +import * as path from 'path'; +import * as express from 'express'; + +const app = express.Router(); +app.get('/apple-touch-icon.png', (req, res) => + res.sendFile(path.resolve(__dirname + '/../../resources/apple-touch-icon.png'))); + +module.exports = app; diff --git a/src/web/manifest.ts b/src/web/manifest.ts new file mode 100644 index 0000000000..e87b0ee328 --- /dev/null +++ b/src/web/manifest.ts @@ -0,0 +1,6 @@ +import * as express from 'express'; + +const app = express.Router(); +app.get('/manifest.json', (req, res) => res.sendFile(__dirname + '/../../resources/manifest.json')); + +module.exports = app; diff --git a/src/web/meta.ts b/src/web/meta.ts new file mode 100644 index 0000000000..9729faa9ea --- /dev/null +++ b/src/web/meta.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; +const git = require('git-last-commit'); + +module.exports = async (req: express.Request, res: express.Response) => { + // Get commit info + git.getLastCommit((err, commit) => { + res.send({ + commit: commit + }); + }, { + dst: `${__dirname}/../../misskey` + }); +}; diff --git a/src/web/serve-app.ts b/src/web/serve-app.ts new file mode 100644 index 0000000000..3292cfde37 --- /dev/null +++ b/src/web/serve-app.ts @@ -0,0 +1,9 @@ +import * as path from 'path'; +import * as express from 'express'; +import * as ms from 'ms'; + +export default (name: string) => (req: express.Request, res: express.Response) => { + res.sendFile(path.resolve(`${__dirname}/app/${name}/view.html`), { + maxAge: ms('7 days') + }); +}; diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 0000000000..d30680f68c --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,77 @@ +/** + * Web Server + */ + +import * as ms from 'ms'; + +// express modules +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as favicon from 'serve-favicon'; +import * as compression from 'compression'; +const subdomain = require('subdomain'); +import serveApp from './serve-app'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); +app.set('view engine', 'pug'); + +app.use(bodyParser.urlencoded({ extended: true })); +app.use(compression()); + +/** + * Initialize requests + */ +app.use((req, res, next) => { + res.header('X-Frame-Options', 'DENY'); + next(); +}); + +/** + * Static resources + */ +app.use(favicon(`${__dirname}/resources/favicon.ico`)); +app.use(require('./manifest')); +app.use(require('./apple-touch-icon')); +app.use('/_/resources', express.static(`${__dirname}/resources`, { + maxAge: ms('7 days') +})); + +/** + * Common API + */ +app.get(/\/api:meta/, require('./meta')); +app.get(/\/api:url/, require('./service/url-preview')); +app.post(/\/api:rss/, require('./service/rss-proxy')); + +/** + * Subdomain + */ +app.use(subdomain({ + base: config.host, + prefix: '@' +})); + +/** + * Routing + */ + +app.use('/@/about/resources', express.static(`${__dirname}/about/resources`, { + maxAge: ms('7 days') +})); + +app.get('/@/about/:page(*)', (req, res) => { + res.render(`${__dirname}/about/pages/${req.params.page}`, { + path: req.params.page, + config: config + }); +}); + +app.get('/@/auth/*', serveApp('auth')); // authorize form +app.get('/@/dev/*', serveApp('dev')); // developer center +app.get('*', serveApp('client')); // client + +module.exports = app; diff --git a/src/web/service/proxy/proxy.ts b/src/web/service/proxy/proxy.ts new file mode 100644 index 0000000000..34c1deafad --- /dev/null +++ b/src/web/service/proxy/proxy.ts @@ -0,0 +1,30 @@ +import * as url from 'url'; +import * as express from 'express'; +import * as request from 'request'; + +module.exports = (req: express.Request, res: express.Response) => { + const _url = req.params.url; + + if (!_url) { + return; + } + + request({ + url: _url + url.parse(req.url, true).search, + encoding: null + }, (err, response, content) => { + if (err) { + console.error(err); + return; + } + + const contentType = response.headers['content-type']; + + if (/^text\//.test(contentType) || contentType === 'application/javascript') { + content = content.toString().replace(/http:\/\//g, `${config.secondary_scheme}://proxy.${config.secondary_host}/http://`); + } + + res.header('Content-Type', contentType); + res.send(content); + }); +}; diff --git a/src/web/service/proxy/server.ts b/src/web/service/proxy/server.ts new file mode 100644 index 0000000000..5b1b8d106c --- /dev/null +++ b/src/web/service/proxy/server.ts @@ -0,0 +1,17 @@ +/** + * Forward Proxy Service + */ + +import * as express from 'express'; +import * as cors from 'cors'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); +app.use(cors()); + +app.get('/:url(*)', require('./proxy')); + +module.exports = app; diff --git a/src/web/service/rss-proxy.ts b/src/web/service/rss-proxy.ts new file mode 100644 index 0000000000..8cc3711e70 --- /dev/null +++ b/src/web/service/rss-proxy.ts @@ -0,0 +1,16 @@ +import * as express from 'express'; +import * as request from 'request'; +const xml2json = require('xml2json'); + +module.exports = (req: express.Request, res: express.Response) => { + const url: string = req.body.url; + + request(url, (err, response, xml) => { + if (err) { + console.error(err); + return; + } + + res.send(xml2json.toJson(xml)); + }); +}; diff --git a/src/web/service/url-preview.ts b/src/web/service/url-preview.ts new file mode 100644 index 0000000000..d1a345ef17 --- /dev/null +++ b/src/web/service/url-preview.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; +import summaly from 'summaly'; + +module.exports = async (req: express.Request, res: express.Response) => { + const summary = await summaly(req.query.url); + summary.icon = wrap(summary.icon); + summary.thumbnail = wrap(summary.thumbnail); + res.send(summary); +}; + +function wrap(url: string): string { + return `${config.proxy_url}/${url}`; +} |