summaryrefslogtreecommitdiff
path: root/src/web/app/common
diff options
context:
space:
mode:
authorsyuilo <syuilotan@yahoo.co.jp>2016-12-29 07:49:51 +0900
committersyuilo <syuilotan@yahoo.co.jp>2016-12-29 07:49:51 +0900
commitb3f42e62af698a67c2250533c437569559f1fdf9 (patch)
treecdf6937576e99cccf85e6fa3aa8860a1173c7cfb /src/web/app/common
downloadsharkey-b3f42e62af698a67c2250533c437569559f1fdf9.tar.gz
sharkey-b3f42e62af698a67c2250533c437569559f1fdf9.tar.bz2
sharkey-b3f42e62af698a67c2250533c437569559f1fdf9.zip
Initial commit :four_leaf_clover:
Diffstat (limited to 'src/web/app/common')
-rw-r--r--src/web/app/common/mixins.ls40
-rw-r--r--src/web/app/common/pages/about/base.pug13
-rw-r--r--src/web/app/common/pages/about/pages/staff.pug13
-rw-r--r--src/web/app/common/scripts/api.ls67
-rw-r--r--src/web/app/common/scripts/bytes-to-size.js6
-rw-r--r--src/web/app/common/scripts/check-for-update.ls9
-rw-r--r--src/web/app/common/scripts/date-stringify.ls14
-rw-r--r--src/web/app/common/scripts/generate-default-userdata.ls27
-rw-r--r--src/web/app/common/scripts/get-post-summary.ls26
-rw-r--r--src/web/app/common/scripts/i.ls16
-rw-r--r--src/web/app/common/scripts/is-promise.ls1
-rw-r--r--src/web/app/common/scripts/loading.ls16
-rw-r--r--src/web/app/common/scripts/log.ls18
-rw-r--r--src/web/app/common/scripts/messaging-stream.ls34
-rw-r--r--src/web/app/common/scripts/signout.ls4
-rw-r--r--src/web/app/common/scripts/stream.ls42
-rw-r--r--src/web/app/common/scripts/text-compiler.js30
-rw-r--r--src/web/app/common/scripts/uuid.js12
-rw-r--r--src/web/app/common/tags.ls16
-rw-r--r--src/web/app/common/tags/copyright.tag5
-rw-r--r--src/web/app/common/tags/core-error.tag63
-rw-r--r--src/web/app/common/tags/ellipsis.tag25
-rw-r--r--src/web/app/common/tags/file-type-icon.tag9
-rw-r--r--src/web/app/common/tags/forkit.tag37
-rw-r--r--src/web/app/common/tags/introduction.tag22
-rw-r--r--src/web/app/common/tags/number.tag15
-rw-r--r--src/web/app/common/tags/raw.tag7
-rw-r--r--src/web/app/common/tags/ripple-string.tag24
-rw-r--r--src/web/app/common/tags/signin.tag136
-rw-r--r--src/web/app/common/tags/signup.tag352
-rw-r--r--src/web/app/common/tags/special-message.tag24
-rw-r--r--src/web/app/common/tags/time.tag43
-rw-r--r--src/web/app/common/tags/uploader.tag201
-rw-r--r--src/web/app/common/tags/url-preview.tag105
-rw-r--r--src/web/app/common/tags/url.tag50
35 files changed, 1522 insertions, 0 deletions
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, '&gt;')
+ .replace(/</g, '&lt;')
+ .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!