整理した

This commit is contained in:
syuilo
2018-03-29 20:32:18 +09:00
parent 8a279a4656
commit cf33e483f7
552 changed files with 360 additions and 1311 deletions

View File

@ -0,0 +1,66 @@
<template>
<svg viewBox="0 0 21 7" preserveAspectRatio="none">
<rect v-for="record in data" class="day"
width="1" height="1"
:x="record.x" :y="record.date.weekday"
rx="1" ry="1"
fill="transparent">
<title>{{ record.date.year }}/{{ record.date.month }}/{{ record.date.day }}</title>
</rect>
<rect v-for="record in data" class="day"
:width="record.v" :height="record.v"
:x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)"
rx="1" ry="1"
:fill="record.color"
style="pointer-events: none;"/>
<rect class="today"
width="1" height="1"
:x="data[data.length - 1].x" :y="data[data.length - 1].date.weekday"
rx="1" ry="1"
fill="none"
stroke-width="0.1"
stroke="#f73520"/>
</svg>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['data'],
created() {
this.data.forEach(d => d.total = d.posts + d.replies + d.reposts);
const peak = Math.max.apply(null, this.data.map(d => d.total));
let x = 0;
this.data.reverse().forEach(d => {
d.x = x;
d.date.weekday = (new Date(d.date.year, d.date.month - 1, d.date.day)).getDay();
d.v = peak == 0 ? 0 : d.total / (peak / 2);
if (d.v > 1) d.v = 1;
const ch = d.date.weekday == 0 || d.date.weekday == 6 ? 275 : 170;
const cs = d.v * 100;
const cl = 15 + ((1 - d.v) * 80);
d.color = `hsl(${ch}, ${cs}%, ${cl}%)`;
if (d.date.weekday == 6) x++;
});
}
});
</script>
<style lang="stylus" scoped>
svg
display block
padding 10px
width 100%
> rect
transform-origin center
&.day
&:hover
fill rgba(0, 0, 0, 0.05)
</style>

View File

@ -0,0 +1,103 @@
<template>
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none" @mousedown.prevent="onMousedown">
<title>Black ... Total<br/>Blue ... Posts<br/>Red ... Replies<br/>Green ... Reposts</title>
<polyline
:points="pointsPost"
fill="none"
stroke-width="1"
stroke="#41ddde"/>
<polyline
:points="pointsReply"
fill="none"
stroke-width="1"
stroke="#f7796c"/>
<polyline
:points="pointsRepost"
fill="none"
stroke-width="1"
stroke="#a1de41"/>
<polyline
:points="pointsTotal"
fill="none"
stroke-width="1"
stroke="#555"
stroke-dasharray="2 2"/>
</svg>
</template>
<script lang="ts">
import Vue from 'vue';
function dragListen(fn) {
window.addEventListener('mousemove', fn);
window.addEventListener('mouseleave', dragClear.bind(null, fn));
window.addEventListener('mouseup', dragClear.bind(null, fn));
}
function dragClear(fn) {
window.removeEventListener('mousemove', fn);
window.removeEventListener('mouseleave', dragClear);
window.removeEventListener('mouseup', dragClear);
}
export default Vue.extend({
props: ['data'],
data() {
return {
viewBoxX: 140,
viewBoxY: 60,
zoom: 1,
pos: 0,
pointsPost: null,
pointsReply: null,
pointsRepost: null,
pointsTotal: null
};
},
created() {
this.data.reverse();
this.data.forEach(d => d.total = d.posts + d.replies + d.reposts);
this.render();
},
methods: {
render() {
const peak = Math.max.apply(null, this.data.map(d => d.total));
if (peak != 0) {
this.pointsPost = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.posts / peak)) * this.viewBoxY}`).join(' ');
this.pointsReply = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' ');
this.pointsRepost = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.reposts / peak)) * this.viewBoxY}`).join(' ');
this.pointsTotal = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ');
}
},
onMousedown(e) {
const clickX = e.clientX;
const clickY = e.clientY;
const baseZoom = this.zoom;
const basePos = this.pos;
// 動かした時
dragListen(me => {
let moveLeft = me.clientX - clickX;
let moveTop = me.clientY - clickY;
this.zoom = baseZoom + (-moveTop / 20);
this.pos = basePos + moveLeft;
if (this.zoom < 1) this.zoom = 1;
if (this.pos > 0) this.pos = 0;
if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX);
this.render();
});
}
}
});
</script>
<style lang="stylus" scoped>
svg
display block
padding 10px
width 100%
cursor all-scroll
</style>

View File

@ -0,0 +1,116 @@
<template>
<div class="mk-activity" :data-melt="design == 2">
<template v-if="design == 0">
<p class="title">%fa:chart-bar%%i18n:desktop.tags.mk-activity-widget.title%</p>
<button @click="toggle" title="%i18n:desktop.tags.mk-activity-widget.toggle%">%fa:sort%</button>
</template>
<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
<template v-else>
<x-calendar v-show="view == 0" :data="[].concat(activity)"/>
<x-chart v-show="view == 1" :data="[].concat(activity)"/>
</template>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XCalendar from './activity.calendar.vue';
import XChart from './activity.chart.vue';
export default Vue.extend({
components: {
XCalendar,
XChart
},
props: {
design: {
default: 0
},
initView: {
default: 0
},
user: {
type: Object,
required: true
}
},
data() {
return {
fetching: true,
activity: null,
view: this.initView
};
},
mounted() {
(this as any).api('aggregation/users/activity', {
userId: this.user.id,
limit: 20 * 7
}).then(activity => {
this.activity = activity;
this.fetching = false;
});
},
methods: {
toggle() {
if (this.view == 1) {
this.view = 0;
this.$emit('viewChanged', this.view);
} else {
this.view++;
this.$emit('viewChanged', this.view);
}
}
}
});
</script>
<style lang="stylus" scoped>
.mk-activity
background #fff
border solid 1px rgba(0, 0, 0, 0.075)
border-radius 6px
&[data-melt]
background transparent !important
border none !important
> .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)
> [data-fa]
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
> .fetching
margin 0
padding 16px
text-align center
color #aaa
> [data-fa]
margin-right 4px
</style>

View File

@ -0,0 +1,108 @@
<template>
<canvas class="mk-analog-clock" ref="canvas" width="256" height="256"></canvas>
</template>
<script lang="ts">
import Vue from 'vue';
import { themeColor } from '../../../config';
const Vec2 = function(this: any, x, y) {
this.x = x;
this.y = y;
};
export default Vue.extend({
data() {
return {
clock: null
};
},
mounted() {
this.tick();
this.clock = setInterval(this.tick, 1000);
},
beforeDestroy() {
clearInterval(this.clock);
},
methods: {
tick() {
const canv = this.$refs.canvas as any;
const now = new Date();
const s = now.getSeconds();
const m = now.getMinutes();
const h = now.getHours();
const ctx = canv.getContext('2d');
const canvW = canv.width;
const canvH = canv.height;
ctx.clearRect(0, 0, canvW, canvH);
{ // 背景
const center = Math.min((canvW / 2), (canvH / 2));
const lineStart = center * 0.90;
const shortLineEnd = center * 0.87;
const longLineEnd = center * 0.84;
for (let i = 0; i < 60; i++) {
const angle = Math.PI * i / 30;
const uv = new Vec2(Math.sin(angle), -Math.cos(angle));
ctx.beginPath();
ctx.lineWidth = 1;
ctx.moveTo((canvW / 2) + uv.x * lineStart, (canvH / 2) + uv.y * lineStart);
if (i % 5 == 0) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
ctx.lineTo((canvW / 2) + uv.x * longLineEnd, (canvH / 2) + uv.y * longLineEnd);
} else {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineTo((canvW / 2) + uv.x * shortLineEnd, (canvH / 2) + uv.y * shortLineEnd);
}
ctx.stroke();
}
}
{ // 分
const angle = Math.PI * (m + s / 60) / 30;
const length = Math.min(canvW, canvH) / 2.6;
const uv = new Vec2(Math.sin(angle), -Math.cos(angle));
ctx.beginPath();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5);
ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length);
ctx.stroke();
}
{ // 時
const angle = Math.PI * (h % 12 + m / 60) / 6;
const length = Math.min(canvW, canvH) / 4;
const uv = new Vec2(Math.sin(angle), -Math.cos(angle));
ctx.beginPath();
ctx.strokeStyle = themeColor;
ctx.lineWidth = 2;
ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5);
ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length);
ctx.stroke();
}
{ // 秒
const angle = Math.PI * s / 30;
const length = Math.min(canvW, canvH) / 2.6;
const uv = new Vec2(Math.sin(angle), -Math.cos(angle));
ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 1;
ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5);
ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length);
ctx.stroke();
}
}
}
});
</script>
<style lang="stylus" scoped>
.mk-analog-clock
display block
width 256px
height 256px
</style>

View File

@ -0,0 +1,252 @@
<template>
<div class="mk-calendar" :data-melt="design == 4 || design == 5">
<template v-if="design == 0 || design == 1">
<button @click="prev" title="%i18n:desktop.tags.mk-calendar-widget.prev%">%fa:chevron-circle-left%</button>
<p class="title">{{ '%i18n:desktop.tags.mk-calendar-widget.title%'.replace('{1}', year).replace('{2}', month) }}</p>
<button @click="next" title="%i18n:desktop.tags.mk-calendar-widget.next%">%fa:chevron-circle-right%</button>
</template>
<div class="calendar">
<template v-if="design == 0 || design == 2 || design == 4">
<div class="weekday"
v-for="(day, i) in Array(7).fill(0)"
:data-today="year == today.getFullYear() && month == today.getMonth() + 1 && today.getDay() == i"
:data-is-donichi="i == 0 || i == 6"
>{{ weekdayText[i] }}</div>
</template>
<div v-for="n in paddingDays"></div>
<div class="day" v-for="(day, i) in days"
:data-today="isToday(i + 1)"
:data-selected="isSelected(i + 1)"
:data-is-out-of-range="isOutOfRange(i + 1)"
:data-is-donichi="isDonichi(i + 1)"
@click="go(i + 1)"
:title="isOutOfRange(i + 1) ? null : '%i18n:desktop.tags.mk-calendar-widget.go%'"
>
<div>{{ i + 1 }}</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
const eachMonthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
function isLeapYear(year) {
return (year % 400 == 0) ? true :
(year % 100 == 0) ? false :
(year % 4 == 0) ? true :
false;
}
export default Vue.extend({
props: {
design: {
default: 0
},
start: {
type: Date,
required: false
}
},
data() {
return {
today: new Date(),
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
selected: new Date(),
weekdayText: [
'%i18n:common.weekday-short.sunday%',
'%i18n:common.weekday-short.monday%',
'%i18n:common.weekday-short.tuesday%',
'%i18n:common.weekday-short.wednesday%',
'%i18n:common.weekday-short.thursday%',
'%i18n:common.weekday-short.friday%',
'%i18n:common.weekday-short.satruday%'
]
};
},
computed: {
paddingDays(): number {
const date = new Date(this.year, this.month - 1, 1);
return date.getDay();
},
days(): number {
let days = eachMonthDays[this.month - 1];
// うるう年なら+1日
if (this.month == 2 && isLeapYear(this.year)) days++;
return days;
}
},
methods: {
isToday(day) {
return this.year == this.today.getFullYear() && this.month == this.today.getMonth() + 1 && day == this.today.getDate();
},
isSelected(day) {
return this.year == this.selected.getFullYear() && this.month == this.selected.getMonth() + 1 && day == this.selected.getDate();
},
isOutOfRange(day) {
const test = (new Date(this.year, this.month - 1, day)).getTime();
return test > this.today.getTime() ||
(this.start ? test < (this.start as any).getTime() : false);
},
isDonichi(day) {
const weekday = (new Date(this.year, this.month - 1, day)).getDay();
return weekday == 0 || weekday == 6;
},
prev() {
if (this.month == 1) {
this.year = this.year - 1;
this.month = 12;
} else {
this.month--;
}
},
next() {
if (this.month == 12) {
this.year = this.year + 1;
this.month = 1;
} else {
this.month++;
}
},
go(day) {
if (this.isOutOfRange(day)) return;
const date = new Date(this.year, this.month - 1, day, 23, 59, 59, 999);
this.selected = date;
this.$emit('chosen', this.selected);
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-calendar
color #777
background #fff
border solid 1px rgba(0, 0, 0, 0.075)
border-radius 6px
&[data-melt]
background transparent !important
border none !important
> .title
z-index 1
margin 0
padding 0 16px
text-align center
line-height 42px
font-size 0.9em
font-weight bold
color #888
box-shadow 0 1px rgba(0, 0, 0, 0.07)
> [data-fa]
margin-right 4px
> button
position absolute
z-index 2
top 0
padding 0
width 42px
font-size 0.9em
line-height 42px
color #ccc
&:hover
color #aaa
&:active
color #999
&:first-of-type
left 0
&:last-of-type
right 0
> .calendar
display flex
flex-wrap wrap
padding 16px
*
user-select none
> div
width calc(100% * (1/7))
text-align center
line-height 32px
font-size 14px
&.weekday
color #19a2a9
&[data-is-donichi]
color #ef95a0
&[data-today]
box-shadow 0 0 0 1px #19a2a9 inset
border-radius 6px
&[data-is-donichi]
box-shadow 0 0 0 1px #ef95a0 inset
&.day
cursor pointer
color #777
> div
border-radius 6px
&:hover > div
background rgba(0, 0, 0, 0.025)
&:active > div
background rgba(0, 0, 0, 0.05)
&[data-is-donichi]
color #ef95a0
&[data-is-out-of-range]
cursor default
color rgba(#777, 0.5)
&[data-is-donichi]
color rgba(#ef95a0, 0.5)
&[data-selected]
font-weight bold
> div
background rgba(0, 0, 0, 0.025)
&:active > div
background rgba(0, 0, 0, 0.05)
&[data-today]
> div
color $theme-color-foreground
background $theme-color
&:hover > div
background lighten($theme-color, 10%)
&:active > div
background darken($theme-color, 10%)
</style>

View File

@ -0,0 +1,180 @@
<template>
<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy">
<span slot="header">
<span v-html="title" :class="$style.title"></span>
<span :class="$style.count" v-if="multiple && files.length > 0">({{ files.length }}ファイル選択中)</span>
</span>
<mk-drive
ref="browser"
:class="$style.browser"
:multiple="multiple"
@selected="onSelected"
@change-selection="onChangeSelection"
/>
<div :class="$style.footer">
<button :class="$style.upload" title="PCからドライブにファイルをアップロード" @click="upload">%fa:upload%</button>
<button :class="$style.cancel" @click="cancel">キャンセル</button>
<button :class="$style.ok" :disabled="multiple && files.length == 0" @click="ok">決定</button>
</div>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
multiple: {
default: false
},
title: {
default: '%fa:R file%ファイルを選択'
}
},
data() {
return {
files: []
};
},
methods: {
onSelected(file) {
this.files = [file];
this.ok();
},
onChangeSelection(files) {
this.files = files;
},
upload() {
(this.$refs.browser as any).selectLocalFile();
},
ok() {
this.$emit('selected', this.multiple ? this.files : this.files[0]);
(this.$refs.window as any).close();
},
cancel() {
(this.$refs.window as any).close();
}
}
});
</script>
<style lang="stylus" module>
@import '~const.styl'
.title
> [data-fa]
margin-right 4px
.count
margin-left 8px
opacity 0.7
.browser
height calc(100% - 72px)
.footer
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
</style>

View File

@ -0,0 +1,114 @@
<template>
<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy">
<span slot="header">
<span v-html="title" :class="$style.title"></span>
</span>
<mk-drive
ref="browser"
:class="$style.browser"
:multiple="false"
/>
<div :class="$style.footer">
<button :class="$style.cancel" @click="cancel">キャンセル</button>
<button :class="$style.ok" @click="ok">決定</button>
</div>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
title: {
default: '%fa:R folder%フォルダを選択'
}
},
methods: {
ok() {
this.$emit('selected', (this.$refs.browser as any).folder);
(this.$refs.window as any).close();
},
cancel() {
(this.$refs.window as any).close();
}
}
});
</script>
<style lang="stylus" module>
@import '~const.styl'
.title
> [data-fa]
margin-right 4px
.browser
height calc(100% - 72px)
.footer
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
</style>

View File

@ -0,0 +1,121 @@
<template>
<ul class="menu">
<li v-for="(item, i) in menu" :class="item.type">
<template v-if="item.type == 'item'">
<p @click="click(item)"><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}</p>
</template>
<template v-if="item.type == 'link'">
<a :href="item.href" :target="item.target" @click="click(item)"><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}</a>
</template>
<template v-else-if="item.type == 'nest'">
<p><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}...<span class="caret">%fa:caret-right%</span></p>
<me-nu :menu="item.menu" @x="click"/>
</template>
</li>
</ul>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'me-nu',
props: ['menu'],
methods: {
click(item) {
this.$emit('x', item);
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.menu
$width = 240px
$item-height = 38px
$padding = 10px
margin 0
padding $padding 0
list-style none
li
display block
&.divider
margin-top $padding
padding-top $padding
border-top solid 1px #eee
&.nest
> p
cursor default
> .caret
position absolute
top 0
right 8px
> *
line-height $item-height
width 28px
text-align center
&: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
&: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
</style>
<style lang="stylus" module>
.icon
> *
width 28px
margin-left -28px
text-align center
</style>

View File

@ -0,0 +1,74 @@
<template>
<div class="context-menu" :style="{ left: `${x}px`, top: `${y}px` }" @contextmenu.prevent="() => {}">
<x-menu :menu="menu" @x="click"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as anime from 'animejs';
import contains from '../../../common/scripts/contains';
import XMenu from './context-menu.menu.vue';
export default Vue.extend({
components: {
XMenu
},
props: ['x', 'y', 'menu'],
mounted() {
this.$nextTick(() => {
Array.from(document.querySelectorAll('body *')).forEach(el => {
el.addEventListener('mousedown', this.onMousedown);
});
this.$el.style.display = 'block';
anime({
targets: this.$el,
opacity: [0, 1],
duration: 100,
easing: 'linear'
});
});
},
methods: {
onMousedown(e) {
e.preventDefault();
if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
return false;
},
click(item) {
if (item.onClick) item.onClick();
this.close();
},
close() {
Array.from(document.querySelectorAll('body *')).forEach(el => {
el.removeEventListener('mousedown', this.onMousedown);
});
this.$emit('closed');
this.$destroy();
}
}
});
</script>
<style lang="stylus" scoped>
.context-menu
$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)
opacity 0
</style>

View File

@ -0,0 +1,178 @@
<template>
<mk-window ref="window" is-modal width="800px" :can-close="false">
<span slot="header">%fa:crop%{{ title }}</span>
<div class="body">
<vue-cropper ref="cropper"
:src="image.url"
:view-mode="1"
:aspect-ratio="aspectRatio"
:container-style="{ width: '100%', 'max-height': '400px' }"
/>
</div>
<div :class="$style.actions">
<button :class="$style.skip" @click="skip">クロップをスキップ</button>
<button :class="$style.cancel" @click="cancel">キャンセル</button>
<button :class="$style.ok" @click="ok">決定</button>
</div>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
import VueCropper from 'vue-cropperjs';
export default Vue.extend({
components: {
VueCropper
},
props: {
image: {
type: Object,
required: true
},
title: {
type: String,
required: true
},
aspectRatio: {
type: Number,
required: true
}
},
methods: {
ok() {
(this.$refs.cropper as any).getCroppedCanvas().toBlob(blob => {
this.$emit('cropped', blob);
(this.$refs.window as any).close();
});
},
skip() {
this.$emit('skipped');
(this.$refs.window as any).close();
},
cancel() {
this.$emit('canceled');
(this.$refs.window as any).close();
}
}
});
</script>
<style lang="stylus" module>
@import '~const.styl'
.header
> [data-fa]
margin-right 4px
.img
width 100%
max-height 400px
.actions
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
</style>
<style lang="stylus">
.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;
}
@keyframes cropper-bg {
0% {
background-position: 0 0;
}
100% {
background-position: -8px -8px;
}
}
</style>

View File

@ -0,0 +1,170 @@
<template>
<div class="mk-dialog">
<div class="bg" ref="bg" @click="onBgClick"></div>
<div class="main" ref="main">
<header v-html="title" :class="$style.header"></header>
<div class="body" v-html="text"></div>
<div class="buttons">
<button v-for="button in buttons" @click="click(button)">{{ button.text }}</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as anime from 'animejs';
export default Vue.extend({
props: {
title: {
type: String,
required: false
},
text: {
type: String,
required: true
},
buttons: {
type: Array,
default: () => {
return [{
text: 'OK'
}];
}
},
modal: {
type: Boolean,
default: false
}
},
mounted() {
this.$nextTick(() => {
(this.$refs.bg as any).style.pointerEvents = 'auto';
anime({
targets: this.$refs.bg,
opacity: 1,
duration: 100,
easing: 'linear'
});
anime({
targets: this.$refs.main,
opacity: 1,
scale: [1.2, 1],
duration: 300,
easing: [0, 0.5, 0.5, 1]
});
});
},
methods: {
click(button) {
this.$emit('clicked', button.id);
this.close();
},
close() {
(this.$refs.bg as any).style.pointerEvents = 'none';
anime({
targets: this.$refs.bg,
opacity: 0,
duration: 300,
easing: 'linear'
});
(this.$refs.main as any).style.pointerEvents = 'none';
anime({
targets: this.$refs.main,
opacity: 0,
scale: 0.8,
duration: 300,
easing: [ 0.5, -0.5, 1, 0.5 ],
complete: () => this.$destroy()
});
},
onBgClick() {
if (!this.modal) {
this.close();
}
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-dialog
> .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
opacity 0
> .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
</style>
<style lang="stylus" module>
@import '~const.styl'
.header
margin 1em 0
color $theme-color
// color #43A4EC
font-weight bold
&:empty
display none
> i
margin-right 0.5em
</style>

View File

@ -0,0 +1,56 @@
<template>
<mk-window ref="window" @closed="$destroy" width="800px" height="500px" :popout-url="popout">
<template slot="header">
<p v-if="usage" :class="$style.info"><b>{{ usage.toFixed(1) }}%</b> %i18n:desktop.tags.mk-drive-browser-window.used%</p>
<span :class="$style.title">%fa:cloud%%i18n:desktop.tags.mk-drive-browser-window.drive%</span>
</template>
<mk-drive :class="$style.browser" multiple :init-folder="folder" ref="browser"/>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
import { url } from '../../../config';
export default Vue.extend({
props: ['folder'],
data() {
return {
usage: null
};
},
mounted() {
(this as any).api('drive').then(info => {
this.usage = info.usage / info.capacity * 100;
});
},
methods: {
popout() {
const folder = (this.$refs.browser as any) ? (this.$refs.browser as any).folder : null;
if (folder) {
return `${url}/i/drive/folder/${folder.id}`;
} else {
return `${url}/i/drive`;
}
}
}
});
</script>
<style lang="stylus" module>
.title
> [data-fa]
margin-right 4px
.info
position absolute
top 0
left 16px
margin 0
font-size 80%
.browser
height 100%
</style>

View File

@ -0,0 +1,317 @@
<template>
<div class="root file"
:data-is-selected="isSelected"
:data-is-contextmenu-showing="isContextmenuShowing"
@click="onClick"
draggable="true"
@dragstart="onDragstart"
@dragend="onDragend"
@contextmenu.prevent.stop="onContextmenu"
:title="title"
>
<div class="label" v-if="os.i.avatarId == file.id"><img src="/assets/label.svg"/>
<p>%i18n:desktop.tags.mk-drive-browser-file.avatar%</p>
</div>
<div class="label" v-if="os.i.bannerId == file.id"><img src="/assets/label.svg"/>
<p>%i18n:desktop.tags.mk-drive-browser-file.banner%</p>
</div>
<div class="thumbnail" ref="thumbnail" :style="`background-color: ${ background }`">
<img :src="`${file.url}?thumbnail&size=128`" alt="" @load="onThumbnailLoaded"/>
</div>
<p class="name">
<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
<span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as anime from 'animejs';
import contextmenu from '../../api/contextmenu';
import copyToClipboard from '../../../common/scripts/copy-to-clipboard';
export default Vue.extend({
props: ['file'],
data() {
return {
isContextmenuShowing: false,
isDragging: false
};
},
computed: {
browser(): any {
return this.$parent;
},
isSelected(): boolean {
return this.browser.selectedFiles.some(f => f.id == this.file.id);
},
title(): string {
return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.datasize)}`;
},
background(): string {
return this.file.properties.avgColor
? `rgb(${this.file.properties.avgColor.join(',')})`
: 'transparent';
}
},
methods: {
onClick() {
this.browser.chooseFile(this.file);
},
onContextmenu(e) {
this.isContextmenuShowing = true;
contextmenu(e, [{
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename%',
icon: '%fa:i-cursor%',
onClick: this.rename
}, {
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copy-url%',
icon: '%fa:link%',
onClick: this.copyUrl
}, {
type: 'link',
href: `${this.file.url}?download`,
text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.download%',
icon: '%fa:download%',
}, {
type: 'divider',
}, {
type: 'item',
text: '%i18n:common.delete%',
icon: '%fa:R trash-alt%',
onClick: this.deleteFile
}, {
type: 'divider',
}, {
type: 'nest',
text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.else-files%',
menu: [{
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-avatar%',
onClick: this.setAsAvatar
}, {
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-banner%',
onClick: this.setAsBanner
}]
}, {
type: 'nest',
text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.open-in-app%',
menu: [{
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.add-app%...',
onClick: this.addApp
}]
}], {
closed: () => {
this.isContextmenuShowing = false;
}
});
},
onDragstart(e) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file));
this.isDragging = true;
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
// (=あなたの子供が、ドラッグを開始しましたよ)
this.browser.isDragSource = true;
},
onDragend(e) {
this.isDragging = false;
this.browser.isDragSource = false;
},
onThumbnailLoaded() {
if (this.file.properties.avgColor) {
anime({
targets: this.$refs.thumbnail,
backgroundColor: `rgba(${this.file.properties.avgColor.join(',')}, 0)`,
duration: 100,
easing: 'linear'
});
}
},
rename() {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename-file%',
placeholder: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.input-new-file-name%',
default: this.file.name,
allowEmpty: false
}).then(name => {
(this as any).api('drive/files/update', {
fileId: this.file.id,
name: name
})
});
},
copyUrl() {
copyToClipboard(this.file.url);
(this as any).apis.dialog({
title: '%fa:check%%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied%',
text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied-url-to-clipboard%',
actions: [{
text: '%i18n:common.ok%'
}]
});
},
setAsAvatar() {
(this as any).apis.updateAvatar(this.file);
},
setAsBanner() {
(this as any).apis.updateBanner(this.file);
},
addApp() {
alert('not implemented yet');
},
deleteFile() {
alert('not implemented yet');
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.root.file
padding 8px 0 0 0
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]
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]
&: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
margin auto
> 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
</style>

View File

@ -0,0 +1,267 @@
<template>
<div class="root folder"
:data-is-contextmenu-showing="isContextmenuShowing"
:data-draghover="draghover"
@click="onClick"
@mouseover="onMouseover"
@mouseout="onMouseout"
@dragover.prevent.stop="onDragover"
@dragenter.prevent="onDragenter"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
draggable="true"
@dragstart="onDragstart"
@dragend="onDragend"
@contextmenu.prevent.stop="onContextmenu"
:title="title"
>
<p class="name">
<template v-if="hover">%fa:R folder-open .fw%</template>
<template v-if="!hover">%fa:R folder .fw%</template>
{{ folder.name }}
</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import contextmenu from '../../api/contextmenu';
export default Vue.extend({
props: ['folder'],
data() {
return {
hover: false,
draghover: false,
isDragging: false,
isContextmenuShowing: false
};
},
computed: {
browser(): any {
return this.$parent;
},
title(): string {
return this.folder.name;
}
},
methods: {
onClick() {
this.browser.move(this.folder);
},
onContextmenu(e) {
this.isContextmenuShowing = true;
contextmenu(e, [{
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.move-to-this-folder%',
icon: '%fa:arrow-right%',
onClick: this.go
}, {
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.show-in-new-window%',
icon: '%fa:R window-restore%',
onClick: this.newWindow
}, {
type: 'divider',
}, {
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename%',
icon: '%fa:i-cursor%',
onClick: this.rename
}, {
type: 'divider',
}, {
type: 'item',
text: '%i18n:common.delete%',
icon: '%fa:R trash-alt%',
onClick: this.deleteFolder
}], {
closed: () => {
this.isContextmenuShowing = false;
}
});
},
onMouseover() {
this.hover = true;
},
onMouseout() {
this.hover = false
},
onDragover(e) {
// 自分自身がドラッグされている場合
if (this.isDragging) {
// 自分自身にはドロップさせない
e.dataTransfer.dropEffect = 'none';
return;
}
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder';
if (isFile || isDriveFile || isDriveFolder) {
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
},
onDragenter() {
if (!this.isDragging) this.draghover = true;
},
onDragleave() {
this.draghover = false;
},
onDrop(e) {
this.draghover = false;
// ファイルだったら
if (e.dataTransfer.files.length > 0) {
Array.from(e.dataTransfer.files).forEach(file => {
this.browser.upload(file, this.folder);
});
return;
}
//#region ドライブのファイル
const driveFile = e.dataTransfer.getData('mk_drive_file');
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.browser.removeFile(file.id);
(this as any).api('drive/files/update', {
fileId: file.id,
folderId: this.folder.id
});
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = e.dataTransfer.getData('mk_drive_folder');
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// 移動先が自分自身ならreject
if (folder.id == this.folder.id) return;
this.browser.removeFolder(folder.id);
(this as any).api('drive/folders/update', {
folderId: folder.id,
parentId: this.folder.id
}).then(() => {
// noop
}).catch(err => {
switch (err) {
case 'detected-circular-definition':
(this as any).apis.dialog({
title: '%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser-folder.unable-to-process%',
text: '%i18n:desktop.tags.mk-drive-browser-folder.circular-reference-detected%',
actions: [{
text: '%i18n:common.ok%'
}]
});
break;
default:
alert('%i18n:desktop.tags.mk-drive-browser-folder.unhandled-error% ' + err);
}
});
}
//#endregion
},
onDragstart(e) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('mk_drive_folder', JSON.stringify(this.folder));
this.isDragging = true;
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
// (=あなたの子供が、ドラッグを開始しましたよ)
this.browser.isDragSource = true;
},
onDragend() {
this.isDragging = false;
this.browser.isDragSource = false;
},
go() {
this.browser.move(this.folder.id);
},
newWindow() {
this.browser.newWindow(this.folder);
},
rename() {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename-folder%',
placeholder: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.input-new-folder-name%',
default: this.folder.name
}).then(name => {
(this as any).api('drive/folders/update', {
folderId: this.folder.id,
name: name
});
});
},
deleteFolder() {
alert('not implemented yet');
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.root.folder
padding 8px
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]
&[data-draghover]
&: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]
background lighten($theme-color, 90%)
> .name
margin 0
font-size 0.9em
color darken($theme-color, 30%)
> [data-fa]
margin-right 4px
margin-left 2px
text-align left
</style>

View File

@ -0,0 +1,113 @@
<template>
<div class="root nav-folder"
:data-draghover="draghover"
@click="onClick"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.stop="onDrop"
>
<template v-if="folder == null">%fa:cloud%</template>
<span>{{ folder == null ? '%i18n:desktop.tags.mk-drive-browser-nav-folder.drive%' : folder.name }}</span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['folder'],
data() {
return {
hover: false,
draghover: false
};
},
computed: {
browser(): any {
return this.$parent;
}
},
methods: {
onClick() {
this.browser.move(this.folder);
},
onMouseover() {
this.hover = true;
},
onMouseout() {
this.hover = false;
},
onDragover(e) {
// このフォルダがルートかつカレントディレクトリならドロップ禁止
if (this.folder == null && this.browser.folder == null) {
e.dataTransfer.dropEffect = 'none';
}
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder';
if (isFile || isDriveFile || isDriveFolder) {
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
return false;
},
onDragenter() {
if (this.folder || this.browser.folder) this.draghover = true;
},
onDragleave() {
if (this.folder || this.browser.folder) this.draghover = false;
},
onDrop(e) {
this.draghover = false;
// ファイルだったら
if (e.dataTransfer.files.length > 0) {
Array.from(e.dataTransfer.files).forEach(file => {
this.browser.upload(file, this.folder);
});
return;
}
//#region ドライブのファイル
const driveFile = e.dataTransfer.getData('mk_drive_file');
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.browser.removeFile(file.id);
(this as any).api('drive/files/update', {
fileId: file.id,
folderId: this.folder ? this.folder.id : null
});
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = e.dataTransfer.getData('mk_drive_folder');
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// 移動先が自分自身ならreject
if (this.folder && folder.id == this.folder.id) return;
this.browser.removeFolder(folder.id);
(this as any).api('drive/folders/update', {
folderId: folder.id,
parentId: this.folder ? this.folder.id : null
});
}
//#endregion
}
}
});
</script>
<style lang="stylus" scoped>
.root.nav-folder
> *
pointer-events none
&[data-draghover]
background #eee
</style>

View File

@ -0,0 +1,773 @@
<template>
<div class="mk-drive">
<nav>
<div class="path" @contextmenu.prevent.stop="() => {}">
<x-nav-folder :class="{ current: folder == null }"/>
<template v-for="folder in hierarchyFolders">
<span class="separator">%fa:angle-right%</span>
<x-nav-folder :folder="folder" :key="folder.id"/>
</template>
<span class="separator" v-if="folder != null">%fa:angle-right%</span>
<span class="folder current" v-if="folder != null">{{ folder.name }}</span>
</div>
<input class="search" type="search" placeholder="&#xf002; %i18n:desktop.tags.mk-drive-browser.search%"/>
</nav>
<div class="main" :class="{ uploading: uploadings.length > 0, fetching }"
ref="main"
@mousedown="onMousedown"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
@contextmenu.prevent.stop="onContextmenu"
>
<div class="selection" ref="selection"></div>
<div class="contents" ref="contents">
<div class="folders" ref="foldersContainer" v-if="folders.length > 0">
<x-folder v-for="folder in folders" :key="folder.id" class="folder" :folder="folder"/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
<div class="padding" v-for="n in 16"></div>
<button v-if="moreFolders">%i18n:desktop.tags.mk-drive-browser.load-more%</button>
</div>
<div class="files" ref="filesContainer" v-if="files.length > 0">
<x-file v-for="file in files" :key="file.id" class="file" :file="file"/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
<div class="padding" v-for="n in 16"></div>
<button v-if="moreFiles" @click="fetchMoreFiles">%i18n:desktop.tags.mk-drive-browser.load-more%</button>
</div>
<div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching">
<p v-if="draghover">%i18n:desktop.tags.mk-drive-browser.empty-draghover%</p>
<p v-if="!draghover && folder == null"><strong>%i18n:desktop.tags.mk-drive-browser.empty-drive%</strong><br/>%i18n:desktop.tags.mk-drive-browser.empty-drive-description%</p>
<p v-if="!draghover && folder != null">%i18n:desktop.tags.mk-drive-browser.empty-folder%</p>
</div>
</div>
<div class="fetching" v-if="fetching">
<div class="spinner">
<div class="dot1"></div>
<div class="dot2"></div>
</div>
</div>
</div>
<div class="dropzone" v-if="draghover"></div>
<mk-uploader ref="uploader" @change="onChangeUploaderUploads" @uploaded="onUploaderUploaded"/>
<input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MkDriveWindow from './drive-window.vue';
import XNavFolder from './drive.nav-folder.vue';
import XFolder from './drive.folder.vue';
import XFile from './drive.file.vue';
import contains from '../../../common/scripts/contains';
import contextmenu from '../../api/contextmenu';
import { url } from '../../../config';
export default Vue.extend({
components: {
XNavFolder,
XFolder,
XFile
},
props: {
initFolder: {
type: Object,
required: false
},
multiple: {
type: Boolean,
default: false
}
},
data() {
return {
/**
* 現在の階層(フォルダ)
* * null でルートを表す
*/
folder: null,
files: [],
folders: [],
moreFiles: false,
moreFolders: false,
hierarchyFolders: [],
selectedFiles: [],
uploadings: [],
connection: null,
connectionId: null,
/**
* ドロップされようとしているか
*/
draghover: false,
/**
* 自信の所有するアイテムがドラッグをスタートさせたか
* (自分自身の階層にドロップできないようにするためのフラグ)
*/
isDragSource: false,
fetching: true
};
},
mounted() {
this.connection = (this as any).os.streams.driveStream.getConnection();
this.connectionId = (this as any).os.streams.driveStream.use();
this.connection.on('file_created', this.onStreamDriveFileCreated);
this.connection.on('file_updated', this.onStreamDriveFileUpdated);
this.connection.on('folder_created', this.onStreamDriveFolderCreated);
this.connection.on('folder_updated', this.onStreamDriveFolderUpdated);
if (this.initFolder) {
this.move(this.initFolder);
} else {
this.fetch();
}
},
beforeDestroy() {
this.connection.off('file_created', this.onStreamDriveFileCreated);
this.connection.off('file_updated', this.onStreamDriveFileUpdated);
this.connection.off('folder_created', this.onStreamDriveFolderCreated);
this.connection.off('folder_updated', this.onStreamDriveFolderUpdated);
(this as any).os.streams.driveStream.dispose(this.connectionId);
},
methods: {
onContextmenu(e) {
contextmenu(e, [{
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.create-folder%',
icon: '%fa:R folder%',
onClick: this.createFolder
}, {
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.upload%',
icon: '%fa:upload%',
onClick: this.selectLocalFile
}, {
type: 'item',
text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.url-upload%',
icon: '%fa:cloud-upload-alt%',
onClick: this.urlUpload
}]);
},
onStreamDriveFileCreated(file) {
this.addFile(file, true);
},
onStreamDriveFileUpdated(file) {
const current = this.folder ? this.folder.id : null;
if (current != file.folderId) {
this.removeFile(file);
} else {
this.addFile(file, true);
}
},
onStreamDriveFolderCreated(folder) {
this.addFolder(folder, true);
},
onStreamDriveFolderUpdated(folder) {
const current = this.folder ? this.folder.id : null;
if (current != folder.parentId) {
this.removeFolder(folder);
} else {
this.addFolder(folder, true);
}
},
onChangeUploaderUploads(uploads) {
this.uploadings = uploads;
},
onUploaderUploaded(file) {
this.addFile(file, true);
},
onMousedown(e): any {
if (contains(this.$refs.foldersContainer, e.target) || contains(this.$refs.filesContainer, e.target)) return true;
const main = this.$refs.main as any;
const selection = this.$refs.selection as any;
const rect = main.getBoundingClientRect();
const left = e.pageX + main.scrollLeft - rect.left - window.pageXOffset
const top = e.pageY + main.scrollTop - rect.top - window.pageYOffset
const move = e => {
selection.style.display = 'block';
const cursorX = e.pageX + main.scrollLeft - rect.left - window.pageXOffset;
const cursorY = e.pageY + main.scrollTop - rect.top - window.pageYOffset;
const w = cursorX - left;
const h = cursorY - top;
if (w > 0) {
selection.style.width = w + 'px';
selection.style.left = left + 'px';
} else {
selection.style.width = -w + 'px';
selection.style.left = cursorX + 'px';
}
if (h > 0) {
selection.style.height = h + 'px';
selection.style.top = top + 'px';
} else {
selection.style.height = -h + 'px';
selection.style.top = cursorY + 'px';
}
};
const up = e => {
document.documentElement.removeEventListener('mousemove', move);
document.documentElement.removeEventListener('mouseup', up);
selection.style.display = 'none';
};
document.documentElement.addEventListener('mousemove', move);
document.documentElement.addEventListener('mouseup', up);
},
onDragover(e): any {
// ドラッグ元が自分自身の所有するアイテムだったら
if (this.isDragSource) {
// 自分自身にはドロップさせない
e.dataTransfer.dropEffect = 'none';
return;
}
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder';
if (isFile || isDriveFile || isDriveFolder) {
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
return false;
},
onDragenter(e) {
if (!this.isDragSource) this.draghover = true;
},
onDragleave(e) {
this.draghover = false;
},
onDrop(e): any {
this.draghover = false;
// ドロップされてきたものがファイルだったら
if (e.dataTransfer.files.length > 0) {
Array.from(e.dataTransfer.files).forEach(file => {
this.upload(file, this.folder);
});
return;
}
//#region ドライブのファイル
const driveFile = e.dataTransfer.getData('mk_drive_file');
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
if (this.files.some(f => f.id == file.id)) return;
this.removeFile(file.id);
(this as any).api('drive/files/update', {
fileId: file.id,
folderId: this.folder ? this.folder.id : null
});
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = e.dataTransfer.getData('mk_drive_folder');
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// 移動先が自分自身ならreject
if (this.folder && folder.id == this.folder.id) return false;
if (this.folders.some(f => f.id == folder.id)) return false;
this.removeFolder(folder.id);
(this as any).api('drive/folders/update', {
folderId: folder.id,
parentId: this.folder ? this.folder.id : null
}).then(() => {
// noop
}).catch(err => {
switch (err) {
case 'detected-circular-definition':
(this as any).apis.dialog({
title: '%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser.unable-to-process%',
text: '%i18n:desktop.tags.mk-drive-browser.circular-reference-detected%',
actions: [{
text: '%i18n:common.ok%'
}]
});
break;
default:
alert('%i18n:desktop.tags.mk-drive-browser.unhandled-error% ' + err);
}
});
}
//#endregion
},
selectLocalFile() {
(this.$refs.fileInput as any).click();
},
urlUpload() {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-drive-browser.url-upload%',
placeholder: '%i18n:desktop.tags.mk-drive-browser.url-of-file%'
}).then(url => {
(this as any).api('drive/files/upload_from_url', {
url: url,
folderId: this.folder ? this.folder.id : undefined
});
(this as any).apis.dialog({
title: '%fa:check%%i18n:desktop.tags.mk-drive-browser.url-upload-requested%',
text: '%i18n:desktop.tags.mk-drive-browser.may-take-time%',
actions: [{
text: '%i18n:common.ok%'
}]
});
});
},
createFolder() {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-drive-browser.create-folder%',
placeholder: '%i18n:desktop.tags.mk-drive-browser.folder-name%'
}).then(name => {
(this as any).api('drive/folders/create', {
name: name,
folderId: this.folder ? this.folder.id : undefined
}).then(folder => {
this.addFolder(folder, true);
});
});
},
onChangeFileInput() {
Array.from((this.$refs.fileInput as any).files).forEach(file => {
this.upload(file, this.folder);
});
},
upload(file, folder) {
if (folder && typeof folder == 'object') folder = folder.id;
(this.$refs.uploader as any).upload(file, folder);
},
chooseFile(file) {
const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id);
if (this.multiple) {
if (isAlreadySelected) {
this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id);
} else {
this.selectedFiles.push(file);
}
this.$emit('change-selection', this.selectedFiles);
} else {
if (isAlreadySelected) {
this.$emit('selected', file);
} else {
this.selectedFiles = [file];
this.$emit('change-selection', [file]);
}
}
},
newWindow(folder) {
if (document.body.clientWidth > 800) {
(this as any).os.new(MkDriveWindow, {
folder: folder
});
} else {
window.open(url + '/i/drive/folder/' + folder.id,
'drive_window',
'height=500, width=800');
}
},
move(target) {
if (target == null) {
this.goRoot();
return;
} else if (typeof target == 'object') {
target = target.id;
}
this.fetching = true;
(this as any).api('drive/folders/show', {
folderId: target
}).then(folder => {
this.folder = folder;
this.hierarchyFolders = [];
const dive = folder => {
this.hierarchyFolders.unshift(folder);
if (folder.parent) dive(folder.parent);
};
if (folder.parent) dive(folder.parent);
this.$emit('open-folder', folder);
this.fetch();
});
},
addFolder(folder, unshift = false) {
const current = this.folder ? this.folder.id : null;
if (current != folder.parentId) return;
if (this.folders.some(f => f.id == folder.id)) {
const exist = this.folders.map(f => f.id).indexOf(folder.id);
Vue.set(this.folders, exist, folder);
return;
}
if (unshift) {
this.folders.unshift(folder);
} else {
this.folders.push(folder);
}
},
addFile(file, unshift = false) {
const current = this.folder ? this.folder.id : null;
if (current != file.folderId) return;
if (this.files.some(f => f.id == file.id)) {
const exist = this.files.map(f => f.id).indexOf(file.id);
Vue.set(this.files, exist, file);
return;
}
if (unshift) {
this.files.unshift(file);
} else {
this.files.push(file);
}
},
removeFolder(folder) {
if (typeof folder == 'object') folder = folder.id;
this.folders = this.folders.filter(f => f.id != folder);
},
removeFile(file) {
if (typeof file == 'object') file = file.id;
this.files = this.files.filter(f => f.id != file);
},
appendFile(file) {
this.addFile(file);
},
appendFolder(folder) {
this.addFolder(folder);
},
prependFile(file) {
this.addFile(file, true);
},
prependFolder(folder) {
this.addFolder(folder, true);
},
goRoot() {
// 既にrootにいるなら何もしない
if (this.folder == null) return;
this.folder = null;
this.hierarchyFolders = [];
this.$emit('move-root');
this.fetch();
},
fetch() {
this.folders = [];
this.files = [];
this.moreFolders = false;
this.moreFiles = false;
this.fetching = true;
let fetchedFolders = null;
let fetchedFiles = null;
const foldersMax = 30;
const filesMax = 30;
// フォルダ一覧取得
(this as any).api('drive/folders', {
folderId: this.folder ? this.folder.id : null,
limit: foldersMax + 1
}).then(folders => {
if (folders.length == foldersMax + 1) {
this.moreFolders = true;
folders.pop();
}
fetchedFolders = folders;
complete();
});
// ファイル一覧取得
(this as any).api('drive/files', {
folderId: this.folder ? this.folder.id : null,
limit: filesMax + 1
}).then(files => {
if (files.length == filesMax + 1) {
this.moreFiles = true;
files.pop();
}
fetchedFiles = files;
complete();
});
let flag = false;
const complete = () => {
if (flag) {
fetchedFolders.forEach(this.appendFolder);
fetchedFiles.forEach(this.appendFile);
this.fetching = false;
} else {
flag = true;
}
};
},
fetchMoreFiles() {
this.fetching = true;
const max = 30;
// ファイル一覧取得
(this as any).api('drive/files', {
folderId: this.folder ? this.folder.id : null,
limit: max + 1
}).then(files => {
if (files.length == max + 1) {
this.moreFiles = true;
files.pop();
} else {
this.moreFiles = false;
}
files.forEach(this.appendFile);
this.fetching = false;
});
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-drive
> 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
> [data-fa]
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-control-foreground-color
> .main
padding 8px
height calc(100% - 38px)
overflow auto
&, *
user-select none
&.fetching
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
> .files
display flex
flex-wrap wrap
> .folder
> .file
flex-grow 1
width 144px
margin 4px
> .padding
flex-grow 1
pointer-events none
width 144px + 8px // 8px is margin
> .empty
padding 16px
text-align center
color #999
pointer-events none
> p
margin 0
> .fetching
.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
</style>

View File

@ -0,0 +1,37 @@
<template>
<div class="mk-ellipsis-icon">
<div></div><div></div><div></div>
</div>
</template>
<style lang="stylus" scoped>
.mk-ellipsis-icon
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)
</style>

View File

@ -0,0 +1,164 @@
<template>
<button class="mk-follow-button"
:class="{ wait, follow: !user.isFollowing, unfollow: user.isFollowing, big: size == 'big' }"
@click="onClick"
:disabled="wait"
:title="user.isFollowing ? 'フォロー解除' : 'フォローする'"
>
<template v-if="!wait && user.isFollowing">
<template v-if="size == 'compact'">%fa:minus%</template>
<template v-if="size == 'big'">%fa:minus%フォロー解除</template>
</template>
<template v-if="!wait && !user.isFollowing">
<template v-if="size == 'compact'">%fa:plus%</template>
<template v-if="size == 'big'">%fa:plus%フォロー</template>
</template>
<template v-if="wait">%fa:spinner .pulse .fw%</template>
</button>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
user: {
type: Object,
required: true
},
size: {
type: String,
default: 'compact'
}
},
data() {
return {
wait: false,
connection: null,
connectionId: null
};
},
mounted() {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
this.connection.on('follow', this.onFollow);
this.connection.on('unfollow', this.onUnfollow);
},
beforeDestroy() {
this.connection.off('follow', this.onFollow);
this.connection.off('unfollow', this.onUnfollow);
(this as any).os.stream.dispose(this.connectionId);
},
methods: {
onFollow(user) {
if (user.id == this.user.id) {
this.user.isFollowing = user.isFollowing;
}
},
onUnfollow(user) {
if (user.id == this.user.id) {
this.user.isFollowing = user.isFollowing;
}
},
onClick() {
this.wait = true;
if (this.user.isFollowing) {
(this as any).api('following/delete', {
userId: this.user.id
}).then(() => {
this.user.isFollowing = false;
}).catch(err => {
console.error(err);
}).then(() => {
this.wait = false;
});
} else {
(this as any).api('following/create', {
userId: this.user.id
}).then(() => {
this.user.isFollowing = true;
}).catch(err => {
console.error(err);
}).then(() => {
this.wait = false;
});
}
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-follow-button
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
&.big
width 100%
height 38px
line-height 38px
i
margin-right 8px
</style>

View File

@ -0,0 +1,26 @@
<template>
<mk-window width="400px" height="550px" @closed="$destroy">
<span slot="header" :class="$style.header">
<img :src="`${user.avatarUrl}?thumbnail&size=64`" alt=""/>{{ user.name }}のフォロワー
</span>
<mk-followers :user="user"/>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['user']
});
</script>
<style lang="stylus" module>
.header
> img
display inline-block
vertical-align bottom
height calc(100% - 10px)
margin 5px
border-radius 4px
</style>

View File

@ -0,0 +1,26 @@
<template>
<mk-users-list
:fetch="fetch"
:count="user.followersCount"
:you-know-count="user.followersYouKnowCount"
>
フォロワーはいないようです
</mk-users-list>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['user'],
methods: {
fetch(iknow, limit, cursor, cb) {
(this as any).api('users/followers', {
userId: this.user.id,
iknow: iknow,
limit: limit,
cursor: cursor ? cursor : undefined
}).then(cb);
}
}
});
</script>

View File

@ -0,0 +1,26 @@
<template>
<mk-window width="400px" height="550px" @closed="$destroy">
<span slot="header" :class="$style.header">
<img :src="`${user.avatarUrl}?thumbnail&size=64`" alt=""/>{{ user.name }}のフォロー
</span>
<mk-following :user="user"/>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['user']
});
</script>
<style lang="stylus" module>
.header
> img
display inline-block
vertical-align bottom
height calc(100% - 10px)
margin 5px
border-radius 4px
</style>

View File

@ -0,0 +1,26 @@
<template>
<mk-users-list
:fetch="fetch"
:count="user.followingCount"
:you-know-count="user.followingYouKnowCount"
>
フォロー中のユーザーはいないようです
</mk-users-list>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['user'],
methods: {
fetch(iknow, limit, cursor, cb) {
(this as any).api('users/following', {
userId: this.user.id,
iknow: iknow,
limit: limit,
cursor: cursor ? cursor : undefined
}).then(cb);
}
}
});
</script>

View File

@ -0,0 +1,171 @@
<template>
<div class="mk-friends-maker">
<p class="title">気になるユーザーをフォロー:</p>
<div class="users" v-if="!fetching && users.length > 0">
<div class="user" v-for="user in users" :key="user.id">
<router-link class="avatar-anchor" :to="`/@${getAcct(user)}`">
<img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="user.id"/>
</router-link>
<div class="body">
<router-link class="name" :to="`/@${getAcct(user)}`" v-user-preview="user.id">{{ user.name }}</router-link>
<p class="username">@{{ getAcct(user) }}</p>
</div>
<mk-follow-button :user="user"/>
</div>
</div>
<p class="empty" v-if="!fetching && users.length == 0">おすすめのユーザーは見つかりませんでした</p>
<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p>
<a class="refresh" @click="refresh">もっと見る</a>
<button class="close" @click="$destroy()" title="閉じる">%fa:times%</button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import getAcct from '../../../../../common/user/get-acct';
export default Vue.extend({
data() {
return {
users: [],
fetching: true,
limit: 6,
page: 0
};
},
mounted() {
this.fetch();
},
methods: {
getAcct,
fetch() {
this.fetching = true;
this.users = [];
(this as any).api('users/recommendation', {
limit: this.limit,
offset: this.limit * this.page
}).then(users => {
this.users = users;
this.fetching = false;
});
},
refresh() {
if (this.users.length < this.limit) {
this.page = 0;
} else {
this.page++;
}
this.fetch();
}
}
});
</script>
<style lang="stylus" scoped>
.mk-friends-maker
padding 24px
> .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
> .fetching
margin 0
padding 16px
text-align center
color #aaa
> [data-fa]
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
> [data-fa]
padding 14px
</style>

View File

@ -0,0 +1,37 @@
<template>
<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy">
<span slot="header" :class="$style.header">%fa:gamepad%オセロ</span>
<mk-othello :class="$style.content" @gamed="g => game = g"/>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
import { url } from '../../../config';
export default Vue.extend({
data() {
return {
game: null
};
},
computed: {
popout(): string {
return this.game
? `${url}/othello/${this.game.id}`
: `${url}/othello`;
}
}
});
</script>
<style lang="stylus" module>
.header
> [data-fa]
margin-right 4px
.content
height 100%
overflow auto
</style>

View File

@ -0,0 +1,357 @@
<template>
<div class="mk-home" :data-customize="customize">
<div class="customize" v-if="customize">
<router-link to="/">%fa:check%完了</router-link>
<div>
<div class="adder">
<p>ウィジェットを追加:</p>
<select v-model="widgetAdderSelected">
<option value="profile">プロフィール</option>
<option value="calendar">カレンダー</option>
<option value="timemachine">カレンダー(タイムマシン)</option>
<option value="activity">アクティビティ</option>
<option value="rss">RSSリーダー</option>
<option value="trends">トレンド</option>
<option value="photo-stream">フォトストリーム</option>
<option value="slideshow">スライドショー</option>
<option value="version">バージョン</option>
<option value="broadcast">ブロードキャスト</option>
<option value="notifications">通知</option>
<option value="users">おすすめユーザー</option>
<option value="polls">投票</option>
<option value="post-form">投稿フォーム</option>
<option value="messaging">メッセージ</option>
<option value="channel">チャンネル</option>
<option value="access-log">アクセスログ</option>
<option value="server">サーバー情報</option>
<option value="donation">寄付のお願い</option>
<option value="nav">ナビゲーション</option>
<option value="tips">ヒント</option>
</select>
<button @click="addWidget">追加</button>
</div>
<div class="trash">
<x-draggable v-model="trash" :options="{ group: 'x' }" @add="onTrash"></x-draggable>
<p>ゴミ箱</p>
</div>
</div>
</div>
<div class="main">
<template v-if="customize">
<x-draggable v-for="place in ['left', 'right']"
:list="widgets[place]"
:class="place"
:data-place="place"
:options="{ group: 'x', animation: 150 }"
@sort="onWidgetSort"
:key="place"
>
<div v-for="widget in widgets[place]" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="onWidgetContextmenu(widget.id)">
<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/>
</div>
</x-draggable>
<div class="main">
<a @click="hint">カスタマイズのヒント</a>
<div>
<mk-post-form v-if="os.i.account.clientSettings.showPostFormOnTopOfTl"/>
<mk-timeline ref="tl" @loaded="onTlLoaded"/>
</div>
</div>
</template>
<template v-else>
<div v-for="place in ['left', 'right']" :class="place">
<component v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" @chosen="warp"/>
</div>
<div class="main">
<mk-post-form v-if="os.i.account.clientSettings.showPostFormOnTopOfTl"/>
<mk-timeline ref="tl" @loaded="onTlLoaded" v-if="mode == 'timeline'"/>
<mk-mentions @loaded="onTlLoaded" v-if="mode == 'mentions'"/>
</div>
</template>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as XDraggable from 'vuedraggable';
import * as uuid from 'uuid';
export default Vue.extend({
components: {
XDraggable
},
props: {
customize: Boolean,
mode: {
type: String,
default: 'timeline'
}
},
data() {
return {
connection: null,
connectionId: null,
widgetAdderSelected: null,
trash: [],
widgets: {
left: [],
right: []
}
};
},
computed: {
home: {
get(): any[] {
//#region 互換性のため
(this as any).os.i.account.clientSettings.home.forEach(w => {
if (w.name == 'rss-reader') w.name = 'rss';
if (w.name == 'user-recommendation') w.name = 'users';
if (w.name == 'recommended-polls') w.name = 'polls';
});
//#endregion
return (this as any).os.i.account.clientSettings.home;
},
set(value) {
(this as any).os.i.account.clientSettings.home = value;
}
},
left(): any[] {
return this.home.filter(w => w.place == 'left');
},
right(): any[] {
return this.home.filter(w => w.place == 'right');
}
},
created() {
this.widgets.left = this.left;
this.widgets.right = this.right;
this.$watch('os.i.account.clientSettings', i => {
this.widgets.left = this.left;
this.widgets.right = this.right;
}, {
deep: true
});
},
mounted() {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
this.connection.on('home_updated', this.onHomeUpdated);
},
beforeDestroy() {
this.connection.off('home_updated', this.onHomeUpdated);
(this as any).os.stream.dispose(this.connectionId);
},
methods: {
hint() {
(this as any).apis.dialog({
title: '%fa:info-circle%カスタマイズのヒント',
text: '<p>ホームのカスタマイズでは、ウィジェットを追加/削除したり、ドラッグ&ドロップして並べ替えたりすることができます。</p>' +
'<p>一部のウィジェットは、<strong><strong>右</strong>クリック</strong>することで表示を変更することができます。</p>' +
'<p>ウィジェットを削除するには、ヘッダーの<strong>「ゴミ箱」</strong>と書かれたエリアにウィジェットをドラッグ&ドロップします。</p>' +
'<p>カスタマイズを終了するには、右上の「完了」をクリックします。</p>',
actions: [{
text: 'Got it!'
}]
});
},
onTlLoaded() {
this.$emit('loaded');
},
onHomeUpdated(data) {
if (data.home) {
(this as any).os.i.account.clientSettings.home = data.home;
this.widgets.left = data.home.filter(w => w.place == 'left');
this.widgets.right = data.home.filter(w => w.place == 'right');
} else {
const w = (this as any).os.i.account.clientSettings.home.find(w => w.id == data.id);
if (w != null) {
w.data = data.data;
this.$refs[w.id][0].preventSave = true;
this.$refs[w.id][0].props = w.data;
this.widgets.left = (this as any).os.i.account.clientSettings.home.filter(w => w.place == 'left');
this.widgets.right = (this as any).os.i.account.clientSettings.home.filter(w => w.place == 'right');
}
}
},
onWidgetContextmenu(widgetId) {
const w = (this.$refs[widgetId] as any)[0];
if (w.func) w.func();
},
onWidgetSort() {
this.saveHome();
},
onTrash(evt) {
this.saveHome();
},
addWidget() {
const widget = {
name: this.widgetAdderSelected,
id: uuid(),
place: 'left',
data: {}
};
this.widgets.left.unshift(widget);
this.saveHome();
},
saveHome() {
const left = this.widgets.left;
const right = this.widgets.right;
this.home = left.concat(right);
left.forEach(w => w.place = 'left');
right.forEach(w => w.place = 'right');
(this as any).api('i/update_home', {
home: this.home
});
},
warp(date) {
(this.$refs.tl as any).warp(date);
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-home
display block
&[data-customize]
padding-top 48px
background-image url('/assets/desktop/grid.svg')
> .main > .main
> a
display block
margin-bottom 8px
text-align center
> div
cursor not-allowed !important
> *
pointer-events none
&:not([data-customize])
> .main > *:empty
display none
> .customize
position fixed
z-index 1000
top 0
left 0
width 100%
height 48px
background #f7f7f7
box-shadow 0 1px 1px rgba(0, 0, 0, 0.075)
> a
display block
position absolute
z-index 1001
top 0
right 0
padding 0 16px
line-height 48px
text-decoration none
color $theme-color-foreground
background $theme-color
transition background 0.1s ease
&:hover
background lighten($theme-color, 10%)
&:active
background darken($theme-color, 10%)
transition background 0s ease
> [data-fa]
margin-right 8px
> div
display flex
margin 0 auto
max-width 1200px - 32px
> div
width 50%
&.adder
> p
display inline
line-height 48px
&.trash
border-left solid 1px #ddd
> div
width 100%
height 100%
> p
position absolute
top 0
left 0
width 100%
line-height 48px
margin 0
text-align center
pointer-events none
> .main
display flex
justify-content center
margin 0 auto
max-width 1200px
> *
.customize-container
cursor move
border-radius 6px
&:hover
box-shadow 0 0 8px rgba(64, 120, 200, 0.3)
> *
pointer-events none
> .main
padding 16px
width calc(100% - 275px * 2)
order 2
.mk-post-form
margin-bottom 16px
border solid 1px #e5e5e5
border-radius 4px
> *:not(.main)
width 275px
padding 16px 0 16px 0
> *:not(:last-child)
margin-bottom 16px
> .left
padding-left 16px
order 1
> .right
padding-right 16px
order 3
@media (max-width 1100px)
> *:not(.main)
display none
> .main
float none
width 100%
max-width 700px
margin 0 auto
</style>

View File

@ -0,0 +1,61 @@
import Vue from 'vue';
import ui from './ui.vue';
import uiNotification from './ui-notification.vue';
import home from './home.vue';
import timeline from './timeline.vue';
import posts from './posts.vue';
import subPostContent from './sub-post-content.vue';
import window from './window.vue';
import postFormWindow from './post-form-window.vue';
import repostFormWindow from './repost-form-window.vue';
import analogClock from './analog-clock.vue';
import ellipsisIcon from './ellipsis-icon.vue';
import mediaImage from './media-image.vue';
import mediaImageDialog from './media-image-dialog.vue';
import mediaVideo from './media-video.vue';
import notifications from './notifications.vue';
import postForm from './post-form.vue';
import repostForm from './repost-form.vue';
import followButton from './follow-button.vue';
import postPreview from './post-preview.vue';
import drive from './drive.vue';
import postDetail from './post-detail.vue';
import settings from './settings.vue';
import calendar from './calendar.vue';
import activity from './activity.vue';
import friendsMaker from './friends-maker.vue';
import followers from './followers.vue';
import following from './following.vue';
import usersList from './users-list.vue';
import widgetContainer from './widget-container.vue';
Vue.component('mk-ui', ui);
Vue.component('mk-ui-notification', uiNotification);
Vue.component('mk-home', home);
Vue.component('mk-timeline', timeline);
Vue.component('mk-posts', posts);
Vue.component('mk-sub-post-content', subPostContent);
Vue.component('mk-window', window);
Vue.component('mk-post-form-window', postFormWindow);
Vue.component('mk-repost-form-window', repostFormWindow);
Vue.component('mk-analog-clock', analogClock);
Vue.component('mk-ellipsis-icon', ellipsisIcon);
Vue.component('mk-media-image', mediaImage);
Vue.component('mk-media-image-dialog', mediaImageDialog);
Vue.component('mk-media-video', mediaVideo);
Vue.component('mk-notifications', notifications);
Vue.component('mk-post-form', postForm);
Vue.component('mk-repost-form', repostForm);
Vue.component('mk-follow-button', followButton);
Vue.component('mk-post-preview', postPreview);
Vue.component('mk-drive', drive);
Vue.component('mk-post-detail', postDetail);
Vue.component('mk-settings', settings);
Vue.component('mk-calendar', calendar);
Vue.component('mk-activity', activity);
Vue.component('mk-friends-maker', friendsMaker);
Vue.component('mk-followers', followers);
Vue.component('mk-following', following);
Vue.component('mk-users-list', usersList);
Vue.component('mk-widget-container', widgetContainer);

View File

@ -0,0 +1,180 @@
<template>
<mk-window ref="window" is-modal width="500px" @before-close="beforeClose" @closed="$destroy">
<span slot="header" :class="$style.header">
%fa:i-cursor%{{ title }}
</span>
<div :class="$style.body">
<input ref="text" v-model="text" :type="type" @keydown="onKeydown" :placeholder="placeholder"/>
</div>
<div :class="$style.actions">
<button :class="$style.cancel" @click="cancel">キャンセル</button>
<button :class="$style.ok" :disabled="!allowEmpty && text.length == 0" @click="ok">決定</button>
</div>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
title: {
type: String
},
placeholder: {
type: String
},
default: {
type: String
},
allowEmpty: {
default: true
},
type: {
default: 'text'
}
},
data() {
return {
done: false,
text: ''
};
},
mounted() {
if (this.default) this.text = this.default;
this.$nextTick(() => {
(this.$refs.text as any).focus();
});
},
methods: {
ok() {
if (!this.allowEmpty && this.text == '') return;
this.done = true;
(this.$refs.window as any).close();
},
cancel() {
this.done = false;
(this.$refs.window as any).close();
},
beforeClose() {
if (this.done) {
this.$emit('done', this.text);
} else {
this.$emit('canceled');
}
},
onKeydown(e) {
if (e.which == 13) { // Enter
e.preventDefault();
e.stopPropagation();
this.ok();
}
}
}
});
</script>
<style lang="stylus" module>
@import '~const.styl'
.header
> [data-fa]
margin-right 4px
.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)
.actions
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
</style>

View File

@ -0,0 +1,69 @@
<template>
<div class="mk-media-image-dialog">
<div class="bg" @click="close"></div>
<img :src="image.url" :alt="image.name" :title="image.name" @click="close"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as anime from 'animejs';
export default Vue.extend({
props: ['image'],
mounted() {
anime({
targets: this.$el,
opacity: 1,
duration: 100,
easing: 'linear'
});
},
methods: {
close() {
anime({
targets: this.$el,
opacity: 0,
duration: 100,
easing: 'linear',
complete: () => this.$destroy()
});
}
}
});
</script>
<style lang="stylus" scoped>
.mk-media-image-dialog
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
</style>

View File

@ -0,0 +1,63 @@
<template>
<a class="mk-media-image"
:href="image.url"
@mousemove="onMousemove"
@mouseleave="onMouseleave"
@click.prevent="onClick"
:style="style"
:title="image.name"
></a>
</template>
<script lang="ts">
import Vue from 'vue';
import MkMediaImageDialog from './media-image-dialog.vue';
export default Vue.extend({
props: ['image'],
computed: {
style(): any {
return {
'background-color': this.image.properties.avgColor ? `rgb(${this.image.properties.avgColor.join(',')})` : 'transparent',
'background-image': `url(${this.image.url}?thumbnail&size=512)`
};
}
},
methods: {
onMousemove(e) {
const rect = this.$el.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const xp = mouseX / this.$el.offsetWidth * 100;
const yp = mouseY / this.$el.offsetHeight * 100;
this.$el.style.backgroundPosition = xp + '% ' + yp + '%';
this.$el.style.backgroundImage = 'url("' + this.image.url + '?thumbnail")';
},
onMouseleave() {
this.$el.style.backgroundPosition = '';
},
onClick() {
(this as any).os.new(MkMediaImageDialog, {
image: this.image
});
}
}
});
</script>
<style lang="stylus" scoped>
.mk-media-image
display block
cursor zoom-in
overflow hidden
width 100%
height 100%
background-position center
border-radius 4px
&:not(:hover)
background-size cover
</style>

View File

@ -0,0 +1,70 @@
<template>
<div class="mk-media-video-dialog">
<div class="bg" @click="close"></div>
<video :src="video.url" :title="video.name" controls autoplay ref="video"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as anime from 'animejs';
export default Vue.extend({
props: ['video', 'start'],
mounted() {
anime({
targets: this.$el,
opacity: 1,
duration: 100,
easing: 'linear'
});
const videoTag = this.$refs.video as HTMLVideoElement
if (this.start) videoTag.currentTime = this.start
},
methods: {
close() {
anime({
targets: this.$el,
opacity: 0,
duration: 100,
easing: 'linear',
complete: () => this.$destroy()
});
}
}
});
</script>
<style lang="stylus" scoped>
.mk-media-video-dialog
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)
> video
position fixed
z-index 2
top 0
right 0
bottom 0
left 0
max-width 80vw
max-height 80vh
margin auto
</style>

View File

@ -0,0 +1,67 @@
<template>
<video class="mk-media-video"
:src="video.url"
:title="video.name"
controls
@dblclick.prevent="onClick"
ref="video"
v-if="inlinePlayable" />
<a class="mk-media-video-thumbnail"
:href="video.url"
:style="imageStyle"
@click.prevent="onClick"
:title="video.name"
v-else>
%fa:R play-circle%
</a>
</template>
<script lang="ts">
import Vue from 'vue';
import MkMediaVideoDialog from './media-video-dialog.vue';
export default Vue.extend({
props: ['video', 'inlinePlayable'],
computed: {
imageStyle(): any {
return {
'background-image': `url(${this.video.url}?thumbnail&size=512)`
};
}
},
methods: {
onClick() {
const videoTag = this.$refs.video as (HTMLVideoElement | null)
var start = 0
if (videoTag) {
start = videoTag.currentTime
videoTag.pause()
}
(this as any).os.new(MkMediaVideoDialog, {
video: this.video,
start,
})
}
}
})
</script>
<style lang="stylus" scoped>
.mk-media-video
display block
width 100%
height 100%
border-radius 4px
.mk-media-video-thumbnail
display flex
justify-content center
align-items center
font-size 3.5em
cursor zoom-in
overflow hidden
background-position center
background-size cover
width 100%
height 100%
</style>

View File

@ -0,0 +1,125 @@
<template>
<div class="mk-mentions">
<header>
<span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて</span>
<span :data-is-active="mode == 'following'" @click="mode = 'following'">フォロー中</span>
</header>
<div class="fetching" v-if="fetching">
<mk-ellipsis-icon/>
</div>
<p class="empty" v-if="posts.length == 0 && !fetching">
%fa:R comments%
<span v-if="mode == 'all'">あなた宛ての投稿はありません</span>
<span v-if="mode == 'following'">あなたがフォローしているユーザーからの言及はありません</span>
</p>
<mk-posts :posts="posts" ref="timeline"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data() {
return {
fetching: true,
moreFetching: false,
mode: 'all',
posts: []
};
},
watch: {
mode() {
this.fetch();
}
},
mounted() {
document.addEventListener('keydown', this.onDocumentKeydown);
window.addEventListener('scroll', this.onScroll);
this.fetch(() => this.$emit('loaded'));
},
beforeDestroy() {
document.removeEventListener('keydown', this.onDocumentKeydown);
window.removeEventListener('scroll', this.onScroll);
},
methods: {
onDocumentKeydown(e) {
if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
if (e.which == 84) { // t
(this.$refs.timeline as any).focus();
}
}
},
onScroll() {
const current = window.scrollY + window.innerHeight;
if (current > document.body.offsetHeight - 8) this.more();
},
fetch(cb?) {
this.fetching = true;
this.posts = [];
(this as any).api('posts/mentions', {
following: this.mode == 'following'
}).then(posts => {
this.posts = posts;
this.fetching = false;
if (cb) cb();
});
},
more() {
if (this.moreFetching || this.fetching || this.posts.length == 0) return;
this.moreFetching = true;
(this as any).api('posts/mentions', {
following: this.mode == 'following',
untilId: this.posts[this.posts.length - 1].id
}).then(posts => {
this.posts = this.posts.concat(posts);
this.moreFetching = false;
});
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-mentions
background #fff
border solid 1px rgba(0, 0, 0, 0.075)
border-radius 6px
> 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
> .fetching
padding 64px 0
> .empty
display block
margin 0 auto
padding 32px
max-width 400px
text-align center
color #999
> [data-fa]
display block
margin-bottom 16px
font-size 3em
color #ccc
</style>

View File

@ -0,0 +1,32 @@
<template>
<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy">
<span slot="header" :class="$style.header">%fa:comments%メッセージ: {{ user.name }}</span>
<mk-messaging-room :user="user" :class="$style.content"/>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
import { url } from '../../../config';
import getAcct from '../../../../../common/user/get-acct';
export default Vue.extend({
props: ['user'],
computed: {
popout(): string {
return `${url}/i/messaging/${getAcct(this.user)}`;
}
}
});
</script>
<style lang="stylus" module>
.header
> [data-fa]
margin-right 4px
.content
height 100%
overflow auto
</style>

View File

@ -0,0 +1,32 @@
<template>
<mk-window ref="window" width="500px" height="560px" @closed="$destroy">
<span slot="header" :class="$style.header">%fa:comments%メッセージ</span>
<mk-messaging :class="$style.content" @navigate="navigate"/>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
import MkMessagingRoomWindow from './messaging-room-window.vue';
export default Vue.extend({
methods: {
navigate(user) {
(this as any).os.new(MkMessagingRoomWindow, {
user: user
});
}
}
});
</script>
<style lang="stylus" module>
.header
> [data-fa]
margin-right 4px
.content
height 100%
overflow auto
</style>

View File

@ -0,0 +1,317 @@
<template>
<div class="mk-notifications">
<div class="notifications" v-if="notifications.length != 0">
<template v-for="(notification, i) in _notifications">
<div class="notification" :class="notification.type" :key="notification.id">
<mk-time :time="notification.createdAt"/>
<template v-if="notification.type == 'reaction'">
<router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">
<img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
</router-link>
<div class="text">
<p>
<mk-reaction-icon :reaction="notification.reaction"/>
<router-link :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</router-link>
</p>
<router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">
%fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right%
</router-link>
</div>
</template>
<template v-if="notification.type == 'repost'">
<router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">
<img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
</router-link>
<div class="text">
<p>%fa:retweet%
<router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link>
</p>
<router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">
%fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right%
</router-link>
</div>
</template>
<template v-if="notification.type == 'quote'">
<router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">
<img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
</router-link>
<div class="text">
<p>%fa:quote-left%
<router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link>
</p>
<router-link class="post-preview" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</router-link>
</div>
</template>
<template v-if="notification.type == 'follow'">
<router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">
<img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
</router-link>
<div class="text">
<p>%fa:user-plus%
<router-link :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</router-link>
</p>
</div>
</template>
<template v-if="notification.type == 'reply'">
<router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">
<img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
</router-link>
<div class="text">
<p>%fa:reply%
<router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link>
</p>
<router-link class="post-preview" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</router-link>
</div>
</template>
<template v-if="notification.type == 'mention'">
<router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">
<img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
</router-link>
<div class="text">
<p>%fa:at%
<router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link>
</p>
<a class="post-preview" :href="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a>
</div>
</template>
<template v-if="notification.type == 'poll_vote'">
<router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">
<img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/>
</router-link>
<div class="text">
<p>%fa:chart-pie%<a :href="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</a></p>
<router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">
%fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right%
</router-link>
</div>
</template>
</div>
<p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'">
<span>%fa:angle-up%{{ notification._datetext }}</span>
<span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span>
</p>
</template>
</div>
<button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
<template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:desktop.tags.mk-notifications.more%' }}
</button>
<p class="empty" v-if="notifications.length == 0 && !fetching">ありません</p>
<p class="loading" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import getAcct from '../../../../../common/user/get-acct';
import getPostSummary from '../../../../../common/get-post-summary';
export default Vue.extend({
data() {
return {
fetching: true,
fetchingMoreNotifications: false,
notifications: [],
moreNotifications: false,
connection: null,
connectionId: null,
getPostSummary
};
},
computed: {
_notifications(): any[] {
return (this.notifications as any).map(notification => {
const date = new Date(notification.createdAt).getDate();
const month = new Date(notification.createdAt).getMonth() + 1;
notification._date = date;
notification._datetext = `${month}${date}`;
return notification;
});
}
},
mounted() {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
this.connection.on('notification', this.onNotification);
const max = 10;
(this as any).api('i/notifications', {
limit: max + 1
}).then(notifications => {
if (notifications.length == max + 1) {
this.moreNotifications = true;
notifications.pop();
}
this.notifications = notifications;
this.fetching = false;
});
},
beforeDestroy() {
this.connection.off('notification', this.onNotification);
(this as any).os.stream.dispose(this.connectionId);
},
methods: {
getAcct,
fetchMoreNotifications() {
this.fetchingMoreNotifications = true;
const max = 30;
(this as any).api('i/notifications', {
limit: max + 1,
untilId: this.notifications[this.notifications.length - 1].id
}).then(notifications => {
if (notifications.length == max + 1) {
this.moreNotifications = true;
notifications.pop();
} else {
this.moreNotifications = false;
}
this.notifications = this.notifications.concat(notifications);
this.fetchingMoreNotifications = false;
});
},
onNotification(notification) {
// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
this.connection.send({
type: 'read_notification',
id: notification.id
});
this.notifications.unshift(notification);
}
}
});
</script>
<style lang="stylus" scoped>
.mk-notifications
> .notifications
> .notification
margin 0
padding 16px
overflow-wrap break-word
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
&:after
content ""
display block
clear both
> .avatar-anchor
display block
float left
position -webkit-sticky
position sticky
top 16px
> img
display block
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, .mk-reaction-icon
margin-right 4px
.post-preview
color rgba(0, 0, 0, 0.7)
.post-ref
color rgba(0, 0, 0, 0.7)
[data-fa]
font-size 1em
font-weight normal
font-style normal
display inline-block
margin-right 3px
&.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
[data-fa]
margin-right 8px
> .more
display block
width 100%
padding 16px
color #555
border-top solid 1px rgba(0, 0, 0, 0.05)
&:hover
background rgba(0, 0, 0, 0.025)
&:active
background rgba(0, 0, 0, 0.05)
&.fetching
cursor wait
> [data-fa]
margin-right 4px
> .empty
margin 0
padding 16px
text-align center
color #aaa
> .loading
margin 0
padding 16px
text-align center
color #aaa
> [data-fa]
margin-right 4px
</style>

View File

@ -0,0 +1,126 @@
<template>
<div class="sub" :title="title">
<router-link class="avatar-anchor" :to="`/@${acct}`">
<img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="post.userId"/>
</router-link>
<div class="main">
<header>
<div class="left">
<router-link class="name" :to="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</router-link>
<span class="username">@{{ acct }}</span>
</div>
<div class="right">
<router-link class="time" :to="`/@${acct}/${post.id}`">
<mk-time :time="post.createdAt"/>
</router-link>
</div>
</header>
<div class="body">
<mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i" :class="$style.text"/>
<div class="media" v-if="post.media">
<mk-media-list :media-list="post.media"/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import dateStringify from '../../../common/scripts/date-stringify';
import getAcct from '../../../../../common/user/get-acct';
export default Vue.extend({
props: ['post'],
computed: {
acct() {
return getAcct(this.post.user);
},
title(): string {
return dateStringify(this.post.createdAt);
}
}
});
</script>
<style lang="stylus" scoped>
.sub
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
</style>
<style lang="stylus" module>
.text
cursor default
display block
margin 0
padding 0
overflow-wrap break-word
font-size 1em
color #717171
</style>

View File

@ -0,0 +1,433 @@
<template>
<div class="mk-post-detail" :title="title">
<button
class="read-more"
v-if="p.reply && p.reply.replyId && context == null"
title="会話をもっと読み込む"
@click="fetchContext"
:disabled="contextFetching"
>
<template v-if="!contextFetching">%fa:ellipsis-v%</template>
<template v-if="contextFetching">%fa:spinner .pulse%</template>
</button>
<div class="context">
<x-sub v-for="post in context" :key="post.id" :post="post"/>
</div>
<div class="reply-to" v-if="p.reply">
<x-sub :post="p.reply"/>
</div>
<div class="repost" v-if="isRepost">
<p>
<router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="post.userId">
<img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/>
</router-link>
%fa:retweet%
<router-link class="name" :href="`/@${acct}`">{{ post.user.name }}</router-link>
がRepost
</p>
</div>
<article>
<router-link class="avatar-anchor" :to="`/@${acct}`">
<img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/>
</router-link>
<header>
<router-link class="name" :to="`/@${acct}`" v-user-preview="p.user.id">{{ p.user.name }}</router-link>
<span class="username">@{{ acct }}</span>
<router-link class="time" :to="`/@${acct}/${p.id}`">
<mk-time :time="p.createdAt"/>
</router-link>
</header>
<div class="body">
<mk-post-html :class="$style.text" v-if="p.ast" :ast="p.ast" :i="os.i"/>
<div class="media" v-if="p.media">
<mk-media-list :media-list="p.media"/>
</div>
<mk-poll v-if="p.poll" :post="p"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
<div class="tags" v-if="p.tags && p.tags.length > 0">
<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
</div>
<a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
<div class="map" v-if="p.geo" ref="map"></div>
<div class="repost" v-if="p.repost">
<mk-post-preview :post="p.repost"/>
</div>
</div>
<footer>
<mk-reactions-viewer :post="p"/>
<button @click="reply" title="返信">
%fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
</button>
<button @click="repost" title="Repost">
%fa:retweet%<p class="count" v-if="p.repostCount > 0">{{ p.repostCount }}</p>
</button>
<button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="リアクション">
%fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p>
</button>
<button @click="menu" ref="menuButton">
%fa:ellipsis-h%
</button>
</footer>
</article>
<div class="replies" v-if="!compact">
<x-sub v-for="post in replies" :key="post.id" :post="post"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import dateStringify from '../../../common/scripts/date-stringify';
import getAcct from '../../../../../common/user/get-acct';
import MkPostFormWindow from './post-form-window.vue';
import MkRepostFormWindow from './repost-form-window.vue';
import MkPostMenu from '../../../common/views/components/post-menu.vue';
import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
import XSub from './post-detail.sub.vue';
export default Vue.extend({
components: {
XSub
},
props: {
post: {
type: Object,
required: true
},
compact: {
default: false
}
},
computed: {
acct() {
return getAcct(this.post.user);
}
},
data() {
return {
context: [],
contextFetching: false,
replies: [],
};
},
computed: {
isRepost(): boolean {
return (this.post.repost &&
this.post.text == null &&
this.post.mediaIds == null &&
this.post.poll == null);
},
p(): any {
return this.isRepost ? this.post.repost : this.post;
},
reactionsCount(): number {
return this.p.reactionCounts
? Object.keys(this.p.reactionCounts)
.map(key => this.p.reactionCounts[key])
.reduce((a, b) => a + b)
: 0;
},
title(): string {
return dateStringify(this.p.createdAt);
},
urls(): string[] {
if (this.p.ast) {
return this.p.ast
.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
.map(t => t.url);
} else {
return null;
}
}
},
mounted() {
// Get replies
if (!this.compact) {
(this as any).api('posts/replies', {
postId: this.p.id,
limit: 8
}).then(replies => {
this.replies = replies;
});
}
// Draw map
if (this.p.geo) {
const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.clientSettings.showMaps : true;
if (shouldShowMap) {
(this as any).os.getGoogleMaps().then(maps => {
const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
const map = new maps.Map(this.$refs.map, {
center: uluru,
zoom: 15
});
new maps.Marker({
position: uluru,
map: map
});
});
}
}
},
methods: {
fetchContext() {
this.contextFetching = true;
// Fetch context
(this as any).api('posts/context', {
postId: this.p.replyId
}).then(context => {
this.contextFetching = false;
this.context = context.reverse();
});
},
reply() {
(this as any).os.new(MkPostFormWindow, {
reply: this.p
});
},
repost() {
(this as any).os.new(MkRepostFormWindow, {
post: this.p
});
},
react() {
(this as any).os.new(MkReactionPicker, {
source: this.$refs.reactButton,
post: this.p
});
},
menu() {
(this as any).os.new(MkPostMenu, {
source: this.$refs.menuButton,
post: this.p
});
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-post-detail
margin 0
padding 0
overflow hidden
text-align left
background #fff
border solid 1px rgba(0, 0, 0, 0.1)
border-radius 8px
> .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
[data-fa]
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
> .repost
margin 8px 0
> .mk-post-preview
padding 16px
border dashed 1px #c0dac6
border-radius 8px
> .location
margin 4px 0
font-size 12px
color #ccc
> .map
width 100%
height 300px
&:empty
display none
> .mk-url-preview
margin-top 8px
> .tags
margin 4px 0 0 0
> *
display inline-block
margin 0 8px 0 0
padding 2px 8px 2px 16px
font-size 90%
color #8d969e
background #edf0f3
border-radius 4px
&:before
content ""
display block
position absolute
top 0
bottom 0
left 4px
width 8px
height 8px
margin auto 0
background #fff
border-radius 100%
&:hover
text-decoration none
background #e2e7ec
> 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
&.reacted
color $theme-color
> .replies
> *
border-top 1px solid #eef0f2
</style>
<style lang="stylus" module>
.text
cursor default
display block
margin 0
padding 0
overflow-wrap break-word
font-size 1.5em
color #717171
</style>

View File

@ -0,0 +1,76 @@
<template>
<mk-window ref="window" is-modal @closed="$destroy">
<span slot="header">
<span :class="$style.icon" v-if="geo">%fa:map-marker-alt%</span>
<span v-if="!reply">%i18n:desktop.tags.mk-post-form-window.post%</span>
<span v-if="reply">%i18n:desktop.tags.mk-post-form-window.reply%</span>
<span :class="$style.count" v-if="media.length != 0">{{ '%i18n:desktop.tags.mk-post-form-window.attaches%'.replace('{}', media.length) }}</span>
<span :class="$style.count" v-if="uploadings.length != 0">{{ '%i18n:desktop.tags.mk-post-form-window.uploading-media%'.replace('{}', uploadings.length) }}<mk-ellipsis/></span>
</span>
<mk-post-preview v-if="reply" :class="$style.postPreview" :post="reply"/>
<mk-post-form ref="form"
:reply="reply"
@posted="onPosted"
@change-uploadings="onChangeUploadings"
@change-attached-media="onChangeMedia"
@geo-attached="onGeoAttached"
@geo-dettached="onGeoDettached"/>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['reply'],
data() {
return {
uploadings: [],
media: [],
geo: null
};
},
mounted() {
this.$nextTick(() => {
(this.$refs.form as any).focus();
});
},
methods: {
onChangeUploadings(files) {
this.uploadings = files;
},
onChangeMedia(media) {
this.media = media;
},
onGeoAttached(geo) {
this.geo = geo;
},
onGeoDettached() {
this.geo = null;
},
onPosted() {
(this.$refs.window as any).close();
}
}
});
</script>
<style lang="stylus" module>
.icon
margin-right 8px
.count
margin-left 8px
opacity 0.8
&:before
content '('
&:after
content ')'
.postPreview
margin 16px 22px
</style>

View File

@ -0,0 +1,536 @@
<template>
<div class="mk-post-form"
@dragover.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.stop="onDrop"
>
<div class="content">
<textarea :class="{ with: (files.length != 0 || poll) }"
ref="text" v-model="text" :disabled="posting"
@keydown="onKeydown" @paste="onPaste" :placeholder="placeholder"
v-autocomplete="'text'"
></textarea>
<div class="medias" :class="{ with: poll }" v-show="files.length != 0">
<x-draggable :list="files" :options="{ animation: 150 }">
<div v-for="file in files" :key="file.id">
<div class="img" :style="{ backgroundImage: `url(${file.url}?thumbnail&size=64)` }" :title="file.name"></div>
<img class="remove" @click="detachMedia(file.id)" src="/assets/desktop/remove.png" title="%i18n:desktop.tags.mk-post-form.attach-cancel%" alt=""/>
</div>
</x-draggable>
<p class="remain">{{ 4 - files.length }}/4</p>
</div>
<mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="saveDraft()"/>
</div>
<mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/>
<button class="upload" title="%i18n:desktop.tags.mk-post-form.attach-media-from-local%" @click="chooseFile">%fa:upload%</button>
<button class="drive" title="%i18n:desktop.tags.mk-post-form.attach-media-from-drive%" @click="chooseFileFromDrive">%fa:cloud%</button>
<button class="kao" title="%i18n:desktop.tags.mk-post-form.insert-a-kao%" @click="kao">%fa:R smile%</button>
<button class="poll" title="%i18n:desktop.tags.mk-post-form.create-poll%" @click="poll = true">%fa:chart-pie%</button>
<button class="geo" title="位置情報を添付する" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button>
<p class="text-count" :class="{ over: text.length > 1000 }">{{ '%i18n:desktop.tags.mk-post-form.text-remain%'.replace('{}', 1000 - text.length) }}</p>
<button :class="{ posting }" class="submit" :disabled="!canPost" @click="post">
{{ posting ? '%i18n:desktop.tags.mk-post-form.posting%' : submitText }}<mk-ellipsis v-if="posting"/>
</button>
<input ref="file" type="file" accept="image/*" multiple="multiple" tabindex="-1" @change="onChangeFile"/>
<div class="dropzone" v-if="draghover"></div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as XDraggable from 'vuedraggable';
import getKao from '../../../common/scripts/get-kao';
export default Vue.extend({
components: {
XDraggable
},
props: ['reply', 'repost'],
data() {
return {
posting: false,
text: '',
files: [],
uploadings: [],
poll: false,
geo: null,
autocomplete: null,
draghover: false
};
},
computed: {
draftId(): string {
return this.repost
? 'repost:' + this.repost.id
: this.reply
? 'reply:' + this.reply.id
: 'post';
},
placeholder(): string {
return this.repost
? '%i18n:desktop.tags.mk-post-form.quote-placeholder%'
: this.reply
? '%i18n:desktop.tags.mk-post-form.reply-placeholder%'
: '%i18n:desktop.tags.mk-post-form.post-placeholder%';
},
submitText(): string {
return this.repost
? '%i18n:desktop.tags.mk-post-form.repost%'
: this.reply
? '%i18n:desktop.tags.mk-post-form.reply%'
: '%i18n:desktop.tags.mk-post-form.post%';
},
canPost(): boolean {
return !this.posting && (this.text.length != 0 || this.files.length != 0 || this.poll || this.repost);
}
},
watch: {
text() {
this.saveDraft();
},
poll() {
this.saveDraft();
},
files() {
this.saveDraft();
}
},
mounted() {
this.$nextTick(() => {
// 書きかけの投稿を復元
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId];
if (draft) {
this.text = draft.data.text;
this.files = draft.data.files;
if (draft.data.poll) {
this.poll = true;
this.$nextTick(() => {
(this.$refs.poll as any).set(draft.data.poll);
});
}
this.$emit('change-attached-media', this.files);
}
});
},
methods: {
focus() {
(this.$refs.text as any).focus();
},
chooseFile() {
(this.$refs.file as any).click();
},
chooseFileFromDrive() {
(this as any).apis.chooseDriveFile({
multiple: true
}).then(files => {
files.forEach(this.attachMedia);
});
},
attachMedia(driveFile) {
this.files.push(driveFile);
this.$emit('change-attached-media', this.files);
},
detachMedia(id) {
this.files = this.files.filter(x => x.id != id);
this.$emit('change-attached-media', this.files);
},
onChangeFile() {
Array.from((this.$refs.file as any).files).forEach(this.upload);
},
upload(file) {
(this.$refs.uploader as any).upload(file);
},
onChangeUploadings(uploads) {
this.$emit('change-uploadings', uploads);
},
clear() {
this.text = '';
this.files = [];
this.poll = false;
this.$emit('change-attached-media', this.files);
},
onKeydown(e) {
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post();
},
onPaste(e) {
Array.from(e.clipboardData.items).forEach((item: any) => {
if (item.kind == 'file') {
this.upload(item.getAsFile());
}
});
},
onDragover(e) {
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
if (isFile || isDriveFile) {
e.preventDefault();
this.draghover = true;
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
}
},
onDragenter(e) {
this.draghover = true;
},
onDragleave(e) {
this.draghover = false;
},
onDrop(e): void {
this.draghover = false;
// ファイルだったら
if (e.dataTransfer.files.length > 0) {
e.preventDefault();
Array.from(e.dataTransfer.files).forEach(this.upload);
return;
}
//#region ドライブのファイル
const driveFile = e.dataTransfer.getData('mk_drive_file');
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.files.push(file);
this.$emit('change-attached-media', this.files);
e.preventDefault();
}
//#endregion
},
setGeo() {
if (navigator.geolocation == null) {
alert('お使いの端末は位置情報に対応していません');
return;
}
navigator.geolocation.getCurrentPosition(pos => {
this.geo = pos.coords;
this.$emit('geo-attached', this.geo);
}, err => {
alert('エラー: ' + err.message);
}, {
enableHighAccuracy: true
});
},
removeGeo() {
this.geo = null;
this.$emit('geo-dettached');
},
post() {
this.posting = true;
(this as any).api('posts/create', {
text: this.text == '' ? undefined : this.text,
mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
replyId: this.reply ? this.reply.id : undefined,
repostId: this.repost ? this.repost.id : undefined,
poll: this.poll ? (this.$refs.poll as any).get() : undefined,
geo: this.geo ? {
coordinates: [this.geo.longitude, this.geo.latitude],
altitude: this.geo.altitude,
accuracy: this.geo.accuracy,
altitudeAccuracy: this.geo.altitudeAccuracy,
heading: isNaN(this.geo.heading) ? null : this.geo.heading,
speed: this.geo.speed,
} : null
}).then(data => {
this.clear();
this.deleteDraft();
this.$emit('posted');
(this as any).apis.notify(this.repost
? '%i18n:desktop.tags.mk-post-form.reposted%'
: this.reply
? '%i18n:desktop.tags.mk-post-form.replied%'
: '%i18n:desktop.tags.mk-post-form.posted%');
}).catch(err => {
(this as any).apis.notify(this.repost
? '%i18n:desktop.tags.mk-post-form.repost-failed%'
: this.reply
? '%i18n:desktop.tags.mk-post-form.reply-failed%'
: '%i18n:desktop.tags.mk-post-form.post-failed%');
}).then(() => {
this.posting = false;
});
},
saveDraft() {
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
data[this.draftId] = {
updatedAt: new Date(),
data: {
text: this.text,
files: this.files,
poll: this.poll && this.$refs.poll ? (this.$refs.poll as any).get() : undefined
}
}
localStorage.setItem('drafts', JSON.stringify(data));
},
deleteDraft() {
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
delete data[this.draftId];
localStorage.setItem('drafts', JSON.stringify(data));
},
kao() {
this.text += getKao();
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-post-form
display block
padding 16px
background lighten($theme-color, 95%)
&:after
content ""
display block
clear both
> .content
textarea
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
& + *
& + * + *
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
& + *
& + * + *
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)
&.with
border-bottom solid 1px rgba($theme-color, 0.1) !important
border-radius 4px 4px 0 0
> .medias
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
&.with
border-bottom solid 1px rgba($theme-color, 0.1) !important
border-radius 0
> .remain
display block
position absolute
top 8px
right 8px
margin 0
padding 0
color rgba($theme-color, 0.4)
> div
padding 4px
&:after
content ""
display block
clear both
> div
float left
border solid 4px transparent
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
> .mk-poll-editor
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
> .mk-uploader
margin 8px 0 0 0
padding 8px
border solid 1px rgba($theme-color, 0.2)
border-radius 4px
input[type='file']
display none
.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
.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;}
> .upload
> .drive
> .kao
> .poll
> .geo
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
</style>

View File

@ -0,0 +1,103 @@
<template>
<div class="mk-post-preview" :title="title">
<router-link class="avatar-anchor" :to="`/@${acct}`">
<img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="post.userId"/>
</router-link>
<div class="main">
<header>
<router-link class="name" :to="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</router-link>
<span class="username">@{{ acct }}</span>
<router-link class="time" :to="`/@${acct}/${post.id}`">
<mk-time :time="post.createdAt"/>
</router-link>
</header>
<div class="body">
<mk-sub-post-content class="text" :post="post"/>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import dateStringify from '../../../common/scripts/date-stringify';
import getAcct from '../../../../../common/user/get-acct';
export default Vue.extend({
props: ['post'],
computed: {
acct() {
return getAcct(this.post.user);
},
title(): string {
return dateStringify(this.post.createdAt);
}
}
});
</script>
<style lang="stylus" scoped>
.mk-post-preview
font-size 0.9em
background #fff
&: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
display flex
white-space nowrap
> .name
margin 0 .5em 0 0
padding 0
color #607073
font-size 1em
font-weight bold
text-decoration none
white-space normal
&:hover
text-decoration underline
> .username
margin 0 .5em 0 0
color #d1d8da
> .time
margin-left auto
color #b2b8bb
> .body
> .text
cursor default
margin 0
padding 0
font-size 1.1em
color #717171
</style>

View File

@ -0,0 +1,112 @@
<template>
<div class="sub" :title="title">
<router-link class="avatar-anchor" :to="`/@${acct}`">
<img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="post.userId"/>
</router-link>
<div class="main">
<header>
<router-link class="name" :to="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</router-link>
<span class="username">@{{ acct }}</span>
<router-link class="created-at" :to="`/@${acct}/${post.id}`">
<mk-time :time="post.createdAt"/>
</router-link>
</header>
<div class="body">
<mk-sub-post-content class="text" :post="post"/>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import dateStringify from '../../../common/scripts/date-stringify';
import getAcct from '../../../../../common/user/get-acct';
export default Vue.extend({
props: ['post'],
computed: {
acct() {
return getAcct(this.post.user);
},
title(): string {
return dateStringify(this.post.createdAt);
}
}
});
</script>
<style lang="stylus" scoped>
.sub
margin 0
padding 16px
font-size 0.9em
&: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
display flex
margin-bottom 2px
white-space nowrap
line-height 21px
> .name
display block
margin 0 .5em 0 0
padding 0
overflow hidden
color #607073
font-size 1em
font-weight bold
text-decoration none
text-overflow ellipsis
&:hover
text-decoration underline
> .username
margin 0 .5em 0 0
color #d1d8da
> .created-at
margin-left auto
color #b2b8bb
> .body
> .text
cursor default
margin 0
padding 0
font-size 1.1em
color #717171
pre
max-height 120px
font-size 80%
</style>

View File

@ -0,0 +1,582 @@
<template>
<div class="post" tabindex="-1" :title="title" @keydown="onKeydown">
<div class="reply-to" v-if="p.reply">
<x-sub :post="p.reply"/>
</div>
<div class="repost" v-if="isRepost">
<p>
<router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="post.userId">
<img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/>
</router-link>
%fa:retweet%
<span>{{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{')) }}</span>
<a class="name" :href="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</a>
<span>{{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1) }}</span>
</p>
<mk-time :time="post.createdAt"/>
</div>
<article>
<router-link class="avatar-anchor" :to="`/@${acct}`">
<img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/>
</router-link>
<div class="main">
<header>
<router-link class="name" :to="`/@${acct}`" v-user-preview="p.user.id">{{ acct }}</router-link>
<span class="is-bot" v-if="p.user.host === null && p.user.account.isBot">bot</span>
<span class="username">@{{ acct }}</span>
<div class="info">
<span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span>
<span class="mobile" v-if="p.viaMobile">%fa:mobile-alt%</span>
<router-link class="created-at" :to="url">
<mk-time :time="p.createdAt"/>
</router-link>
</div>
</header>
<div class="body">
<p class="channel" v-if="p.channel">
<a :href="`${_CH_URL_}/${p.channel.id}`" target="_blank">{{ p.channel.title }}</a>:
</p>
<div class="text">
<a class="reply" v-if="p.reply">%fa:reply%</a>
<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
<a class="rp" v-if="p.repost">RP:</a>
</div>
<div class="media" v-if="p.media">
<mk-media-list :media-list="p.media"/>
</div>
<mk-poll v-if="p.poll" :post="p" ref="pollViewer"/>
<div class="tags" v-if="p.tags && p.tags.length > 0">
<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
</div>
<a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
<div class="map" v-if="p.geo" ref="map"></div>
<div class="repost" v-if="p.repost">
<mk-post-preview :post="p.repost"/>
</div>
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
</div>
<footer>
<mk-reactions-viewer :post="p" ref="reactionsViewer"/>
<button @click="reply" title="%i18n:desktop.tags.mk-timeline-post.reply%">
%fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
</button>
<button @click="repost" title="%i18n:desktop.tags.mk-timeline-post.repost%">
%fa:retweet%<p class="count" v-if="p.repostCount > 0">{{ p.repostCount }}</p>
</button>
<button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%">
%fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p>
</button>
<button @click="menu" ref="menuButton">
%fa:ellipsis-h%
</button>
<button title="%i18n:desktop.tags.mk-timeline-post.detail">
<template v-if="!isDetailOpened">%fa:caret-down%</template>
<template v-if="isDetailOpened">%fa:caret-up%</template>
</button>
</footer>
</div>
</article>
<div class="detail" v-if="isDetailOpened">
<mk-post-status-graph width="462" height="130" :post="p"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import dateStringify from '../../../common/scripts/date-stringify';
import getAcct from '../../../../../common/user/get-acct';
import MkPostFormWindow from './post-form-window.vue';
import MkRepostFormWindow from './repost-form-window.vue';
import MkPostMenu from '../../../common/views/components/post-menu.vue';
import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
import XSub from './posts.post.sub.vue';
function focus(el, fn) {
const target = fn(el);
if (target) {
if (target.hasAttribute('tabindex')) {
target.focus();
} else {
focus(target, fn);
}
}
}
export default Vue.extend({
components: {
XSub
},
props: ['post'],
data() {
return {
isDetailOpened: false,
connection: null,
connectionId: null
};
},
computed: {
acct() {
return getAcct(this.p.user);
},
isRepost(): boolean {
return (this.post.repost &&
this.post.text == null &&
this.post.mediaIds == null &&
this.post.poll == null);
},
p(): any {
return this.isRepost ? this.post.repost : this.post;
},
reactionsCount(): number {
return this.p.reactionCounts
? Object.keys(this.p.reactionCounts)
.map(key => this.p.reactionCounts[key])
.reduce((a, b) => a + b)
: 0;
},
title(): string {
return dateStringify(this.p.createdAt);
},
url(): string {
return `/@${this.acct}/${this.p.id}`;
},
urls(): string[] {
if (this.p.ast) {
return this.p.ast
.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
.map(t => t.url);
} else {
return null;
}
}
},
created() {
if ((this as any).os.isSignedIn) {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
}
},
mounted() {
this.capture(true);
if ((this as any).os.isSignedIn) {
this.connection.on('_connected_', this.onStreamConnected);
}
// Draw map
if (this.p.geo) {
const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.clientSettings.showMaps : true;
if (shouldShowMap) {
(this as any).os.getGoogleMaps().then(maps => {
const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
const map = new maps.Map(this.$refs.map, {
center: uluru,
zoom: 15
});
new maps.Marker({
position: uluru,
map: map
});
});
}
}
},
beforeDestroy() {
this.decapture(true);
if ((this as any).os.isSignedIn) {
this.connection.off('_connected_', this.onStreamConnected);
(this as any).os.stream.dispose(this.connectionId);
}
},
methods: {
capture(withHandler = false) {
if ((this as any).os.isSignedIn) {
this.connection.send({
type: 'capture',
id: this.p.id
});
if (withHandler) this.connection.on('post-updated', this.onStreamPostUpdated);
}
},
decapture(withHandler = false) {
if ((this as any).os.isSignedIn) {
this.connection.send({
type: 'decapture',
id: this.p.id
});
if (withHandler) this.connection.off('post-updated', this.onStreamPostUpdated);
}
},
onStreamConnected() {
this.capture();
},
onStreamPostUpdated(data) {
const post = data.post;
if (post.id == this.post.id) {
this.$emit('update:post', post);
} else if (post.id == this.post.repostId) {
this.post.repost = post;
}
},
reply() {
(this as any).os.new(MkPostFormWindow, {
reply: this.p
});
},
repost() {
(this as any).os.new(MkRepostFormWindow, {
post: this.p
});
},
react() {
(this as any).os.new(MkReactionPicker, {
source: this.$refs.reactButton,
post: this.p
});
},
menu() {
(this as any).os.new(MkPostMenu, {
source: this.$refs.menuButton,
post: this.p
});
},
onKeydown(e) {
let shouldBeCancel = true;
switch (true) {
case e.which == 38: // [↑]
case e.which == 74: // [j]
case e.which == 9 && e.shiftKey: // [Shift] + [Tab]
focus(this.$el, e => e.previousElementSibling);
break;
case e.which == 40: // [↓]
case e.which == 75: // [k]
case e.which == 9: // [Tab]
focus(this.$el, e => e.nextElementSibling);
break;
case e.which == 81: // [q]
case e.which == 69: // [e]
this.repost();
break;
case e.which == 70: // [f]
case e.which == 76: // [l]
//this.like();
break;
case e.which == 82: // [r]
this.reply();
break;
default:
shouldBeCancel = false;
}
if (shouldBeCancel) e.preventDefault();
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.post
margin 0
padding 0
background #fff
border-bottom solid 1px #eaeaea
&:first-child
border-top-left-radius 6px
border-top-right-radius 6px
> .repost
border-top-left-radius 6px
border-top-right-radius 6px
&:last-of-type
border-bottom none
&: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
[data-fa]
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 10px 0
//position -webkit-sticky
//position sticky
//top 74px
> .avatar
display block
width 58px
height 58px
margin 0
border-radius 8px
vertical-align bottom
> .main
float left
width calc(100% - 74px)
> header
display flex
align-items center
margin-bottom 4px
white-space nowrap
> .name
display block
margin 0 .5em 0 0
padding 0
overflow hidden
color #627079
font-size 1em
font-weight bold
text-decoration none
text-overflow ellipsis
&:hover
text-decoration underline
> .is-bot
margin 0 .5em 0 0
padding 1px 6px
font-size 12px
color #aaa
border solid 1px #ddd
border-radius 3px
> .username
margin 0 .5em 0 0
color #ccc
> .info
margin-left auto
font-size 0.9em
> .mobile
margin-right 8px
color #ccc
> .app
margin-right 8px
padding-right 8px
color #ccc
border-right solid 1px #eaeaea
> .created-at
color #c0c0c0
> .body
> .text
cursor default
display block
margin 0
padding 0
overflow-wrap break-word
font-size 1.1em
color #717171
>>> .quote
margin 8px
padding 6px 12px
color #aaa
border-left solid 3px #eee
> .reply
margin-right 8px
color #717171
> .rp
margin-left 4px
font-style oblique
color #a0bf46
> .location
margin 4px 0
font-size 12px
color #ccc
> .map
width 100%
height 300px
&:empty
display none
> .tags
margin 4px 0 0 0
> *
display inline-block
margin 0 8px 0 0
padding 2px 8px 2px 16px
font-size 90%
color #8d969e
background #edf0f3
border-radius 4px
&:before
content ""
display block
position absolute
top 0
bottom 0
left 4px
width 8px
height 8px
margin auto 0
background #fff
border-radius 100%
&:hover
text-decoration none
background #e2e7ec
.mk-url-preview
margin-top 8px
> .channel
margin 0
> .mk-poll
font-size 80%
> .repost
margin 8px 0
> .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
&.reacted
color $theme-color
&:last-child
position absolute
right 0
margin 0
> .detail
padding-top 4px
background rgba(0, 0, 0, 0.0125)
</style>
<style lang="stylus" module>
.text
code
padding 4px 8px
margin 0 0.5em
font-size 80%
color #525252
background #f8f8f8
border-radius 2px
pre > code
padding 16px
margin 0
[data-is-me]:after
content "you"
padding 0 4px
margin-left 4px
font-size 80%
color $theme-color-foreground
background $theme-color
border-radius 4px
</style>

View File

@ -0,0 +1,89 @@
<template>
<div class="mk-posts">
<template v-for="(post, i) in _posts">
<x-post :post="post" :key="post.id" @update:post="onPostUpdated(i, $event)"/>
<p class="date" v-if="i != posts.length - 1 && post._date != _posts[i + 1]._date">
<span>%fa:angle-up%{{ post._datetext }}</span>
<span>%fa:angle-down%{{ _posts[i + 1]._datetext }}</span>
</p>
</template>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XPost from './posts.post.vue';
export default Vue.extend({
components: {
XPost
},
props: {
posts: {
type: Array,
default: () => []
}
},
computed: {
_posts(): any[] {
return (this.posts as any).map(post => {
const date = new Date(post.createdAt).getDate();
const month = new Date(post.createdAt).getMonth() + 1;
post._date = date;
post._datetext = `${month}${date}`;
return post;
});
}
},
methods: {
focus() {
(this.$el as any).children[0].focus();
},
onPostUpdated(i, post) {
Vue.set((this as any).posts, i, post);
}
}
});
</script>
<style lang="stylus" scoped>
.mk-posts
> .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
[data-fa]
margin-right 8px
> footer
> *
display block
margin 0
padding 16px
width 100%
text-align center
color #ccc
border-top solid 1px #eaeaea
border-bottom-left-radius 4px
border-bottom-right-radius 4px
> button
&:hover
background #f5f5f5
&:active
background #eee
</style>

View File

@ -0,0 +1,95 @@
<template>
<mk-window ref="window" :is-modal="false" :can-close="false" width="500px" @closed="$destroy">
<span slot="header">{{ title }}<mk-ellipsis/></span>
<div :class="$style.body">
<p :class="$style.init" v-if="isNaN(value)">待機中<mk-ellipsis/></p>
<p :class="$style.percentage" v-if="!isNaN(value)">{{ Math.floor((value / max) * 100) }}</p>
<progress :class="$style.progress"
v-if="!isNaN(value) && value < max"
:value="isNaN(value) ? 0 : value"
:max="max"
></progress>
<div :class="[$style.progress, $style.waiting]" v-if="value >= max"></div>
</div>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['title', 'initValue', 'initMax'],
data() {
return {
value: this.initValue,
max: this.initMax
};
},
methods: {
update(value, max) {
this.value = parseInt(value, 10);
this.max = parseInt(max, 10);
},
close() {
(this.$refs.window as any).close();
}
}
});
</script>
<style lang="stylus" module>
@import '~const.styl'
.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
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)
.waiting
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;}
</style>

View File

@ -0,0 +1,42 @@
<template>
<mk-window ref="window" is-modal @closed="$destroy">
<span slot="header" :class="$style.header">%fa:retweet%%i18n:desktop.tags.mk-repost-form-window.title%</span>
<mk-repost-form ref="form" :post="post" @posted="onPosted" @canceled="onCanceled"/>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['post'],
mounted() {
document.addEventListener('keydown', this.onDocumentKeydown);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onDocumentKeydown);
},
methods: {
onDocumentKeydown(e) {
if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
if (e.which == 27) { // Esc
(this.$refs.window as any).close();
}
}
},
onPosted() {
(this.$refs.window as any).close();
},
onCanceled() {
(this.$refs.window as any).close();
}
}
});
</script>
<style lang="stylus" module>
.header
> [data-fa]
margin-right 4px
</style>

View File

@ -0,0 +1,131 @@
<template>
<div class="mk-repost-form">
<mk-post-preview :post="post"/>
<template v-if="!quote">
<footer>
<a class="quote" v-if="!quote" @click="onQuote">%i18n:desktop.tags.mk-repost-form.quote%</a>
<button class="cancel" @click="cancel">%i18n:desktop.tags.mk-repost-form.cancel%</button>
<button class="ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:desktop.tags.mk-repost-form.reposting%' : '%i18n:desktop.tags.mk-repost-form.repost%' }}</button>
</footer>
</template>
<template v-if="quote">
<mk-post-form ref="form" :repost="post" @posted="onChildFormPosted"/>
</template>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['post'],
data() {
return {
wait: false,
quote: false
};
},
methods: {
ok() {
this.wait = true;
(this as any).api('posts/create', {
repostId: this.post.id
}).then(data => {
this.$emit('posted');
(this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.success%');
}).catch(err => {
(this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.failure%');
}).then(() => {
this.wait = false;
});
},
cancel() {
this.$emit('canceled');
},
onQuote() {
this.quote = true;
this.$nextTick(() => {
(this.$refs.form as any).focus();
});
},
onChildFormPosted() {
this.$emit('posted');
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-repost-form
> .mk-post-preview
margin 16px 22px
> 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
</style>

View File

@ -0,0 +1,24 @@
<template>
<mk-window ref="window" is-modal width="700px" height="550px" @closed="$destroy">
<span slot="header" :class="$style.header">%fa:cog%設定</span>
<mk-settings @done="close"/>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
methods: {
close() {
(this as any).$refs.window.close();
}
}
});
</script>
<style lang="stylus" module>
.header
> [data-fa]
margin-right 4px
</style>

View File

@ -0,0 +1,80 @@
<template>
<div class="2fa">
<p>%i18n:desktop.tags.mk-2fa-setting.intro%<a href="%i18n:desktop.tags.mk-2fa-setting.url%" target="_blank">%i18n:desktop.tags.mk-2fa-setting.detail%</a></p>
<div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-2fa-setting.caution%</p></div>
<p v-if="!data && !os.i.account.twoFactorEnabled"><button @click="register" class="ui primary">%i18n:desktop.tags.mk-2fa-setting.register%</button></p>
<template v-if="os.i.account.twoFactorEnabled">
<p>%i18n:desktop.tags.mk-2fa-setting.already-registered%</p>
<button @click="unregister" class="ui">%i18n:desktop.tags.mk-2fa-setting.unregister%</button>
</template>
<div v-if="data">
<ol>
<li>%i18n:desktop.tags.mk-2fa-setting.authenticator% <a href="https://support.google.com/accounts/answer/1066447" target="_blank">%i18n:desktop.tags.mk-2fa-setting.howtoinstall%</a></li>
<li>%i18n:desktop.tags.mk-2fa-setting.scan%<br><img :src="data.qr"></li>
<li>%i18n:desktop.tags.mk-2fa-setting.done%<br>
<input type="number" v-model="token" class="ui">
<button @click="submit" class="ui primary">%i18n:desktop.tags.mk-2fa-setting.submit%</button>
</li>
</ol>
<div class="ui info"><p>%fa:info-circle%%i18n:desktop.tags.mk-2fa-setting.info%</p></div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data() {
return {
data: null,
token: null
};
},
methods: {
register() {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-2fa-setting.enter-password%',
type: 'password'
}).then(password => {
(this as any).api('i/2fa/register', {
password: password
}).then(data => {
this.data = data;
});
});
},
unregister() {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-2fa-setting.enter-password%',
type: 'password'
}).then(password => {
(this as any).api('i/2fa/unregister', {
password: password
}).then(() => {
(this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.unregistered%');
(this as any).os.i.account.twoFactorEnabled = false;
});
});
},
submit() {
(this as any).api('i/2fa/done', {
token: this.token
}).then(() => {
(this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.success%');
(this as any).os.i.account.twoFactorEnabled = true;
}).catch(() => {
(this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.failed%');
});
}
}
});
</script>
<style lang="stylus" scoped>
.2fa
color #4a535a
</style>

View File

@ -0,0 +1,40 @@
<template>
<div class="root api">
<p>Token: <code>{{ os.i.account.token }}</code></p>
<p>%i18n:desktop.tags.mk-api-info.intro%</p>
<div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-api-info.caution%</p></div>
<p>%i18n:desktop.tags.mk-api-info.regeneration-of-token%</p>
<button class="ui" @click="regenerateToken">%i18n:desktop.tags.mk-api-info.regenerate-token%</button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
methods: {
regenerateToken() {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-api-info.enter-password%',
type: 'password'
}).then(password => {
(this as any).api('i/regenerate_token', {
password: password
});
});
}
}
});
</script>
<style lang="stylus" scoped>
.root.api
color #4a535a
code
display inline-block
padding 4px 6px
color #555
background #eee
border-radius 2px
</style>

View File

@ -0,0 +1,39 @@
<template>
<div class="root">
<div class="none ui info" v-if="!fetching && apps.length == 0">
<p>%fa:info-circle%%i18n:common.tags.mk-authorized-apps.no-apps%</p>
</div>
<div class="apps" v-if="apps.length != 0">
<div v-for="app in apps">
<p><b>{{ app.name }}</b></p>
<p>{{ app.description }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data() {
return {
fetching: true,
apps: []
};
},
mounted() {
(this as any).api('i/authorized_apps').then(apps => {
this.apps = apps;
this.fetching = false;
});
}
});
</script>
<style lang="stylus" scoped>
.root
> .apps
> div
padding 16px 0 0 0
border-bottom solid 1px #eee
</style>

View File

@ -0,0 +1,35 @@
<template>
<div class="root">
<template v-if="!fetching">
<el-progress :text-inside="true" :stroke-width="18" :percentage="Math.floor((usage / capacity) * 100)"/>
<p><b>{{ capacity | bytes }}</b><b>{{ usage | bytes }}</b>使用中</p>
</template>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data() {
return {
fetching: true,
usage: null,
capacity: null
};
},
mounted() {
(this as any).api('drive').then(info => {
this.capacity = info.capacity;
this.usage = info.usage;
this.fetching = false;
});
}
});
</script>
<style lang="stylus" scoped>
.root
> p
> b
margin 0 8px
</style>

View File

@ -0,0 +1,35 @@
<template>
<div>
<div class="none ui info" v-if="!fetching && users.length == 0">
<p>%fa:info-circle%%i18n:desktop.tags.mk-mute-setting.no-users%</p>
</div>
<div class="users" v-if="users.length != 0">
<div v-for="user in users" :key="user.id">
<p><b>{{ user.name }}</b> @{{ getAcct(user) }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import getAcct from '../../../../../common/user/get-acct';
export default Vue.extend({
data() {
return {
fetching: true,
users: []
};
},
methods: {
getAcct
},
mounted() {
(this as any).api('mute/list').then(x => {
this.users = x.users;
this.fetching = false;
});
}
});
</script>

View File

@ -0,0 +1,47 @@
<template>
<div>
<button @click="reset" class="ui primary">%i18n:desktop.tags.mk-password-setting.reset%</button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
methods: {
reset() {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-password-setting.enter-current-password%',
type: 'password'
}).then(currentPassword => {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-password-setting.enter-new-password%',
type: 'password'
}).then(newPassword => {
(this as any).apis.input({
title: '%i18n:desktop.tags.mk-password-setting.enter-new-password-again%',
type: 'password'
}).then(newPassword2 => {
if (newPassword !== newPassword2) {
(this as any).apis.dialog({
title: null,
text: '%i18n:desktop.tags.mk-password-setting.not-match%',
actions: [{
text: 'OK'
}]
});
return;
}
(this as any).api('i/change_password', {
currentPasword: currentPassword,
newPassword: newPassword
}).then(() => {
(this as any).apis.notify('%i18n:desktop.tags.mk-password-setting.changed%');
});
});
});
});
}
}
});
</script>

View File

@ -0,0 +1,87 @@
<template>
<div class="profile">
<label class="avatar ui from group">
<p>%i18n:desktop.tags.mk-profile-setting.avatar%</p>
<img class="avatar" :src="`${os.i.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
<button class="ui" @click="updateAvatar">%i18n:desktop.tags.mk-profile-setting.choice-avatar%</button>
</label>
<label class="ui from group">
<p>%i18n:desktop.tags.mk-profile-setting.name%</p>
<input v-model="name" type="text" class="ui"/>
</label>
<label class="ui from group">
<p>%i18n:desktop.tags.mk-profile-setting.location%</p>
<input v-model="location" type="text" class="ui"/>
</label>
<label class="ui from group">
<p>%i18n:desktop.tags.mk-profile-setting.description%</p>
<textarea v-model="description" class="ui"></textarea>
</label>
<label class="ui from group">
<p>%i18n:desktop.tags.mk-profile-setting.birthday%</p>
<el-date-picker v-model="birthday" type="date" value-format="yyyy-MM-dd"/>
</label>
<button class="ui primary" @click="save">%i18n:desktop.tags.mk-profile-setting.save%</button>
<section>
<h2>その他</h2>
<mk-switch v-model="os.i.account.isBot" @change="onChangeIsBot" text="このアカウントはbotです"/>
</section>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data() {
return {
name: null,
location: null,
description: null,
birthday: null,
};
},
created() {
this.name = (this as any).os.i.name;
this.location = (this as any).os.i.account.profile.location;
this.description = (this as any).os.i.description;
this.birthday = (this as any).os.i.account.profile.birthday;
},
methods: {
updateAvatar() {
(this as any).apis.updateAvatar();
},
save() {
(this as any).api('i/update', {
name: this.name,
location: this.location || null,
description: this.description || null,
birthday: this.birthday || null
}).then(() => {
(this as any).apis.notify('プロフィールを更新しました');
});
},
onChangeIsBot() {
(this as any).api('i/update', {
isBot: (this as any).os.i.account.isBot
});
}
}
});
</script>
<style lang="stylus" scoped>
.profile
> .avatar
> img
display inline-block
vertical-align top
width 64px
height 64px
border-radius 4px
> button
margin-left 8px
</style>

View File

@ -0,0 +1,98 @@
<template>
<div class="root">
<div class="signins" v-if="signins.length != 0">
<div v-for="signin in signins">
<header @click="signin._show = !signin._show">
<template v-if="signin.success">%fa:check%</template>
<template v-else>%fa:times%</template>
<span class="ip">{{ signin.ip }}</span>
<mk-time :time="signin.createdAt"/>
</header>
<div class="headers" v-show="signin._show">
<tree-view :data="signin.headers"/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data() {
return {
fetching: true,
signins: [],
connection: null,
connectionId: null
};
},
mounted() {
(this as any).api('i/signin_history').then(signins => {
this.signins = signins;
this.fetching = false;
});
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
this.connection.on('signin', this.onSignin);
},
beforeDestroy() {
this.connection.off('signin', this.onSignin);
(this as any).os.stream.dispose(this.connectionId);
},
methods: {
onSignin(signin) {
this.signins.unshift(signin);
}
}
});
</script>
<style lang="stylus" scoped>
.root
> .signins
> div
border-bottom solid 1px #eee
> header
display flex
padding 8px 0
line-height 32px
cursor pointer
> [data-fa]
margin-right 8px
text-align left
&.check
color #0fda82
&.times
color #ff3100
> .ip
display inline-block
text-align left
padding 8px
line-height 16px
font-family monospace
font-size 14px
color #444
background #f8f8f8
border-radius 4px
> .mk-time
margin-left auto
text-align right
color #777
> .headers
overflow auto
margin 0 0 16px 0
max-height 100px
white-space pre-wrap
word-break break-all
</style>

View File

@ -0,0 +1,419 @@
<template>
<div class="mk-settings">
<div class="nav">
<p :class="{ active: page == 'profile' }" @mousedown="page = 'profile'">%fa:user .fw%%i18n:desktop.tags.mk-settings.profile%</p>
<p :class="{ active: page == 'web' }" @mousedown="page = 'web'">%fa:desktop .fw%Web</p>
<p :class="{ active: page == 'notification' }" @mousedown="page = 'notification'">%fa:R bell .fw%通知</p>
<p :class="{ active: page == 'drive' }" @mousedown="page = 'drive'">%fa:cloud .fw%%i18n:desktop.tags.mk-settings.drive%</p>
<p :class="{ active: page == 'mute' }" @mousedown="page = 'mute'">%fa:ban .fw%%i18n:desktop.tags.mk-settings.mute%</p>
<p :class="{ active: page == 'apps' }" @mousedown="page = 'apps'">%fa:puzzle-piece .fw%アプリ</p>
<p :class="{ active: page == 'twitter' }" @mousedown="page = 'twitter'">%fa:B twitter .fw%Twitter</p>
<p :class="{ active: page == 'security' }" @mousedown="page = 'security'">%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.security%</p>
<p :class="{ active: page == 'api' }" @mousedown="page = 'api'">%fa:key .fw%API</p>
<p :class="{ active: page == 'other' }" @mousedown="page = 'other'">%fa:cogs .fw%%i18n:desktop.tags.mk-settings.other%</p>
</div>
<div class="pages">
<section class="profile" v-show="page == 'profile'">
<h1>%i18n:desktop.tags.mk-settings.profile%</h1>
<x-profile/>
</section>
<section class="web" v-show="page == 'web'">
<h1>動作</h1>
<mk-switch v-model="os.i.account.clientSettings.fetchOnScroll" @change="onChangeFetchOnScroll" text="スクロールで自動読み込み">
<span>ページを下までスクロールしたときに自動で追加のコンテンツを読み込みます</span>
</mk-switch>
<mk-switch v-model="autoPopout" text="ウィンドウの自動ポップアウト">
<span>ウィンドウが開かれるときポップアウト(ブラウザ外に切り離す)可能なら自動でポップアウトしますこの設定はブラウザに記憶されます</span>
</mk-switch>
</section>
<section class="web" v-show="page == 'web'">
<h1>デザインと表示</h1>
<div class="div">
<button class="ui button" @click="customizeHome">ホームをカスタマイズ</button>
</div>
<mk-switch v-model="os.i.account.clientSettings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="タイムライン上部に投稿フォームを表示する"/>
<mk-switch v-model="os.i.account.clientSettings.showMaps" @change="onChangeShowMaps" text="マップの自動展開">
<span>位置情報が添付された投稿のマップを自動的に展開します</span>
</mk-switch>
<mk-switch v-model="os.i.account.clientSettings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="ウィンドウのタイトルバーにグラデーションを使用"/>
</section>
<section class="web" v-show="page == 'web'">
<h1>サウンド</h1>
<mk-switch v-model="enableSounds" text="サウンドを有効にする">
<span>投稿やメッセージを送受信したときなどにサウンドを再生しますこの設定はブラウザに記憶されます</span>
</mk-switch>
<label>ボリューム</label>
<el-slider
v-model="soundVolume"
:show-input="true"
:format-tooltip="v => `${v}%`"
:disabled="!enableSounds"
/>
<button class="ui button" @click="soundTest">%fa:volume-up% テスト</button>
</section>
<section class="web" v-show="page == 'web'">
<h1>モバイル</h1>
<mk-switch v-model="os.i.account.clientSettings.disableViaMobile" @change="onChangeDisableViaMobile" text="モバイルからの投稿フラグを付けない"/>
</section>
<section class="web" v-show="page == 'web'">
<h1>言語</h1>
<el-select v-model="lang" placeholder="言語を選択">
<el-option-group label="推奨">
<el-option label="自動" value=""/>
</el-option-group>
<el-option-group label="言語を指定">
<el-option label="ja-JP" value="ja"/>
<el-option label="en-US" value="en"/>
</el-option-group>
</el-select>
<div class="none ui info">
<p>%fa:info-circle%変更はページの再度読み込み後に反映されます。</p>
</div>
</section>
<section class="web" v-show="page == 'web'">
<h1>キャッシュ</h1>
<button class="ui button" @click="clean">クリーンアップ</button>
<div class="none ui info warn">
<p>%fa:exclamation-triangle%クリーンアップを行うと、ブラウザに記憶されたアカウント情報のキャッシュ、書きかけの投稿・返信・メッセージ、およびその他のデータ(設定情報含む)が削除されます。クリーンアップを行った後はページを再度読み込みする必要があります。</p>
</div>
</section>
<section class="notification" v-show="page == 'notification'">
<h1>通知</h1>
<mk-switch v-model="os.i.account.settings.autoWatch" @change="onChangeAutoWatch" text="投稿の自動ウォッチ">
<span>リアクションしたり返信したりした投稿に関する通知を自動的に受け取るようにします。</span>
</mk-switch>
</section>
<section class="drive" v-show="page == 'drive'">
<h1>%i18n:desktop.tags.mk-settings.drive%</h1>
<x-drive/>
</section>
<section class="mute" v-show="page == 'mute'">
<h1>%i18n:desktop.tags.mk-settings.mute%</h1>
<x-mute/>
</section>
<section class="apps" v-show="page == 'apps'">
<h1>アプリケーション</h1>
<x-apps/>
</section>
<section class="twitter" v-show="page == 'twitter'">
<h1>Twitter</h1>
<mk-twitter-setting/>
</section>
<section class="password" v-show="page == 'security'">
<h1>%i18n:desktop.tags.mk-settings.password%</h1>
<x-password/>
</section>
<section class="2fa" v-show="page == 'security'">
<h1>%i18n:desktop.tags.mk-settings.2fa%</h1>
<x-2fa/>
</section>
<section class="signin" v-show="page == 'security'">
<h1>サインイン履歴</h1>
<x-signins/>
</section>
<section class="api" v-show="page == 'api'">
<h1>API</h1>
<x-api/>
</section>
<section class="other" v-show="page == 'other'">
<h1>Misskeyについて</h1>
<p v-if="meta">このサーバーの運営者: <i><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></i></p>
</section>
<section class="other" v-show="page == 'other'">
<h1>Misskey Update</h1>
<p>
<span>バージョン: <i>{{ version }}</i></span>
<template v-if="latestVersion !== undefined">
<br>
<span>最新のバージョン: <i>{{ latestVersion ? latestVersion : version }}</i></span>
</template>
</p>
<button class="ui button block" @click="checkForUpdate" :disabled="checkingForUpdate">
<template v-if="checkingForUpdate">アップデートを確認中<mk-ellipsis/></template>
<template v-else>アップデートを確認</template>
</button>
<details>
<summary>詳細設定</summary>
<mk-switch v-model="preventUpdate" text="アップデートを延期する(非推奨)">
<span>この設定をオンにしてもアップデートが反映される場合があります。この設定はこのデバイスのみ有効です。</span>
</mk-switch>
</details>
</section>
<section class="other" v-show="page == 'other'">
<h1>高度な設定</h1>
<mk-switch v-model="debug" text="デバッグモードを有効にする">
<span>この設定はブラウザに記憶されます。</span>
</mk-switch>
<template v-if="debug">
<mk-switch v-model="useRawScript" text="生のスクリプトを読み込む">
<span>圧縮されていない「生の」スクリプトを使用します。サイズが大きいため、読み込みに時間がかかる場合があります。この設定はブラウザに記憶されます。</span>
</mk-switch>
<div class="none ui info">
<p>%fa:info-circle%Misskeyはソースマップも提供しています。</p>
</div>
</template>
<mk-switch v-model="enableExperimental" text="実験的機能を有効にする">
<span>実験的機能を有効にするとMisskeyの動作が不安定になる可能性があります。この設定はブラウザに記憶されます。</span>
</mk-switch>
<details v-if="debug">
<summary>ツール</summary>
<button class="ui button block" @click="taskmngr">タスクマネージャ</button>
</details>
</section>
<section class="other" v-show="page == 'other'">
<h1>%i18n:desktop.tags.mk-settings.license%</h1>
<div v-html="license"></div>
<a :href="licenseUrl" target="_blank">サードパーティ</a>
</section>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XProfile from './settings.profile.vue';
import XMute from './settings.mute.vue';
import XPassword from './settings.password.vue';
import X2fa from './settings.2fa.vue';
import XApi from './settings.api.vue';
import XApps from './settings.apps.vue';
import XSignins from './settings.signins.vue';
import XDrive from './settings.drive.vue';
import { url, docsUrl, license, lang, version } from '../../../config';
import checkForUpdate from '../../../common/scripts/check-for-update';
import MkTaskManager from './taskmanager.vue';
export default Vue.extend({
components: {
XProfile,
XMute,
XPassword,
X2fa,
XApi,
XApps,
XSignins,
XDrive
},
data() {
return {
page: 'profile',
meta: null,
license,
version,
latestVersion: undefined,
checkingForUpdate: false,
enableSounds: localStorage.getItem('enableSounds') == 'true',
autoPopout: localStorage.getItem('autoPopout') == 'true',
soundVolume: localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) : 100,
lang: localStorage.getItem('lang') || '',
preventUpdate: localStorage.getItem('preventUpdate') == 'true',
debug: localStorage.getItem('debug') == 'true',
useRawScript: localStorage.getItem('useRawScript') == 'true',
enableExperimental: localStorage.getItem('enableExperimental') == 'true'
};
},
computed: {
licenseUrl(): string {
return `${docsUrl}/${lang}/license`;
}
},
watch: {
autoPopout() {
localStorage.setItem('autoPopout', this.autoPopout ? 'true' : 'false');
},
enableSounds() {
localStorage.setItem('enableSounds', this.enableSounds ? 'true' : 'false');
},
soundVolume() {
localStorage.setItem('soundVolume', this.soundVolume.toString());
},
lang() {
localStorage.setItem('lang', this.lang);
},
preventUpdate() {
localStorage.setItem('preventUpdate', this.preventUpdate ? 'true' : 'false');
},
debug() {
localStorage.setItem('debug', this.debug ? 'true' : 'false');
},
useRawScript() {
localStorage.setItem('useRawScript', this.useRawScript ? 'true' : 'false');
},
enableExperimental() {
localStorage.setItem('enableExperimental', this.enableExperimental ? 'true' : 'false');
}
},
created() {
(this as any).os.getMeta().then(meta => {
this.meta = meta;
});
},
methods: {
taskmngr() {
(this as any).os.new(MkTaskManager);
},
customizeHome() {
this.$router.push('/i/customize-home');
this.$emit('done');
},
onChangeFetchOnScroll(v) {
(this as any).api('i/update_client_setting', {
name: 'fetchOnScroll',
value: v
});
},
onChangeAutoWatch(v) {
(this as any).api('i/update', {
autoWatch: v
});
},
onChangeShowPostFormOnTopOfTl(v) {
(this as any).api('i/update_client_setting', {
name: 'showPostFormOnTopOfTl',
value: v
});
},
onChangeShowMaps(v) {
(this as any).api('i/update_client_setting', {
name: 'showMaps',
value: v
});
},
onChangeGradientWindowHeader(v) {
(this as any).api('i/update_client_setting', {
name: 'gradientWindowHeader',
value: v
});
},
onChangeDisableViaMobile(v) {
(this as any).api('i/update_client_setting', {
name: 'disableViaMobile',
value: v
});
},
checkForUpdate() {
this.checkingForUpdate = true;
checkForUpdate((this as any).os, true, true).then(newer => {
this.checkingForUpdate = false;
this.latestVersion = newer;
if (newer == null) {
(this as any).apis.dialog({
title: '利用可能な更新はありません',
text: 'お使いのMisskeyは最新です。'
});
} else {
(this as any).apis.dialog({
title: '新しいバージョンが利用可能です',
text: 'ページを再度読み込みすると更新が適用されます。'
});
}
});
},
clean() {
localStorage.clear();
(this as any).apis.dialog({
title: 'キャッシュを削除しました',
text: 'ページを再度読み込みしてください。'
});
},
soundTest() {
const sound = new Audio(`${url}/assets/message.mp3`);
sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1;
sound.play();
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-settings
display flex
width 100%
height 100%
> .nav
flex 0 0 200px
width 100%
height 100%
padding 16px 0 0 0
overflow auto
border-right solid 1px #ddd
> p
display block
padding 10px 16px
margin 0
color #666
cursor pointer
user-select none
transition margin-left 0.2s ease
> [data-fa]
margin-right 4px
&:hover
color #555
&.active
margin-left 8px
color $theme-color !important
> .pages
width 100%
height 100%
flex auto
overflow auto
> section
margin 32px
color #4a535a
> h1
margin 0 0 1em 0
padding 0 0 8px 0
font-size 1em
color #555
border-bottom solid 1px #eee
&, >>> *
.ui.button.block
margin 16px 0
> section
margin 32px 0
> h2
margin 0 0 1em 0
padding 0 0 8px 0
font-size 1em
color #555
border-bottom solid 1px #eee
> .web
> .div
border-bottom solid 1px #eee
padding 0 0 16px 0
margin 0 0 16px 0
</style>

View File

@ -0,0 +1,56 @@
<template>
<div class="mk-sub-post-content">
<div class="body">
<a class="reply" v-if="post.replyId">%fa:reply%</a>
<mk-post-html :ast="post.ast" :i="os.i"/>
<a class="rp" v-if="post.repostId" :href="`/post:${post.repostId}`">RP: ...</a>
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
</div>
<details v-if="post.media">
<summary>({{ post.media.length }}つのメディア)</summary>
<mk-media-list :media-list="post.media"/>
</details>
<details v-if="post.poll">
<summary>投票</summary>
<mk-poll :post="post"/>
</details>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['post'],
computed: {
urls(): string[] {
if (this.post.ast) {
return this.post.ast
.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
.map(t => t.url);
} else {
return null;
}
}
}
});
</script>
<style lang="stylus" scoped>
.mk-sub-post-content
overflow-wrap break-word
> .body
> .reply
margin-right 6px
color #717171
> .rp
margin-left 4px
font-style oblique
color #a0bf46
mk-poll
font-size 80%
</style>

View File

@ -0,0 +1,219 @@
<template>
<mk-window ref="window" width="750px" height="500px" @closed="$destroy" name="TaskManager">
<span slot="header" :class="$style.header">%fa:stethoscope%タスクマネージャ</span>
<el-tabs :class="$style.content">
<el-tab-pane label="Requests">
<el-table
:data="os.requests"
style="width: 100%"
:default-sort="{prop: 'date', order: 'descending'}"
>
<el-table-column type="expand">
<template slot-scope="props">
<pre>{{ props.row.data }}</pre>
<pre>{{ props.row.res }}</pre>
</template>
</el-table-column>
<el-table-column
label="Requested at"
prop="date"
sortable
>
<template slot-scope="scope">
<b style="margin-right: 8px">{{ scope.row.date.getTime() }}</b>
<span>(<mk-time :time="scope.row.date"/>)</span>
</template>
</el-table-column>
<el-table-column
label="Name"
>
<template slot-scope="scope">
<b>{{ scope.row.name }}</b>
</template>
</el-table-column>
<el-table-column
label="Status"
>
<template slot-scope="scope">
<span>{{ scope.row.status || '(pending)' }}</span>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="Streams">
<el-table
:data="os.connections"
style="width: 100%"
>
<el-table-column
label="Uptime"
>
<template slot-scope="scope">
<mk-timer v-if="scope.row.connectedAt" :time="scope.row.connectedAt"/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
label="Name"
>
<template slot-scope="scope">
<b>{{ scope.row.name == '' ? '[Home]' : scope.row.name }}</b>
</template>
</el-table-column>
<el-table-column
label="User"
>
<template slot-scope="scope">
<span>{{ scope.row.user || '(anonymous)' }}</span>
</template>
</el-table-column>
<el-table-column
prop="state"
label="State"
/>
<el-table-column
prop="in"
label="In"
/>
<el-table-column
prop="out"
label="Out"
/>
</el-table>
</el-tab-pane>
<el-tab-pane label="Streams (Inspect)">
<el-tabs type="card" style="height:50%">
<el-tab-pane v-for="c in os.connections" :label="c.name == '' ? '[Home]' : c.name" :key="c.id" :name="c.id" ref="connectionsTab">
<div style="padding: 12px 0 0 12px">
<el-button size="mini" @click="send(c)">Send</el-button>
<el-button size="mini" type="warning" @click="c.isSuspended = true" v-if="!c.isSuspended">Suspend</el-button>
<el-button size="mini" type="success" @click="c.isSuspended = false" v-else>Resume</el-button>
<el-button size="mini" type="danger" @click="c.close">Disconnect</el-button>
</div>
<el-table
:data="c.inout"
style="width: 100%"
:default-sort="{prop: 'at', order: 'descending'}"
>
<el-table-column type="expand">
<template slot-scope="props">
<pre>{{ props.row.data }}</pre>
</template>
</el-table-column>
<el-table-column
label="Date"
prop="at"
sortable
>
<template slot-scope="scope">
<b style="margin-right: 8px">{{ scope.row.at.getTime() }}</b>
<span>(<mk-time :time="scope.row.at"/>)</span>
</template>
</el-table-column>
<el-table-column
label="Type"
>
<template slot-scope="scope">
<span>{{ getMessageType(scope.row.data) }}</span>
</template>
</el-table-column>
<el-table-column
label="Incoming / Outgoing"
prop="type"
/>
</el-table>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<el-tab-pane label="Windows">
<el-table
:data="Array.from(os.windows.windows)"
style="width: 100%"
>
<el-table-column
label="Name"
>
<template slot-scope="scope">
<b>{{ scope.row.name || '(unknown)' }}</b>
</template>
</el-table-column>
<el-table-column
label="Operations"
>
<template slot-scope="scope">
<el-button size="mini" type="danger" @click="scope.row.close">Close</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</mk-window>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
mounted() {
(this as any).os.windows.on('added', this.onWindowsChanged);
(this as any).os.windows.on('removed', this.onWindowsChanged);
},
beforeDestroy() {
(this as any).os.windows.off('added', this.onWindowsChanged);
(this as any).os.windows.off('removed', this.onWindowsChanged);
},
methods: {
getMessageType(data): string {
return data.type ? data.type : '-';
},
onWindowsChanged() {
this.$forceUpdate();
},
send(c) {
(this as any).apis.input({
title: 'Send a JSON message',
allowEmpty: false
}).then(json => {
c.send(JSON.parse(json));
});
}
}
});
</script>
<style lang="stylus" module>
.header
> [data-fa]
margin-right 4px
.content
height 100%
overflow auto
</style>
<style>
.el-tabs__header {
margin-bottom: 0 !important;
}
.el-tabs__item {
padding: 0 20px !important;
}
</style>

View File

@ -0,0 +1,156 @@
<template>
<div class="mk-timeline">
<mk-friends-maker v-if="alone"/>
<div class="fetching" v-if="fetching">
<mk-ellipsis-icon/>
</div>
<p class="empty" v-if="posts.length == 0 && !fetching">
%fa:R comments%自分の投稿や自分がフォローしているユーザーの投稿が表示されます
</p>
<mk-posts :posts="posts" ref="timeline">
<button slot="footer" @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">もっと見る</template>
<template v-if="moreFetching">%fa:spinner .pulse .fw%</template>
</button>
</mk-posts>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { url } from '../../../config';
export default Vue.extend({
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
posts: [],
connection: null,
connectionId: null,
date: null
};
},
computed: {
alone(): boolean {
return (this as any).os.i.followingCount == 0;
}
},
mounted() {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
this.connection.on('post', this.onPost);
this.connection.on('follow', this.onChangeFollowing);
this.connection.on('unfollow', this.onChangeFollowing);
document.addEventListener('keydown', this.onKeydown);
window.addEventListener('scroll', this.onScroll);
this.fetch();
},
beforeDestroy() {
this.connection.off('post', this.onPost);
this.connection.off('follow', this.onChangeFollowing);
this.connection.off('unfollow', this.onChangeFollowing);
(this as any).os.stream.dispose(this.connectionId);
document.removeEventListener('keydown', this.onKeydown);
window.removeEventListener('scroll', this.onScroll);
},
methods: {
fetch(cb?) {
this.fetching = true;
(this as any).api('posts/timeline', {
limit: 11,
untilDate: this.date ? this.date.getTime() : undefined
}).then(posts => {
if (posts.length == 11) {
posts.pop();
this.existMore = true;
}
this.posts = posts;
this.fetching = false;
this.$emit('loaded');
if (cb) cb();
});
},
more() {
if (this.moreFetching || this.fetching || this.posts.length == 0 || !this.existMore) return;
this.moreFetching = true;
(this as any).api('posts/timeline', {
limit: 11,
untilId: this.posts[this.posts.length - 1].id
}).then(posts => {
if (posts.length == 11) {
posts.pop();
} else {
this.existMore = false;
}
this.posts = this.posts.concat(posts);
this.moreFetching = false;
});
},
onPost(post) {
// サウンドを再生する
if ((this as any).os.isEnableSounds) {
const sound = new Audio(`${url}/assets/post.mp3`);
sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1;
sound.play();
}
this.posts.unshift(post);
},
onChangeFollowing() {
this.fetch();
},
onScroll() {
if ((this as any).os.i.account.clientSettings.fetchOnScroll !== false) {
const current = window.scrollY + window.innerHeight;
if (current > document.body.offsetHeight - 8) this.more();
}
},
onKeydown(e) {
if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
if (e.which == 84) { // t
(this.$refs.timeline as any).focus();
}
}
},
warp(date) {
this.date = date;
this.fetch();
}
}
});
</script>
<style lang="stylus" scoped>
.mk-timeline
background #fff
border solid 1px rgba(0, 0, 0, 0.075)
border-radius 6px
> .mk-friends-maker
border-bottom solid 1px #eee
> .fetching
padding 64px 0
> .empty
display block
margin 0 auto
padding 32px
max-width 400px
text-align center
color #999
> [data-fa]
display block
margin-bottom 16px
font-size 3em
color #ccc
</style>

View File

@ -0,0 +1,61 @@
<template>
<div class="mk-ui-notification">
<p>{{ message }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as anime from 'animejs';
export default Vue.extend({
props: ['message'],
mounted() {
this.$nextTick(() => {
anime({
targets: this.$el,
opacity: 1,
translateY: [-64, 0],
easing: 'easeOutElastic',
duration: 500
});
setTimeout(() => {
anime({
targets: this.$el,
opacity: 0,
translateY: -64,
duration: 500,
easing: 'easeInElastic',
complete: () => this.$destroy()
});
}, 6000);
});
}
});
</script>
<style lang="stylus" scoped>
.mk-ui-notification
display block
position fixed
z-index 10000
top -128px
left 0
right 0
margin 0 auto
padding 128px 0 0 0
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)
transform translateY(-64px)
opacity 0
> p
margin 0
line-height 64px
text-align center
</style>

View File

@ -0,0 +1,225 @@
<template>
<div class="account">
<button class="header" :data-active="isOpen" @click="toggle">
<span class="username">{{ os.i.username }}<template v-if="!isOpen">%fa:angle-down%</template><template v-if="isOpen">%fa:angle-up%</template></span>
<img class="avatar" :src="`${ os.i.avatarUrl }?thumbnail&size=64`" alt="avatar"/>
</button>
<transition name="zoom-in-top">
<div class="menu" v-if="isOpen">
<ul>
<li>
<router-link :to="`/@${ os.i.username }`">%fa:user%%i18n:desktop.tags.mk-ui-header-account.profile%%fa:angle-right%</router-link>
</li>
<li @click="drive">
<p>%fa:cloud%%i18n:desktop.tags.mk-ui-header-account.drive%%fa:angle-right%</p>
</li>
<li>
<a href="/i/mentions">%fa:at%%i18n:desktop.tags.mk-ui-header-account.mentions%%fa:angle-right%</a>
</li>
</ul>
<ul>
<li @click="settings">
<p>%fa:cog%%i18n:desktop.tags.mk-ui-header-account.settings%%fa:angle-right%</p>
</li>
</ul>
<ul>
<li @click="signout">
<p>%fa:power-off%%i18n:desktop.tags.mk-ui-header-account.signout%%fa:angle-right%</p>
</li>
</ul>
</div>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MkSettingsWindow from './settings-window.vue';
import MkDriveWindow from './drive-window.vue';
import contains from '../../../common/scripts/contains';
export default Vue.extend({
data() {
return {
isOpen: false
};
},
beforeDestroy() {
this.close();
},
methods: {
toggle() {
this.isOpen ? this.close() : this.open();
},
open() {
this.isOpen = true;
Array.from(document.querySelectorAll('body *')).forEach(el => {
el.addEventListener('mousedown', this.onMousedown);
});
},
close() {
this.isOpen = false;
Array.from(document.querySelectorAll('body *')).forEach(el => {
el.removeEventListener('mousedown', this.onMousedown);
});
},
onMousedown(e) {
e.preventDefault();
if (!contains(this.$el, e.target) && this.$el != e.target) this.close();
return false;
},
drive() {
this.close();
(this as any).os.new(MkDriveWindow);
},
settings() {
this.close();
(this as any).os.new(MkSettingsWindow);
},
signout() {
(this as any).os.signout();
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.account
> .header
display block
margin 0
padding 0
color #9eaba8
border none
background transparent
cursor pointer
*
pointer-events none
&:hover
&[data-active='true']
color darken(#9eaba8, 20%)
> .avatar
filter saturate(150%)
&:active
color darken(#9eaba8, 30%)
> .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
[data-fa]
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
> [data-fa]:first-of-type
margin-right 6px
> [data-fa]: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
&:active
background darken($theme-color, 10%)
.zoom-in-top-enter-active,
.zoom-in-top-leave-active {
transform-origin: center -16px;
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<div class="clock">
<div class="header">
<time ref="time">
<span class="yyyymmdd">{{ yyyy }}/{{ mm }}/{{ dd }}</span>
<br>
<span class="hhnn">{{ hh }}<span :style="{ visibility: now.getSeconds() % 2 == 0 ? 'visible' : 'hidden' }">:</span>{{ nn }}</span>
</time>
</div>
<div class="content">
<mk-analog-clock/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data() {
return {
now: new Date(),
clock: null
};
},
computed: {
yyyy(): number {
return this.now.getFullYear();
},
mm(): string {
return ('0' + (this.now.getMonth() + 1)).slice(-2);
},
dd(): string {
return ('0' + this.now.getDate()).slice(-2);
},
hh(): string {
return ('0' + this.now.getHours()).slice(-2);
},
nn(): string {
return ('0' + this.now.getMinutes()).slice(-2);
}
},
mounted() {
this.tick();
this.clock = setInterval(this.tick, 1000);
},
beforeDestroy() {
clearInterval(this.clock);
},
methods: {
tick() {
this.now = new Date();
}
}
});
</script>
<style lang="stylus" scoped>
.clock
display inline-block
overflow visible
> .header
padding 0 12px
text-align center
font-size 10px
&, *
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
</style>

View File

@ -0,0 +1,175 @@
<template>
<div class="nav">
<ul>
<template v-if="os.isSignedIn">
<li class="home" :class="{ active: $route.name == 'index' }">
<router-link to="/">
%fa:home%
<p>%i18n:desktop.tags.mk-ui-header-nav.home%</p>
</router-link>
</li>
<li class="messaging">
<a @click="messaging">
%fa:comments%
<p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p>
<template v-if="hasUnreadMessagingMessages">%fa:circle%</template>
</a>
</li>
<li class="game">
<a @click="game">
%fa:gamepad%
<p>ゲーム</p>
<template v-if="hasGameInvitations">%fa:circle%</template>
</a>
</li>
</template>
<li class="ch">
<a :href="chUrl" target="_blank">
%fa:tv%
<p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p>
</a>
</li>
</ul>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { chUrl } from '../../../config';
import MkMessagingWindow from './messaging-window.vue';
import MkGameWindow from './game-window.vue';
export default Vue.extend({
data() {
return {
hasUnreadMessagingMessages: false,
hasGameInvitations: false,
connection: null,
connectionId: null,
chUrl
};
},
mounted() {
if ((this as any).os.isSignedIn) {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages);
this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage);
this.connection.on('othello_invited', this.onOthelloInvited);
this.connection.on('othello_no_invites', this.onOthelloNoInvites);
// Fetch count of unread messaging messages
(this as any).api('messaging/unread').then(res => {
if (res.count > 0) {
this.hasUnreadMessagingMessages = true;
}
});
}
},
beforeDestroy() {
if ((this as any).os.isSignedIn) {
this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages);
this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage);
this.connection.off('othello_invited', this.onOthelloInvited);
this.connection.off('othello_no_invites', this.onOthelloNoInvites);
(this as any).os.stream.dispose(this.connectionId);
}
},
methods: {
onUnreadMessagingMessage() {
this.hasUnreadMessagingMessages = true;
},
onReadAllMessagingMessages() {
this.hasUnreadMessagingMessages = false;
},
onOthelloInvited() {
this.hasGameInvitations = true;
},
onOthelloNoInvites() {
this.hasGameInvitations = false;
},
messaging() {
(this as any).os.new(MkMessagingWindow);
},
game() {
(this as any).os.new(MkGameWindow);
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.nav
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 13px
font-variant small-caps
color #9eaba8
text-decoration none
transition none
cursor pointer
*
pointer-events none
&:hover
color darken(#9eaba8, 20%)
text-decoration none
> [data-fa]:first-child
margin-right 8px
> [data-fa]:last-child
margin-left 5px
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
</style>

View File

@ -0,0 +1,158 @@
<template>
<div class="notifications">
<button :data-active="isOpen" @click="toggle" title="%i18n:desktop.tags.mk-ui-header-notifications.title%">
%fa:R bell%<template v-if="hasUnreadNotifications">%fa:circle%</template>
</button>
<div class="pop" v-if="isOpen">
<mk-notifications/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import contains from '../../../common/scripts/contains';
export default Vue.extend({
data() {
return {
isOpen: false,
hasUnreadNotifications: false,
connection: null,
connectionId: null
};
},
mounted() {
if ((this as any).os.isSignedIn) {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
this.connection.on('read_all_notifications', this.onReadAllNotifications);
this.connection.on('unread_notification', this.onUnreadNotification);
// Fetch count of unread notifications
(this as any).api('notifications/get_unread_count').then(res => {
if (res.count > 0) {
this.hasUnreadNotifications = true;
}
});
}
},
beforeDestroy() {
if ((this as any).os.isSignedIn) {
this.connection.off('read_all_notifications', this.onReadAllNotifications);
this.connection.off('unread_notification', this.onUnreadNotification);
(this as any).os.stream.dispose(this.connectionId);
}
},
methods: {
onReadAllNotifications() {
this.hasUnreadNotifications = false;
},
onUnreadNotification() {
this.hasUnreadNotifications = true;
},
toggle() {
this.isOpen ? this.close() : this.open();
},
open() {
this.isOpen = true;
Array.from(document.querySelectorAll('body *')).forEach(el => {
el.addEventListener('mousedown', this.onMousedown);
});
},
close() {
this.isOpen = false;
Array.from(document.querySelectorAll('body *')).forEach(el => {
el.removeEventListener('mousedown', this.onMousedown);
});
},
onMousedown(e) {
e.preventDefault();
if (!contains(this.$el, e.target) && this.$el != e.target) this.close();
return false;
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.notifications
> button
display block
margin 0
padding 0
width 32px
color #9eaba8
border none
background transparent
cursor pointer
*
pointer-events none
&:hover
&[data-active='true']
color darken(#9eaba8, 20%)
&:active
color darken(#9eaba8, 30%)
> [data-fa].bell
font-size 1.2em
line-height 48px
> [data-fa].circle
margin-left -5px
vertical-align super
font-size 10px
color $theme-color
> .pop
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
</style>

View File

@ -0,0 +1,54 @@
<template>
<div class="post">
<button @click="post" title="%i18n:desktop.tags.mk-ui-header-post-button.post%">%fa:pencil-alt%</button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
methods: {
post() {
(this as any).apis.post();
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.post
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 4px
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
</style>

View File

@ -0,0 +1,70 @@
<template>
<form class="search" @submit.prevent="onSubmit">
%fa:search%
<input v-model="q" type="search" placeholder="%i18n:desktop.tags.mk-ui-header-search.placeholder%"/>
<div class="result"></div>
</form>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data() {
return {
q: ''
};
},
methods: {
onSubmit() {
location.href = `/search?q=${encodeURIComponent(this.q)}`;
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.search
> [data-fa]
display block
position absolute
top 0
left 0
width 48px
text-align center
line-height 48px
color #9eaba8
pointer-events none
> *
vertical-align middle
> input
user-select text
cursor auto
margin 8px 0 0 0
padding 6px 18px 6px 36px
width 14em
height 32px
font-size 1em
background rgba(0, 0, 0, 0.05)
outline none
//border solid 1px #ddd
border none
border-radius 16px
transition color 0.5s ease, border 0.5s ease
font-family FontAwesome, sans-serif
&::placeholder
color #9eaba8
&:hover
background rgba(0, 0, 0, 0.08)
&:focus
box-shadow 0 0 0 2px rgba($theme-color, 0.5) !important
</style>

View File

@ -0,0 +1,172 @@
<template>
<div class="header">
<mk-special-message/>
<div class="main" ref="main">
<div class="backdrop"></div>
<div class="main">
<p ref="welcomeback" v-if="os.isSignedIn">おかえりなさい<b>{{ os.i.name }}</b>さん</p>
<div class="container" ref="mainContainer">
<div class="left">
<x-nav/>
</div>
<div class="right">
<x-search/>
<x-account v-if="os.isSignedIn"/>
<x-notifications v-if="os.isSignedIn"/>
<x-post v-if="os.isSignedIn"/>
<x-clock/>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as anime from 'animejs';
import XNav from './ui.header.nav.vue';
import XSearch from './ui.header.search.vue';
import XAccount from './ui.header.account.vue';
import XNotifications from './ui.header.notifications.vue';
import XPost from './ui.header.post.vue';
import XClock from './ui.header.clock.vue';
export default Vue.extend({
components: {
XNav,
XSearch,
XAccount,
XNotifications,
XPost,
XClock,
},
mounted() {
if ((this as any).os.isSignedIn) {
const ago = (new Date().getTime() - new Date((this as any).os.i.account.lastUsedAt).getTime()) / 1000
const isHisasiburi = ago >= 3600;
(this as any).os.i.account.lastUsedAt = new Date();
if (isHisasiburi) {
(this.$refs.welcomeback as any).style.display = 'block';
(this.$refs.main as any).style.overflow = 'hidden';
anime({
targets: this.$refs.welcomeback,
top: '0',
opacity: 1,
delay: 1000,
duration: 500,
easing: 'easeOutQuad'
});
anime({
targets: this.$refs.mainContainer,
opacity: 0,
delay: 1000,
duration: 500,
easing: 'easeOutQuad'
});
setTimeout(() => {
anime({
targets: this.$refs.welcomeback,
top: '-48px',
opacity: 0,
duration: 500,
complete: () => {
(this.$refs.welcomeback as any).style.display = 'none';
(this.$refs.main as any).style.overflow = 'initial';
},
easing: 'easeInQuad'
});
anime({
targets: this.$refs.mainContainer,
opacity: 1,
duration: 500,
easing: 'easeInQuad'
});
}, 2500);
}
}
}
});
</script>
<style lang="stylus" scoped>
.header
position -webkit-sticky
position sticky
top 0
z-index 1000
width 100%
box-shadow 0 1px 1px rgba(0, 0, 0, 0.075)
> .main
height 48px
> .backdrop
position absolute
top 0
z-index 1000
width 100%
height 48px
background #f7f7f7
> .main
z-index 1001
margin 0
padding 0
background-clip content-box
font-size 0.9rem
user-select none
> p
display none
position absolute
top 48px
width 100%
line-height 48px
margin 0
text-align center
color #888
opacity 0
> .container
display flex
width 100%
max-width 1300px
margin 0 auto
&:before
content ""
position absolute
top 0
left 0
display block
width 100%
height 48px
background-image url(/assets/desktop/header-logo.svg)
background-size 46px
background-position center
background-repeat no-repeat
opacity 0.3
> .left
margin 0 auto 0 0
height 48px
> .right
margin 0 0 0 auto
height 48px
> *
display inline-block
vertical-align top
@media (max-width 1100px)
> .mk-ui-header-search
display none
</style>

View File

@ -0,0 +1,37 @@
<template>
<div>
<x-header/>
<div class="content">
<slot></slot>
</div>
<mk-stream-indicator v-if="os.isSignedIn"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XHeader from './ui.header.vue';
export default Vue.extend({
components: {
XHeader
},
mounted() {
document.addEventListener('keydown', this.onKeydown);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onKeydown);
},
methods: {
onKeydown(e) {
if (e.target.tagName == 'INPUT' || e.target.tagName == 'TEXTAREA') return;
if (e.which == 80 || e.which == 78) { // p or n
e.preventDefault();
(this as any).apis.post();
}
}
}
});
</script>

View File

@ -0,0 +1,173 @@
<template>
<div class="mk-user-preview">
<template v-if="u != null">
<div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl}?thumbnail&size=512)` : ''"></div>
<router-link class="avatar" :to="`/@${acct}`">
<img :src="`${u.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
</router-link>
<div class="title">
<router-link class="name" :to="`/@${acct}`">{{ u.name }}</router-link>
<p class="username">@{{ acct }}</p>
</div>
<div class="description">{{ u.description }}</div>
<div class="status">
<div>
<p>投稿</p><a>{{ u.postsCount }}</a>
</div>
<div>
<p>フォロー</p><a>{{ u.followingCount }}</a>
</div>
<div>
<p>フォロワー</p><a>{{ u.followersCount }}</a>
</div>
</div>
<mk-follow-button v-if="os.isSignedIn && user.id != os.i.id" :user="u"/>
</template>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as anime from 'animejs';
import getAcct from '../../../../../common/user/get-acct';
import parseAcct from '../../../../../common/user/parse-acct';
export default Vue.extend({
props: {
user: {
type: [Object, String],
required: true
}
},
computed: {
acct() {
return getAcct(this.u);
}
},
data() {
return {
u: null
};
},
mounted() {
if (typeof this.user == 'object') {
this.u = this.user;
this.$nextTick(() => {
this.open();
});
} else {
const query = this.user[0] == '@' ?
parseAcct(this.user[0].substr(1)) :
{ userId: this.user[0] };
(this as any).api('users/show', query).then(user => {
this.u = user;
this.open();
});
}
},
methods: {
open() {
anime({
targets: this.$el,
opacity: 1,
'margin-top': 0,
duration: 200,
easing: 'easeOutQuad'
});
},
close() {
anime({
targets: this.$el,
opacity: 0,
'margin-top': '-8px',
duration: 200,
easing: 'easeOutQuad',
complete: () => this.$destroy()
});
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-user-preview
position absolute
z-index 2048
margin-top -8px
width 250px
background #fff
background-clip content-box
border solid 1px rgba(0, 0, 0, 0.1)
border-radius 4px
overflow hidden
opacity 0
> .banner
height 84px
background-color #f5f5f5
background-size cover
background-position center
> .avatar
display block
position absolute
top 62px
left 13px
z-index 2
> img
display block
width 58px
height 58px
margin 0
border solid 3px #fff
border-radius 8px
> .title
display block
padding 8px 0 8px 82px
> .name
display inline-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
> .description
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
</style>

View File

@ -0,0 +1,107 @@
<template>
<div class="root item">
<router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="user.id">
<img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/>
</router-link>
<div class="main">
<header>
<router-link class="name" :to="`/@${acct}`" v-user-preview="user.id">{{ user.name }}</router-link>
<span class="username">@{{ acct }}</span>
</header>
<div class="body">
<p class="followed" v-if="user.isFollowed">フォローされています</p>
<div class="description">{{ user.description }}</div>
</div>
</div>
<mk-follow-button :user="user"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import getAcct from '../../../../../common/user/get-acct';
export default Vue.extend({
props: ['user'],
computed: {
acct() {
return getAcct(this.user);
}
}
});
</script>
<style lang="stylus" scoped>
.root.item
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
> .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
> .description
cursor default
display block
margin 0
padding 0
overflow-wrap break-word
font-size 1.1em
color #717171
> .mk-follow-button
position absolute
top 16px
right 16px
</style>

View File

@ -0,0 +1,143 @@
<template>
<div class="mk-users-list">
<nav>
<div>
<span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span>
<span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span>
</div>
</nav>
<div class="users" v-if="!fetching && users.length != 0">
<div v-for="u in users" :key="u.id">
<x-item :user="u"/>
</div>
</div>
<button class="more" v-if="!fetching && next != null" @click="more" :disabled="moreFetching">
<span v-if="!moreFetching">もっと</span>
<span v-if="moreFetching">読み込み中<mk-ellipsis/></span>
</button>
<p class="no" v-if="!fetching && users.length == 0">
<slot></slot>
</p>
<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XItem from './users-list.item.vue';
export default Vue.extend({
components: {
XItem
},
props: ['fetch', 'count', 'youKnowCount'],
data() {
return {
limit: 30,
mode: 'all',
fetching: true,
moreFetching: false,
users: [],
next: null
};
},
mounted() {
this._fetch(() => {
this.$emit('loaded');
});
},
methods: {
_fetch(cb) {
this.fetching = true;
this.fetch(this.mode == 'iknow', this.limit, null, obj => {
this.users = obj.users;
this.next = obj.next;
this.fetching = false;
if (cb) cb();
});
},
more() {
this.moreFetching = true;
this.fetch(this.mode == 'iknow', this.limit, this.next, obj => {
this.moreFetching = false;
this.users = this.users.concat(obj.users);
this.next = obj.next;
});
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-users-list
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
> [data-fa]
margin-right 4px
</style>

View File

@ -0,0 +1,85 @@
<template>
<div class="mk-widget-container" :class="{ naked }">
<header :class="{ withGradient }" v-if="showHeader">
<div class="title"><slot name="header"></slot></div>
<slot name="func"></slot>
</header>
<slot></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
showHeader: {
type: Boolean,
default: true
},
naked: {
type: Boolean,
default: false
}
},
computed: {
withGradient(): boolean {
return (this as any).os.isSignedIn
? (this as any).os.i.account.clientSettings.gradientWindowHeader != null
? (this as any).os.i.account.clientSettings.gradientWindowHeader
: false
: false;
}
}
});
</script>
<style lang="stylus" scoped>
.mk-widget-container
background #fff
border solid 1px rgba(0, 0, 0, 0.075)
border-radius 6px
overflow hidden
&.naked
background transparent !important
border none !important
> header
> .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)
> [data-fa]
margin-right 4px
&:empty
display none
> 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
&.withGradient
> .title
background linear-gradient(to bottom, #fff, #ececec)
box-shadow 0 1px rgba(#000, 0.11)
</style>

View File

@ -0,0 +1,635 @@
<template>
<div class="mk-window" :data-flexible="isFlexible" @dragover="onDragover">
<div class="bg" ref="bg" v-show="isModal" @click="onBgClick"></div>
<div class="main" ref="main" tabindex="-1" :data-is-modal="isModal" @mousedown="onBodyMousedown" @keydown="onKeydown" :style="{ width, height }">
<div class="body">
<header ref="header"
:class="{ withGradient }"
@contextmenu.prevent="() => {}" @mousedown.prevent="onHeaderMousedown"
>
<h1><slot name="header"></slot></h1>
<div>
<button class="popout" v-if="popoutUrl" @mousedown.stop="() => {}" @click="popout" title="ポップアウト">%fa:R window-restore%</button>
<button class="close" v-if="canClose" @mousedown.stop="() => {}" @click="close" title="閉じる">%fa:times%</button>
</div>
</header>
<div class="content">
<slot></slot>
</div>
</div>
<div class="handle top" v-if="canResize" @mousedown.prevent="onTopHandleMousedown"></div>
<div class="handle right" v-if="canResize" @mousedown.prevent="onRightHandleMousedown"></div>
<div class="handle bottom" v-if="canResize" @mousedown.prevent="onBottomHandleMousedown"></div>
<div class="handle left" v-if="canResize" @mousedown.prevent="onLeftHandleMousedown"></div>
<div class="handle top-left" v-if="canResize" @mousedown.prevent="onTopLeftHandleMousedown"></div>
<div class="handle top-right" v-if="canResize" @mousedown.prevent="onTopRightHandleMousedown"></div>
<div class="handle bottom-right" v-if="canResize" @mousedown.prevent="onBottomRightHandleMousedown"></div>
<div class="handle bottom-left" v-if="canResize" @mousedown.prevent="onBottomLeftHandleMousedown"></div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import * as anime from 'animejs';
import contains from '../../../common/scripts/contains';
const minHeight = 40;
const minWidth = 200;
function dragListen(fn) {
window.addEventListener('mousemove', fn);
window.addEventListener('mouseleave', dragClear.bind(null, fn));
window.addEventListener('mouseup', dragClear.bind(null, fn));
}
function dragClear(fn) {
window.removeEventListener('mousemove', fn);
window.removeEventListener('mouseleave', dragClear);
window.removeEventListener('mouseup', dragClear);
}
export default Vue.extend({
props: {
isModal: {
type: Boolean,
default: false
},
canClose: {
type: Boolean,
default: true
},
width: {
type: String,
default: '530px'
},
height: {
type: String,
default: 'auto'
},
popoutUrl: {
type: [String, Function],
default: null
},
name: {
type: String,
default: null
}
},
data() {
return {
preventMount: false
};
},
computed: {
isFlexible(): boolean {
return this.height == null;
},
canResize(): boolean {
return !this.isFlexible;
},
withGradient(): boolean {
return (this as any).os.isSignedIn
? (this as any).os.i.account.clientSettings.gradientWindowHeader != null
? (this as any).os.i.account.clientSettings.gradientWindowHeader
: false
: false;
}
},
created() {
if (localStorage.getItem('autoPopout') == 'true' && this.popoutUrl) {
this.popout();
this.preventMount = true;
} else {
// ウィンドウをウィンドウシステムに登録
(this as any).os.windows.add(this);
}
},
mounted() {
if (this.preventMount) {
this.$destroy();
return;
}
this.$nextTick(() => {
const main = this.$refs.main as any;
main.style.top = '15%';
main.style.left = (window.innerWidth / 2) - (main.offsetWidth / 2) + 'px';
window.addEventListener('resize', this.onBrowserResize);
this.open();
});
},
destroyed() {
// ウィンドウをウィンドウシステムから削除
(this as any).os.windows.remove(this);
window.removeEventListener('resize', this.onBrowserResize);
},
methods: {
open() {
this.$emit('opening');
this.top();
const bg = this.$refs.bg as any;
const main = this.$refs.main as any;
if (this.isModal) {
bg.style.pointerEvents = 'auto';
anime({
targets: bg,
opacity: 1,
duration: 100,
easing: 'linear'
});
}
main.style.pointerEvents = 'auto';
anime({
targets: main,
opacity: 1,
scale: [1.1, 1],
duration: 200,
easing: 'easeOutQuad'
});
if (focus) main.focus();
setTimeout(() => {
this.$emit('opened');
}, 300);
},
close() {
this.$emit('before-close');
const bg = this.$refs.bg as any;
const main = this.$refs.main as any;
if (this.isModal) {
bg.style.pointerEvents = 'none';
anime({
targets: bg,
opacity: 0,
duration: 300,
easing: 'linear'
});
}
main.style.pointerEvents = 'none';
anime({
targets: main,
opacity: 0,
scale: 0.8,
duration: 300,
easing: [0.5, -0.5, 1, 0.5]
});
setTimeout(() => {
this.$destroy();
this.$emit('closed');
}, 300);
},
popout() {
const url = typeof this.popoutUrl == 'function' ? this.popoutUrl() : this.popoutUrl;
const main = this.$refs.main as any;
if (main) {
const position = main.getBoundingClientRect();
const width = parseInt(getComputedStyle(main, '').width, 10);
const height = parseInt(getComputedStyle(main, '').height, 10);
const x = window.screenX + position.left;
const y = window.screenY + position.top;
window.open(url, url,
`width=${width}, height=${height}, top=${y}, left=${x}`);
this.close();
} else {
const x = window.top.outerHeight / 2 + window.top.screenY - (parseInt(this.height, 10) / 2);
const y = window.top.outerWidth / 2 + window.top.screenX - (parseInt(this.width, 10) / 2);
window.open(url, url,
`width=${this.width}, height=${this.height}, top=${x}, left=${y}`);
}
},
// 最前面へ移動
top() {
let z = 0;
(this as any).os.windows.getAll().forEach(w => {
if (w == this) return;
const m = w.$refs.main;
const mz = Number(document.defaultView.getComputedStyle(m, null).zIndex);
if (mz > z) z = mz;
});
if (z > 0) {
(this.$refs.main as any).style.zIndex = z + 1;
if (this.isModal) (this.$refs.bg as any).style.zIndex = z + 1;
}
},
onBgClick() {
if (this.canClose) this.close();
},
onBodyMousedown() {
this.top();
},
onHeaderMousedown(e) {
const main = this.$refs.main as any;
if (!contains(main, document.activeElement)) main.focus();
const position = main.getBoundingClientRect();
const clickX = e.clientX;
const clickY = e.clientY;
const moveBaseX = clickX - position.left;
const moveBaseY = clickY - position.top;
const browserWidth = window.innerWidth;
const browserHeight = window.innerHeight;
const windowWidth = main.offsetWidth;
const windowHeight = main.offsetHeight;
// 動かした時
dragListen(me => {
let moveLeft = me.clientX - moveBaseX;
let moveTop = me.clientY - moveBaseY;
// 上はみ出し
if (moveTop < 0) moveTop = 0;
// 左はみ出し
if (moveLeft < 0) moveLeft = 0;
// 下はみ出し
if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight;
// 右はみ出し
if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth;
main.style.left = moveLeft + 'px';
main.style.top = moveTop + 'px';
});
},
// 上ハンドル掴み時
onTopHandleMousedown(e) {
const main = this.$refs.main as any;
const base = e.clientY;
const height = parseInt(getComputedStyle(main, '').height, 10);
const top = parseInt(getComputedStyle(main, '').top, 10);
// 動かした時
dragListen(me => {
const move = me.clientY - base;
if (top + move > 0) {
if (height + -move > minHeight) {
this.applyTransformHeight(height + -move);
this.applyTransformTop(top + move);
} else { // 最小の高さより小さくなろうとした時
this.applyTransformHeight(minHeight);
this.applyTransformTop(top + (height - minHeight));
}
} else { // 上のはみ出し時
this.applyTransformHeight(top + height);
this.applyTransformTop(0);
}
});
},
// 右ハンドル掴み時
onRightHandleMousedown(e) {
const main = this.$refs.main as any;
const base = e.clientX;
const width = parseInt(getComputedStyle(main, '').width, 10);
const left = parseInt(getComputedStyle(main, '').left, 10);
const browserWidth = window.innerWidth;
// 動かした時
dragListen(me => {
const move = me.clientX - base;
if (left + width + move < browserWidth) {
if (width + move > minWidth) {
this.applyTransformWidth(width + move);
} else { // 最小の幅より小さくなろうとした時
this.applyTransformWidth(minWidth);
}
} else { // 右のはみ出し時
this.applyTransformWidth(browserWidth - left);
}
});
},
// 下ハンドル掴み時
onBottomHandleMousedown(e) {
const main = this.$refs.main as any;
const base = e.clientY;
const height = parseInt(getComputedStyle(main, '').height, 10);
const top = parseInt(getComputedStyle(main, '').top, 10);
const browserHeight = window.innerHeight;
// 動かした時
dragListen(me => {
const move = me.clientY - base;
if (top + height + move < browserHeight) {
if (height + move > minHeight) {
this.applyTransformHeight(height + move);
} else { // 最小の高さより小さくなろうとした時
this.applyTransformHeight(minHeight);
}
} else { // 下のはみ出し時
this.applyTransformHeight(browserHeight - top);
}
});
},
// 左ハンドル掴み時
onLeftHandleMousedown(e) {
const main = this.$refs.main as any;
const base = e.clientX;
const width = parseInt(getComputedStyle(main, '').width, 10);
const left = parseInt(getComputedStyle(main, '').left, 10);
// 動かした時
dragListen(me => {
const move = me.clientX - base;
if (left + move > 0) {
if (width + -move > minWidth) {
this.applyTransformWidth(width + -move);
this.applyTransformLeft(left + move);
} else { // 最小の幅より小さくなろうとした時
this.applyTransformWidth(minWidth);
this.applyTransformLeft(left + (width - minWidth));
}
} else { // 左のはみ出し時
this.applyTransformWidth(left + width);
this.applyTransformLeft(0);
}
});
},
// 左上ハンドル掴み時
onTopLeftHandleMousedown(e) {
this.onTopHandleMousedown(e);
this.onLeftHandleMousedown(e);
},
// 右上ハンドル掴み時
onTopRightHandleMousedown(e) {
this.onTopHandleMousedown(e);
this.onRightHandleMousedown(e);
},
// 右下ハンドル掴み時
onBottomRightHandleMousedown(e) {
this.onBottomHandleMousedown(e);
this.onRightHandleMousedown(e);
},
// 左下ハンドル掴み時
onBottomLeftHandleMousedown(e) {
this.onBottomHandleMousedown(e);
this.onLeftHandleMousedown(e);
},
// 高さを適用
applyTransformHeight(height) {
(this.$refs.main as any).style.height = height + 'px';
},
// 幅を適用
applyTransformWidth(width) {
(this.$refs.main as any).style.width = width + 'px';
},
// Y座標を適用
applyTransformTop(top) {
(this.$refs.main as any).style.top = top + 'px';
},
// X座標を適用
applyTransformLeft(left) {
(this.$refs.main as any).style.left = left + 'px';
},
onDragover(e) {
e.dataTransfer.dropEffect = 'none';
},
onKeydown(e) {
if (e.which == 27) { // Esc
if (this.canClose) {
e.preventDefault();
e.stopPropagation();
this.close();
}
}
},
onBrowserResize() {
const main = this.$refs.main as any;
const position = main.getBoundingClientRect();
const browserWidth = window.innerWidth;
const browserHeight = window.innerHeight;
const windowWidth = main.offsetWidth;
const windowHeight = main.offsetHeight;
if (position.left < 0) main.style.left = 0;
if (position.top < 0) main.style.top = 0;
if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px';
if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px';
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.mk-window
display block
> .bg
display block
position fixed
z-index 2000
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 2000
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
$header-height = 40px
z-index 1001
height $header-height
overflow hidden
white-space nowrap
cursor move
background #fff
border-radius 6px 6px 0 0
box-shadow 0 1px 0 rgba(#000, 0.1)
&.withGradient
background linear-gradient(to bottom, #fff, #ececec)
box-shadow 0 1px 0 rgba(#000, 0.15)
&, *
user-select none
> h1
pointer-events none
display block
margin 0 auto
overflow hidden
height $header-height
text-overflow ellipsis
text-align center
font-size 1em
line-height $header-height
font-weight normal
color #666
> div:last-child
position absolute
top 0
right 0
display block
z-index 1
> *
display inline-block
margin 0
padding 0
cursor pointer
font-size 1em
color rgba(#000, 0.4)
border none
outline none
background transparent
&:hover
color rgba(#000, 0.6)
&:active
color darken(#000, 30%)
> [data-fa]
padding 0
width $header-height
line-height $header-height
text-align center
> .content
height 100%
&:not([flexible])
> .main > .body > .content
height calc(100% - 40px)
</style>