整理した
This commit is contained in:
@ -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>
|
103
src/client/app/desktop/views/components/activity.chart.vue
Normal file
103
src/client/app/desktop/views/components/activity.chart.vue
Normal 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>
|
116
src/client/app/desktop/views/components/activity.vue
Normal file
116
src/client/app/desktop/views/components/activity.vue
Normal 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>
|
108
src/client/app/desktop/views/components/analog-clock.vue
Normal file
108
src/client/app/desktop/views/components/analog-clock.vue
Normal 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>
|
252
src/client/app/desktop/views/components/calendar.vue
Normal file
252
src/client/app/desktop/views/components/calendar.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
121
src/client/app/desktop/views/components/context-menu.menu.vue
Normal file
121
src/client/app/desktop/views/components/context-menu.menu.vue
Normal 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>
|
||||
|
74
src/client/app/desktop/views/components/context-menu.vue
Normal file
74
src/client/app/desktop/views/components/context-menu.vue
Normal 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>
|
178
src/client/app/desktop/views/components/crop-window.vue
Normal file
178
src/client/app/desktop/views/components/crop-window.vue
Normal 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>
|
170
src/client/app/desktop/views/components/dialog.vue
Normal file
170
src/client/app/desktop/views/components/dialog.vue
Normal 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>
|
56
src/client/app/desktop/views/components/drive-window.vue
Normal file
56
src/client/app/desktop/views/components/drive-window.vue
Normal 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>
|
||||
|
317
src/client/app/desktop/views/components/drive.file.vue
Normal file
317
src/client/app/desktop/views/components/drive.file.vue
Normal 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>
|
267
src/client/app/desktop/views/components/drive.folder.vue
Normal file
267
src/client/app/desktop/views/components/drive.folder.vue
Normal 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>
|
113
src/client/app/desktop/views/components/drive.nav-folder.vue
Normal file
113
src/client/app/desktop/views/components/drive.nav-folder.vue
Normal 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>
|
773
src/client/app/desktop/views/components/drive.vue
Normal file
773
src/client/app/desktop/views/components/drive.vue
Normal 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=" %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>
|
37
src/client/app/desktop/views/components/ellipsis-icon.vue
Normal file
37
src/client/app/desktop/views/components/ellipsis-icon.vue
Normal 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>
|
164
src/client/app/desktop/views/components/follow-button.vue
Normal file
164
src/client/app/desktop/views/components/follow-button.vue
Normal 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>
|
26
src/client/app/desktop/views/components/followers-window.vue
Normal file
26
src/client/app/desktop/views/components/followers-window.vue
Normal 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>
|
26
src/client/app/desktop/views/components/followers.vue
Normal file
26
src/client/app/desktop/views/components/followers.vue
Normal 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>
|
26
src/client/app/desktop/views/components/following-window.vue
Normal file
26
src/client/app/desktop/views/components/following-window.vue
Normal 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>
|
26
src/client/app/desktop/views/components/following.vue
Normal file
26
src/client/app/desktop/views/components/following.vue
Normal 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>
|
171
src/client/app/desktop/views/components/friends-maker.vue
Normal file
171
src/client/app/desktop/views/components/friends-maker.vue
Normal 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>
|
37
src/client/app/desktop/views/components/game-window.vue
Normal file
37
src/client/app/desktop/views/components/game-window.vue
Normal 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>
|
357
src/client/app/desktop/views/components/home.vue
Normal file
357
src/client/app/desktop/views/components/home.vue
Normal 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>
|
61
src/client/app/desktop/views/components/index.ts
Normal file
61
src/client/app/desktop/views/components/index.ts
Normal 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);
|
180
src/client/app/desktop/views/components/input-dialog.vue
Normal file
180
src/client/app/desktop/views/components/input-dialog.vue
Normal 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>
|
@ -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>
|
63
src/client/app/desktop/views/components/media-image.vue
Normal file
63
src/client/app/desktop/views/components/media-image.vue
Normal 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>
|
@ -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>
|
67
src/client/app/desktop/views/components/media-video.vue
Normal file
67
src/client/app/desktop/views/components/media-video.vue
Normal 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>
|
125
src/client/app/desktop/views/components/mentions.vue
Normal file
125
src/client/app/desktop/views/components/mentions.vue
Normal 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>
|
@ -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>
|
32
src/client/app/desktop/views/components/messaging-window.vue
Normal file
32
src/client/app/desktop/views/components/messaging-window.vue
Normal 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>
|
317
src/client/app/desktop/views/components/notifications.vue
Normal file
317
src/client/app/desktop/views/components/notifications.vue
Normal 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>
|
126
src/client/app/desktop/views/components/post-detail.sub.vue
Normal file
126
src/client/app/desktop/views/components/post-detail.sub.vue
Normal 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>
|
433
src/client/app/desktop/views/components/post-detail.vue
Normal file
433
src/client/app/desktop/views/components/post-detail.vue
Normal 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>
|
76
src/client/app/desktop/views/components/post-form-window.vue
Normal file
76
src/client/app/desktop/views/components/post-form-window.vue
Normal 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>
|
536
src/client/app/desktop/views/components/post-form.vue
Normal file
536
src/client/app/desktop/views/components/post-form.vue
Normal 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>
|
103
src/client/app/desktop/views/components/post-preview.vue
Normal file
103
src/client/app/desktop/views/components/post-preview.vue
Normal 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>
|
112
src/client/app/desktop/views/components/posts.post.sub.vue
Normal file
112
src/client/app/desktop/views/components/posts.post.sub.vue
Normal 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>
|
582
src/client/app/desktop/views/components/posts.post.vue
Normal file
582
src/client/app/desktop/views/components/posts.post.vue
Normal 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>
|
89
src/client/app/desktop/views/components/posts.vue
Normal file
89
src/client/app/desktop/views/components/posts.vue
Normal 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>
|
95
src/client/app/desktop/views/components/progress-dialog.vue
Normal file
95
src/client/app/desktop/views/components/progress-dialog.vue
Normal 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>
|
@ -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>
|
131
src/client/app/desktop/views/components/repost-form.vue
Normal file
131
src/client/app/desktop/views/components/repost-form.vue
Normal 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>
|
24
src/client/app/desktop/views/components/settings-window.vue
Normal file
24
src/client/app/desktop/views/components/settings-window.vue
Normal 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>
|
80
src/client/app/desktop/views/components/settings.2fa.vue
Normal file
80
src/client/app/desktop/views/components/settings.2fa.vue
Normal 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>
|
40
src/client/app/desktop/views/components/settings.api.vue
Normal file
40
src/client/app/desktop/views/components/settings.api.vue
Normal 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>
|
39
src/client/app/desktop/views/components/settings.apps.vue
Normal file
39
src/client/app/desktop/views/components/settings.apps.vue
Normal 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>
|
35
src/client/app/desktop/views/components/settings.drive.vue
Normal file
35
src/client/app/desktop/views/components/settings.drive.vue
Normal 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>
|
35
src/client/app/desktop/views/components/settings.mute.vue
Normal file
35
src/client/app/desktop/views/components/settings.mute.vue
Normal 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>
|
@ -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>
|
87
src/client/app/desktop/views/components/settings.profile.vue
Normal file
87
src/client/app/desktop/views/components/settings.profile.vue
Normal 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>
|
||||
|
98
src/client/app/desktop/views/components/settings.signins.vue
Normal file
98
src/client/app/desktop/views/components/settings.signins.vue
Normal 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>
|
419
src/client/app/desktop/views/components/settings.vue
Normal file
419
src/client/app/desktop/views/components/settings.vue
Normal 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>
|
56
src/client/app/desktop/views/components/sub-post-content.vue
Normal file
56
src/client/app/desktop/views/components/sub-post-content.vue
Normal 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>
|
219
src/client/app/desktop/views/components/taskmanager.vue
Normal file
219
src/client/app/desktop/views/components/taskmanager.vue
Normal 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>
|
156
src/client/app/desktop/views/components/timeline.vue
Normal file
156
src/client/app/desktop/views/components/timeline.vue
Normal 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>
|
61
src/client/app/desktop/views/components/ui-notification.vue
Normal file
61
src/client/app/desktop/views/components/ui-notification.vue
Normal 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>
|
225
src/client/app/desktop/views/components/ui.header.account.vue
Normal file
225
src/client/app/desktop/views/components/ui.header.account.vue
Normal 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>
|
109
src/client/app/desktop/views/components/ui.header.clock.vue
Normal file
109
src/client/app/desktop/views/components/ui.header.clock.vue
Normal 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>
|
175
src/client/app/desktop/views/components/ui.header.nav.vue
Normal file
175
src/client/app/desktop/views/components/ui.header.nav.vue
Normal 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>
|
@ -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>
|
54
src/client/app/desktop/views/components/ui.header.post.vue
Normal file
54
src/client/app/desktop/views/components/ui.header.post.vue
Normal 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>
|
70
src/client/app/desktop/views/components/ui.header.search.vue
Normal file
70
src/client/app/desktop/views/components/ui.header.search.vue
Normal 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>
|
172
src/client/app/desktop/views/components/ui.header.vue
Normal file
172
src/client/app/desktop/views/components/ui.header.vue
Normal 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>
|
37
src/client/app/desktop/views/components/ui.vue
Normal file
37
src/client/app/desktop/views/components/ui.vue
Normal 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>
|
||||
|
173
src/client/app/desktop/views/components/user-preview.vue
Normal file
173
src/client/app/desktop/views/components/user-preview.vue
Normal 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>
|
107
src/client/app/desktop/views/components/users-list.item.vue
Normal file
107
src/client/app/desktop/views/components/users-list.item.vue
Normal 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>
|
143
src/client/app/desktop/views/components/users-list.vue
Normal file
143
src/client/app/desktop/views/components/users-list.vue
Normal 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>
|
85
src/client/app/desktop/views/components/widget-container.vue
Normal file
85
src/client/app/desktop/views/components/widget-container.vue
Normal 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>
|
635
src/client/app/desktop/views/components/window.vue
Normal file
635
src/client/app/desktop/views/components/window.vue
Normal 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>
|
Reference in New Issue
Block a user