This commit is contained in:
syuilo 2018-02-10 16:22:14 +09:00
parent dd60907abe
commit 4f1795b97b
16 changed files with 576 additions and 551 deletions

View file

@ -0,0 +1,7 @@
import Vue from 'vue';
import signin from './signin.vue';
import signup from './signup.vue';
Vue.component('mk-signin', signin);
Vue.component('mk-signup', signup);

View file

@ -0,0 +1,118 @@
<template>
<div :data-is-voted="isVoted">
<ul>
<li v-for="choice in poll.choices" :key="choice.id" @click="vote.bind(choice.id)" :class="{ voted: choice.voted }" :title="!isVoted ? '%i18n:common.tags.mk-poll.vote-to%'.replace('{}', choice.text) : ''">
<div class="backdrop" :style="{ 'width:' + (showResult ? (choice.votes / total * 100) : 0) + '%' }"></div>
<span>
<template v-if="choice.is_voted">%fa:check%</template>
{{ text }}
<span class="votes" v-if="showResult">({{ '%i18n:common.tags.mk-poll.vote-count%'.replace('{}', choice.votes) }})</span>
</span>
</li>
</ul>
<p v-if="total > 0">
<span>{{ '%i18n:common.tags.mk-poll.total-users%'.replace('{}', total) }}</span>
<a v-if="!isVoted" @click="toggleShowResult">{{ showResult ? '%i18n:common.tags.mk-poll.vote%' : '%i18n:common.tags.mk-poll.show-result%' }}</a>
<span v-if="isVoted">%i18n:common.tags.mk-poll.voted%</span>
</p>
</div>
</template>
<script lang="typescript">
export default {
props: ['post'],
data() {
return {
showResult: false
};
},
computed: {
poll() {
return this.post.poll;
},
total() {
return this.poll.choices.reduce((a, b) => a + b.votes, 0);
},
isVoted() {
return this.poll.choices.some(c => c.is_voted);
}
},
created() {
this.showResult = this.isVoted;
},
methods: {
toggleShowResult() {
this.showResult = !this.showResult;
},
vote(id) {
if (this.poll.choices.some(c => c.is_voted)) return;
this.api('posts/polls/vote', {
post_id: this.post.id,
choice: id
}).then(() => {
this.poll.choices.forEach(c => {
if (c.id == id) {
c.votes++;
c.is_voted = true;
}
});
this.showResult = true;
});
}
}
};
</script>
<style lang="stylus" scoped>
:scope
display block
> ul
display block
margin 0
padding 0
list-style none
> li
display block
margin 4px 0
padding 4px 8px
width 100%
border solid 1px #eee
border-radius 4px
overflow hidden
cursor pointer
&:hover
background rgba(0, 0, 0, 0.05)
&:active
background rgba(0, 0, 0, 0.1)
> .backdrop
position absolute
top 0
left 0
height 100%
background $theme-color
transition width 1s ease
> .votes
margin-left 4px
> p
a
color inherit
&[data-is-voted]
> ul > li
cursor default
&:hover
background transparent
&:active
background transparent
</style>

View file

@ -0,0 +1,20 @@
<template>
<span>
<img v-if="reaction == 'like'" src="/assets/reactions/like.png" alt="%i18n:common.reactions.like%">
<img v-if="reaction == 'love'" src="/assets/reactions/love.png" alt="%i18n:common.reactions.love%">
<img v-if="reaction == 'laugh'" src="/assets/reactions/laugh.png" alt="%i18n:common.reactions.laugh%">
<img v-if="reaction == 'hmm'" src="/assets/reactions/hmm.png" alt="%i18n:common.reactions.hmm%">
<img v-if="reaction == 'surprise'" src="/assets/reactions/surprise.png" alt="%i18n:common.reactions.surprise%">
<img v-if="reaction == 'congrats'" src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%">
<img v-if="reaction == 'angry'" src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%">
<img v-if="reaction == 'confused'" src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%">
<img v-if="reaction == 'pudding'" src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%">
</span>
</template>
<style lang="stylus" scoped>
img
vertical-align middle
width 1em
height 1em
</style>

View file

@ -0,0 +1,188 @@
<template>
<div>
<div class="backdrop" ref="backdrop" @click="close"></div>
<div class="popover" :class="{ compact }" ref="popover">
<p v-if="!compact">{{ title }}</p>
<div>
<button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" title="%i18n:common.reactions.like%"><mk-reaction-icon reaction='like'/></button>
<button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" title="%i18n:common.reactions.love%"><mk-reaction-icon reaction='love'/></button>
<button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" title="%i18n:common.reactions.laugh%"><mk-reaction-icon reaction='laugh'/></button>
<button @click="react('hmm')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.hmm%"><mk-reaction-icon reaction='hmm'/></button>
<button @click="react('surprise')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.surprise%"><mk-reaction-icon reaction='surprise'/></button>
<button @click="react('congrats')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.congrats%"><mk-reaction-icon reaction='congrats'/></button>
<button @click="react('angry')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.angry%"><mk-reaction-icon reaction='angry'/></button>
<button @click="react('confused')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.confused%"><mk-reaction-icon reaction='confused'/></button>
<button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.pudding%"><mk-reaction-icon reaction='pudding'/></button>
</div>
</div>
</div>
</template>
<script lang="typescript">
import anime from 'animejs';
import api from '../scripts/api';
import MkReactionIcon from './reaction-icon.vue';
const placeholder = '%i18n:common.tags.mk-reaction-picker.choose-reaction%';
export default {
components: {
MkReactionIcon
},
props: ['post', 'source', 'compact', 'cb'],
data() {
return {
title: placeholder
};
},
created() {
const rect = this.source.getBoundingClientRect();
const width = this.$refs.popover.offsetWidth;
const height = this.$refs.popover.offsetHeight;
if (this.compact) {
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2);
this.$refs.popover.style.left = (x - (width / 2)) + 'px';
this.$refs.popover.style.top = (y - (height / 2)) + 'px';
} else {
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
const y = rect.top + window.pageYOffset + this.source.offsetHeight;
this.$refs.popover.style.left = (x - (width / 2)) + 'px';
this.$refs.popover.style.top = y + 'px';
}
anime({
targets: this.$refs.backdrop,
opacity: 1,
duration: 100,
easing: 'linear'
});
anime({
targets: this.$refs.popover,
opacity: 1,
scale: [0.5, 1],
duration: 500
});
},
methods: {
react(reaction) {
api('posts/reactions/create', {
post_id: this.post.id,
reaction: reaction
}).then(() => {
if (this.cb) this.cb();
this.$destroy();
});
},
onMouseover(e) {
this.title = e.target.title;
},
onMouseout(e) {
this.title = placeholder;
},
close() {
this.$refs.backdrop.style.pointerEvents = 'none';
anime({
targets: this.$refs.backdrop,
opacity: 0,
duration: 200,
easing: 'linear'
});
this.$refs.popover.style.pointerEvents = 'none';
anime({
targets: this.$refs.popover,
opacity: 0,
scale: 0.5,
duration: 200,
easing: 'easeInBack',
complete: () => this.$destroy()
});
}
}
};
</script>
<style lang="stylus" scoped>
$border-color = rgba(27, 31, 35, 0.15)
:scope
display block
position initial
> .backdrop
position fixed
top 0
left 0
z-index 10000
width 100%
height 100%
background rgba(0, 0, 0, 0.1)
opacity 0
> .popover
position absolute
z-index 10001
background #fff
border 1px solid $border-color
border-radius 4px
box-shadow 0 3px 12px rgba(27, 31, 35, 0.15)
transform scale(0.5)
opacity 0
$balloon-size = 16px
&:not(.compact)
margin-top $balloon-size
transform-origin center -($balloon-size)
&:before
content ""
display block
position absolute
top -($balloon-size * 2)
left s('calc(50% - %s)', $balloon-size)
border-top solid $balloon-size transparent
border-left solid $balloon-size transparent
border-right solid $balloon-size transparent
border-bottom solid $balloon-size $border-color
&:after
content ""
display block
position absolute
top -($balloon-size * 2) + 1.5px
left s('calc(50% - %s)', $balloon-size)
border-top solid $balloon-size transparent
border-left solid $balloon-size transparent
border-right solid $balloon-size transparent
border-bottom solid $balloon-size #fff
> p
display block
margin 0
padding 8px 10px
font-size 14px
color #586069
border-bottom solid 1px #e1e4e8
> div
padding 4px
width 240px
text-align center
> button
width 40px
height 40px
font-size 24px
border-radius 2px
&:hover
background #eee
&:active
background $theme-color
box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15)
</style>

View file

@ -0,0 +1,49 @@
<template>
<div>
<template v-if="reactions">
<span v-if="reactions.like"><mk-reaction-icon reaction='like'/><span>{{ reactions.like }}</span></span>
<span v-if="reactions.love"><mk-reaction-icon reaction='love'/><span>{{ reactions.love }}</span></span>
<span v-if="reactions.laugh"><mk-reaction-icon reaction='laugh'/><span>{{ reactions.laugh }}</span></span>
<span v-if="reactions.hmm"><mk-reaction-icon reaction='hmm'/><span>{{ reactions.hmm }}</span></span>
<span v-if="reactions.surprise"><mk-reaction-icon reaction='surprise'/><span>{{ reactions.surprise }}</span></span>
<span v-if="reactions.congrats"><mk-reaction-icon reaction='congrats'/><span>{{ reactions.congrats }}</span></span>
<span v-if="reactions.angry"><mk-reaction-icon reaction='angry'/><span>{{ reactions.angry }}</span></span>
<span v-if="reactions.confused"><mk-reaction-icon reaction='confused'/><span>{{ reactions.confused }}</span></span>
<span v-if="reactions.pudding"><mk-reaction-icon reaction='pudding'/><span>{{ reactions.pudding }}</span></span>
</template>
</div>
</template>
<script lang="typescript">
export default {
props: ['post'],
computed: {
reactions() {
return this.post.reaction_counts;
}
}
};
</script>
<style lang="stylus" scoped>
:scope
display block
border-top dashed 1px #eee
border-bottom dashed 1px #eee
margin 4px 0
&:empty
display none
> span
margin-right 8px
> mk-reaction-icon
font-size 1.4em
> span
margin-left 4px
font-size 1.2em
color #444
</style>

View file

@ -0,0 +1,138 @@
<template>
<form class="form" :class="{ signing: signing }" @submit.prevent="onSubmit">
<label class="user-name">
<input v-model="username" type="text" pattern="^[a-zA-Z0-9-]+$" placeholder="%i18n:common.tags.mk-signin.username%" autofocus required @change="onUsernameChange"/>%fa:at%
</label>
<label class="password">
<input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signin.password%" required/>%fa:lock%
</label>
<label class="token" v-if="user && user.two_factor_enabled">
<input v-model="token" type="number" placeholder="%i18n:common.tags.mk-signin.token%" required/>%fa:lock%
</label>
<button type="submit" disabled={ signing }>{ signing ? '%i18n:common.tags.mk-signin.signing-in%' : '%i18n:common.tags.mk-signin.signin%' }</button>
</form>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['os'],
data() {
return {
signing: false,
user: null
};
},
methods: {
onUsernameChange() {
this.os.api('users/show', {
username: this.username
}).then(user => {
this.user = user;
});
},
onSubmit() {
this.signing = true;
this.os.api('signin', {
username: this.username,
password: this.password,
token: this.user && this.user.two_factor_enabled ? this.token : undefined
}).then(() => {
location.reload();
}).catch(() => {
alert('something happened');
this.signing = false;
});
}
}
});
</script>
<style lang="stylus" scoped>
.form
display block
z-index 2
&.signing
&, *
cursor wait !important
label
display block
margin 12px 0
[data-fa]
display block
pointer-events none
position absolute
bottom 0
top 0
left 0
z-index 1
margin auto
padding 0 16px
height 1em
color #898786
input[type=text]
input[type=password]
input[type=number]
user-select text
display inline-block
cursor auto
padding 0 0 0 38px
margin 0
width 100%
line-height 44px
font-size 1em
color rgba(0, 0, 0, 0.7)
background #fff
outline none
border solid 1px #eee
border-radius 4px
&:hover
background rgba(255, 255, 255, 0.7)
border-color #ddd
& + i
color #797776
&:focus
background #fff
border-color #ccc
& + i
color #797776
[type=submit]
cursor pointer
padding 16px
margin -6px 0 0 0
width 100%
font-size 1.2em
color rgba(0, 0, 0, 0.5)
outline none
border none
border-radius 0
background transparent
transition all .5s ease
&:hover
color $theme-color
transition all .2s ease
&:focus
color $theme-color
transition all .2s ease
&:active
color darken($theme-color, 30%)
transition all .2s ease
&:disabled
opacity 0.7
</style>

View file

@ -0,0 +1,331 @@
<template>
<form @submit.prevent="onSubmit" autocomplete="off">
<label class="username">
<p class="caption">%fa:at%%i18n:common.tags.mk-signup.username%</p>
<input v-model="username" type="text" pattern="^[a-zA-Z0-9-]{3,20}$" placeholder="a~z、A~Z、0~9、-" autocomplete="off" required @keyup="onChangeUsername"/>
<p class="profile-page-url-preview" v-if="refs.username.value != '' && username-state != 'invalidFormat' && username-state != 'minRange' && username-state != 'maxRange'">{ _URL_ + '/' + refs.username.value }</p>
<p class="info" v-if="usernameState == 'wait'" style="color:#999">%fa:spinner .pulse .fw%%i18n:common.tags.mk-signup.checking%</p>
<p class="info" v-if="usernameState == 'ok'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.available%</p>
<p class="info" v-if="usernameState == 'unavailable'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.unavailable%</p>
<p class="info" v-if="usernameState == 'error'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.error%</p>
<p class="info" v-if="usernameState == 'invalid-format'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.invalid-format%</p>
<p class="info" v-if="usernameState == 'min-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-short%</p>
<p class="info" v-if="usernameState == 'max-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-long%</p>
</label>
<label class="password">
<p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%</p>
<input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signup.password-placeholder%" autocomplete="off" required @keyup="onChangePassword"/>
<div class="meter" v-if="passwordStrength != ''" :data-strength="passwordStrength">
<div class="value" ref="passwordMetar"></div>
</div>
<p class="info" v-if="passwordStrength == 'low'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.weak-password%</p>
<p class="info" v-if="passwordStrength == 'medium'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.normal-password%</p>
<p class="info" v-if="passwordStrength == 'high'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.strong-password%</p>
</label>
<label class="retype-password">
<p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%(%i18n:common.tags.mk-signup.retype%)</p>
<input v-model="passwordRetype" type="password" placeholder="%i18n:common.tags.mk-signup.retype-placeholder%" autocomplete="off" required @keyup="onChangePasswordRetype"/>
<p class="info" v-if="passwordRetypeState == 'match'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.password-matched%</p>
<p class="info" v-if="passwordRetypeState == 'not-match'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.password-not-matched%</p>
</label>
<label class="recaptcha">
<p class="caption"><template v-if="recaptchaed">%fa:toggle-on%</template><template v-if="!recaptchaed">%fa:toggle-off%</template>%i18n:common.tags.mk-signup.recaptcha%</p>
<div v-if="recaptcha" class="g-recaptcha" data-callback="onRecaptchaed" data-expired-callback="onRecaptchaExpired" data-sitekey="recaptcha.site_key"></div>
</label>
<label class="agree-tou">
<input name="agree-tou" type="checkbox" autocomplete="off" required/>
<p><a :href="touUrl" target="_blank">利用規約</a>に同意する</p>
</label>
<button type="submit">%i18n:common.tags.mk-signup.create%</button>
</form>
</template>
<script lang="ts">
import Vue from 'vue';
const getPasswordStrength = require('syuilo-password-strength');
import
const aboutUrl = `${_DOCS_URL_}/${_LANG_}/tou`;
export default Vue.extend({
methods: {
onSubmit() {
}
},
mounted() {
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js');
head.appendChild(script);
}
});
</script>
<style lang="stylus" scoped>
:scope
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
> [data-fa]
margin-right 0.25em
color #96adac
> .info
display block
margin 4px 0
font-size 0.8em
> [data-fa]
margin-right 0.3em
&.username
.profile-page-url-preview
display block
margin 4px 8px 0 4px
font-size 0.8em
color #888
&:empty
display none
&:not(:empty) + .info
margin-top 0
&.password
.meter
display block
margin-top 8px
width 100%
height 8px
&[data-strength='']
display none
&[data-strength='low']
> .value
background #d73612
&[data-strength='medium']
> .value
background #d7ca12
&[data-strength='high']
> .value
background #61bb22
> .value
display block
width 0%
height 100%
background transparent
border-radius 4px
transition all 0.1s ease
[type=text], [type=password]
user-select text
display inline-block
cursor auto
padding 0 12px
margin 0
width 100%
line-height 44px
font-size 1em
color #333 !important
background #fff !important
outline none
border solid 1px rgba(0, 0, 0, 0.1)
border-radius 4px
box-shadow 0 0 0 114514px #fff inset
transition all .3s ease
&:hover
border-color rgba(0, 0, 0, 0.2)
transition all .1s ease
&:focus
color $theme-color !important
border-color $theme-color
box-shadow 0 0 0 1024px #fff inset, 0 0 0 4px rgba($theme-color, 10%)
transition all 0s ease
&:disabled
opacity 0.5
.agree-tou
padding 4px
border-radius 4px
&:hover
background #f4f4f4
&:active
background #eee
&, *
cursor pointer
p
display inline
color #555
button
margin 0 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%)
</style>
<script lang="typescript">
this.mixin('api');
this.usernameState = null;
this.passwordStrength = '';
this.passwordRetypeState = null;
this.recaptchaed = false;
this.aboutUrl = `${_DOCS_URL_}/${_LANG_}/tou`;
window.onRecaptchaed = () => {
this.recaptchaed = true;
this.update();
};
window.onRecaptchaExpired = () => {
this.recaptchaed = false;
this.update();
};
this.on('mount', () => {
this.update({
recaptcha: {
site_key: _RECAPTCHA_SITEKEY_
}
});
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js');
head.appendChild(script);
});
this.onChangeUsername = () => {
const username = this.$refs.username.value;
if (username == '') {
this.update({
usernameState: null
});
return;
}
const err =
!username.match(/^[a-zA-Z0-9\-]+$/) ? 'invalid-format' :
username.length < 3 ? 'min-range' :
username.length > 20 ? 'max-range' :
null;
if (err) {
this.update({
usernameState: err
});
return;
}
this.update({
usernameState: 'wait'
});
this.api('username/available', {
username: username
}).then(result => {
this.update({
usernameState: result.available ? 'ok' : 'unavailable'
});
}).catch(err => {
this.update({
usernameState: 'error'
});
});
};
this.onChangePassword = () => {
const password = this.$refs.password.value;
if (password == '') {
this.passwordStrength = '';
return;
}
const strength = getPasswordStrength(password);
this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
this.update();
this.$refs.passwordMetar.style.width = `${strength * 100}%`;
};
this.onChangePasswordRetype = () => {
const password = this.$refs.password.value;
const retypedPassword = this.$refs.passwordRetype.value;
if (retypedPassword == '') {
this.passwordRetypeState = null;
return;
}
this.passwordRetypeState = password == retypedPassword ? 'match' : 'not-match';
};
this.onsubmit = e => {
e.preventDefault();
const username = this.$refs.username.value;
const password = this.$refs.password.value;
const locker = document.body.appendChild(document.createElement('mk-locker'));
this.api('signup', {
username: username,
password: password,
'g-recaptcha-response': grecaptcha.getResponse()
}).then(() => {
this.api('signin', {
username: username,
password: password
}).then(() => {
location.href = '/';
});
}).catch(() => {
alert('%i18n:common.tags.mk-signup.some-error%');
grecaptcha.reset();
this.recaptchaed = false;
locker.parentNode.removeChild(locker);
});
return false;
};
</script>

View file

@ -0,0 +1,74 @@
<template>
<div>
<p v-if=" stream.state == 'initializing' ">
%fa:spinner .pulse%
<span>%i18n:common.tags.mk-stream-indicator.connecting%<mk-ellipsis/></span>
</p>
<p v-if=" stream.state == 'reconnecting' ">
%fa:spinner .pulse%
<span>%i18n:common.tags.mk-stream-indicator.reconnecting%<mk-ellipsis/></span>
</p>
<p v-if=" stream.state == 'connected' ">
%fa:check%
<span>%i18n:common.tags.mk-stream-indicator.connected%</span>
</p>
</div>
</template>
<script lang="typescript">
import anime from 'animejs';
import Ellipsis from './ellipsis.vue';
export default {
props: ['stream'],
created() {
if (this.stream.state == 'connected') {
this.root.style.opacity = 0;
}
this.stream.on('_connected_', () => {
setTimeout(() => {
anime({
targets: this.root,
opacity: 0,
easing: 'linear',
duration: 200
});
}, 1000);
});
this.stream.on('_closed_', () => {
anime({
targets: this.root,
opacity: 1,
easing: 'linear',
duration: 100
});
});
}
};
</script>
<style lang="stylus" scoped>
> div
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)
border-radius 4px
> p
display block
margin 0
> [data-fa]
margin-right 0.25em
</style>

View file

@ -0,0 +1,63 @@
<template>
<time>
<span v-if=" mode == 'relative' ">{{ relative }}</span>
<span v-if=" mode == 'absolute' ">{{ absolute }}</span>
<span v-if=" mode == 'detail' ">{{ absolute }} ({{ relative }})</span>
</time>
</template>
<script lang="typescript">
import Vue from 'vue';
export default Vue.extend({
props: ['time', 'mode'],
data() {
return {
mode: 'relative',
tickId: null,
now: new Date()
};
},
computed: {
absolute() {
return (
this.time.getFullYear() + '年' +
(this.time.getMonth() + 1) + '月' +
this.time.getDate() + '日' +
' ' +
this.time.getHours() + '時' +
this.time.getMinutes() + '分');
},
relative() {
const ago = (this.now - this.time) / 1000/*ms*/;
return (
ago >= 31536000 ? '%i18n:common.time.years_ago%' .replace('{}', ~~(ago / 31536000)) :
ago >= 2592000 ? '%i18n:common.time.months_ago%' .replace('{}', ~~(ago / 2592000)) :
ago >= 604800 ? '%i18n:common.time.weeks_ago%' .replace('{}', ~~(ago / 604800)) :
ago >= 86400 ? '%i18n:common.time.days_ago%' .replace('{}', ~~(ago / 86400)) :
ago >= 3600 ? '%i18n:common.time.hours_ago%' .replace('{}', ~~(ago / 3600)) :
ago >= 60 ? '%i18n:common.time.minutes_ago%'.replace('{}', ~~(ago / 60)) :
ago >= 10 ? '%i18n:common.time.seconds_ago%'.replace('{}', ~~(ago % 60)) :
ago >= 0 ? '%i18n:common.time.just_now%' :
ago < 0 ? '%i18n:common.time.future%' :
'%i18n:common.time.unknown%');
}
},
created() {
if (this.mode == 'relative' || this.mode == 'detail') {
this.tick();
this.tickId = setInterval(this.tick, 1000);
}
},
destroyed() {
if (this.mode === 'relative' || this.mode === 'detail') {
clearInterval(this.tickId);
}
},
methods: {
tick() {
this.now = new Date();
}
}
});
</script>

View file

@ -0,0 +1,126 @@
<template>
<a :href="url" target="_blank" :title="url" v-if="!fetching">
<div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div>
<article>
<header>
<h1>{{ title }}</h1>
</header>
<p>{{ description }}</p>
<footer>
<img class="icon" v-if="icon" :src="icon"/>
<p>{{ sitename }}</p>
</footer>
</article>
</a>
</template>
<script lang="typescript">
export default {
props: ['url'],
data() {
return {
fetching: true,
title: null,
description: null,
thumbnail: null,
icon: null,
sitename: null
};
},
created() {
fetch('/api:url?url=' + this.url).then(res => {
res.json().then(info => {
this.title = info.title;
this.description = info.description;
this.thumbnail = info.thumbnail;
this.icon = info.icon;
this.sitename = info.sitename;
this.fetching = false;
});
});
}
};
</script>
<style lang="stylus" scoped>
:scope
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
height 16px
> img
display inline-block
width 16px
height 16px
margin-right 4px
vertical-align top
> p
display inline-block
margin 0
color #666
font-size 0.8em
line-height 16px
vertical-align top
@media (max-width 500px)
font-size 8px
> a
border none
> .thumbnail
width 70px
& + article
left 70px
width calc(100% - 70px)
> article
padding 8px
</style>

View file

@ -0,0 +1,65 @@
<template>
<a :href="url" :target="target">
<span class="schema">{{ schema }}//</span>
<span class="hostname">{{ hostname }}</span>
<span class="port" v-if="port != ''">:{{ port }}</span>
<span class="pathname" v-if="pathname != ''">{{ pathname }}</span>
<span class="query">{{ query }}</span>
<span class="hash">{{ hash }}</span>
%fa:external-link-square-alt%
</a>
</template>
<script lang="typescript">
export default {
props: ['url', 'target'],
data() {
return {
schema: null,
hostname: null,
port: null,
pathname: null,
query: null,
hash: null
};
},
created() {
const url = new URL(this.url);
this.schema = url.protocol;
this.hostname = url.hostname;
this.port = url.port;
this.pathname = url.pathname;
this.query = url.search;
this.hash = url.hash;
}
};
</script>
<style lang="stylus" scoped>
:scope
word-break break-all
> a
> [data-fa]
padding-left 2px
font-size .9em
font-weight 400
font-style normal
> .schema
opacity 0.5
> .hostname
font-weight bold
> .pathname
opacity 0.8
> .query
opacity 0.5
> .hash
font-style italic
</style>