v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com> Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
This commit is contained in:
206
src/client/widgets/calendar.vue
Normal file
206
src/client/widgets/calendar.vue
Normal file
@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="mkw-calendar" :class="{ _panel: props.design === 0 }">
|
||||
<div class="calendar" :data-is-holiday="isHoliday">
|
||||
<p class="month-and-year">
|
||||
<span class="year">{{ $t('yearX', { year }) }}</span>
|
||||
<span class="month">{{ $t('monthX', { month }) }}</span>
|
||||
</p>
|
||||
<p class="day">{{ $t('dayX', { day }) }}</p>
|
||||
<p class="week-day">{{ weekDay }}</p>
|
||||
</div>
|
||||
<div class="info">
|
||||
<div>
|
||||
<p>{{ $t('today') }}: <b>{{ dayP.toFixed(1) }}%</b></p>
|
||||
<div class="meter">
|
||||
<div class="val" :style="{ width: `${dayP}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>{{ $t('thisMonth') }}: <b>{{ monthP.toFixed(1) }}%</b></p>
|
||||
<div class="meter">
|
||||
<div class="val" :style="{ width: `${monthP}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>{{ $t('thisYear') }}: <b>{{ yearP.toFixed(1) }}%</b></p>
|
||||
<div class="meter">
|
||||
<div class="val" :style="{ width: `${yearP}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import define from './define';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default define({
|
||||
name: 'calendar',
|
||||
props: () => ({
|
||||
design: 0
|
||||
})
|
||||
}).extend({
|
||||
i18n,
|
||||
data() {
|
||||
return {
|
||||
now: new Date(),
|
||||
year: null,
|
||||
month: null,
|
||||
day: null,
|
||||
weekDay: null,
|
||||
yearP: null,
|
||||
dayP: null,
|
||||
monthP: null,
|
||||
isHoliday: null,
|
||||
clock: null
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.tick();
|
||||
this.clock = setInterval(this.tick, 1000);
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.clock);
|
||||
},
|
||||
methods: {
|
||||
func() {
|
||||
if (this.props.design == 2) {
|
||||
this.props.design = 0;
|
||||
} else {
|
||||
this.props.design++;
|
||||
}
|
||||
this.save();
|
||||
},
|
||||
tick() {
|
||||
const now = new Date();
|
||||
const nd = now.getDate();
|
||||
const nm = now.getMonth();
|
||||
const ny = now.getFullYear();
|
||||
|
||||
this.year = ny;
|
||||
this.month = nm + 1;
|
||||
this.day = nd;
|
||||
this.weekDay = [
|
||||
this.$t('_weekday.sunday'),
|
||||
this.$t('_weekday.monday'),
|
||||
this.$t('_weekday.tuesday'),
|
||||
this.$t('_weekday.wednesday'),
|
||||
this.$t('_weekday.thursday'),
|
||||
this.$t('_weekday.friday'),
|
||||
this.$t('_weekday.saturday')
|
||||
][now.getDay()];
|
||||
|
||||
const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime();
|
||||
const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
|
||||
const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
|
||||
const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
|
||||
const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime();
|
||||
const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
|
||||
|
||||
this.dayP = dayNumer / dayDenom * 100;
|
||||
this.monthP = monthNumer / monthDenom * 100;
|
||||
this.yearP = yearNumer / yearDenom * 100;
|
||||
|
||||
this.isHoliday = now.getDay() == 0 || now.getDay() == 6;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mkw-calendar {
|
||||
padding: 16px 0;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
> .calendar {
|
||||
float: left;
|
||||
width: 60%;
|
||||
text-align: center;
|
||||
|
||||
&[data-is-holiday] {
|
||||
> .day {
|
||||
color: #ef95a0;
|
||||
}
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
line-height: 18px;
|
||||
font-size: 14px;
|
||||
|
||||
> span {
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .day {
|
||||
margin: 10px 0;
|
||||
line-height: 32px;
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
> .info {
|
||||
display: block;
|
||||
float: left;
|
||||
width: 40%;
|
||||
padding: 0 16px 0 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
> div {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0 0 2px 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
opacity: 0.8;
|
||||
|
||||
> b {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
> .meter {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--aupeazdm);
|
||||
border-radius: 8px;
|
||||
|
||||
> .val {
|
||||
height: 4px;
|
||||
transition: width .3s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(1) {
|
||||
> .meter > .val {
|
||||
background: #f7796c;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
> .meter > .val {
|
||||
background: #a1de41;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
> .meter > .val {
|
||||
background: #41ddde;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
58
src/client/widgets/define.ts
Normal file
58
src/client/widgets/define.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
export default function <T extends object>(data: {
|
||||
name: string;
|
||||
props?: () => T;
|
||||
}) {
|
||||
return Vue.extend({
|
||||
props: {
|
||||
widget: {
|
||||
type: Object
|
||||
},
|
||||
isCustomizeMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
id(): string {
|
||||
return this.widget.id;
|
||||
},
|
||||
|
||||
props(): T {
|
||||
return this.widget.data;
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
bakedOldProps: null
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.mergeProps();
|
||||
|
||||
this.$watch('props', () => {
|
||||
this.mergeProps();
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
mergeProps() {
|
||||
if (data.props) {
|
||||
const defaultProps = data.props();
|
||||
for (const prop of Object.keys(defaultProps)) {
|
||||
if (this.props.hasOwnProperty(prop)) continue;
|
||||
Vue.set(this.props, prop, defaultProps[prop]);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
save() {
|
||||
this.$store.dispatch('settings/updateWidget', this.widget);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
8
src/client/widgets/index.ts
Normal file
8
src/client/widgets/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
Vue.component('mkw-memo', () => import('./memo.vue').then(m => m.default));
|
||||
Vue.component('mkw-notifications', () => import('./notifications.vue').then(m => m.default));
|
||||
Vue.component('mkw-timeline', () => import('./timeline.vue').then(m => m.default));
|
||||
Vue.component('mkw-calendar', () => import('./calendar.vue').then(m => m.default));
|
||||
Vue.component('mkw-rss', () => import('./rss.vue').then(m => m.default));
|
||||
Vue.component('mkw-trends', () => import('./trends.vue').then(m => m.default));
|
120
src/client/widgets/memo.vue
Normal file
120
src/client/widgets/memo.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div>
|
||||
<mk-container :show-header="!props.compact">
|
||||
<template #header><fa :icon="faStickyNote"/>{{ $t('_widgets.memo') }}</template>
|
||||
|
||||
<div class="otgbylcu">
|
||||
<textarea v-model="text" :placeholder="$t('placeholder')" @input="onChange"></textarea>
|
||||
<button @click="saveMemo" :disabled="!changed">{{ $t('save') }}</button>
|
||||
</div>
|
||||
</mk-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { faStickyNote } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkContainer from '../components/ui/container.vue';
|
||||
import define from './define';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default define({
|
||||
name: 'memo',
|
||||
props: () => ({
|
||||
compact: false
|
||||
})
|
||||
}).extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkContainer
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
text: null,
|
||||
changed: false,
|
||||
timeoutId: null,
|
||||
faStickyNote
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.text = this.$store.state.settings.memo;
|
||||
|
||||
this.$watch('$store.state.settings.memo', text => {
|
||||
this.text = text;
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
func() {
|
||||
this.props.compact = !this.props.compact;
|
||||
this.save();
|
||||
},
|
||||
|
||||
onChange() {
|
||||
this.changed = true;
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = setTimeout(this.saveMemo, 1000);
|
||||
},
|
||||
|
||||
saveMemo() {
|
||||
this.$store.dispatch('settings/set', {
|
||||
key: 'memo',
|
||||
value: this.text
|
||||
});
|
||||
this.changed = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.otgbylcu {
|
||||
padding-bottom: 28px + 16px;
|
||||
|
||||
> textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
padding: 16px;
|
||||
color: var(--inputText);
|
||||
background: var(--face);
|
||||
border: none;
|
||||
border-bottom: solid var(--lineWidth) var(--faceDivider);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
> button {
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
height: 28px;
|
||||
color: #fff;
|
||||
background: var(--accent) !important;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten10) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken) !important;
|
||||
transition: background 0s ease;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
46
src/client/widgets/notifications.vue
Normal file
46
src/client/widgets/notifications.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="mkw-notifications">
|
||||
<mk-container :show-header="!props.compact">
|
||||
<template #header><fa :icon="faBell"/>{{ $t('notifications') }}</template>
|
||||
|
||||
<div style="height: 300px; overflow: auto; background: var(--bg);">
|
||||
<x-notifications/>
|
||||
</div>
|
||||
</mk-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { faBell } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkContainer from '../components/ui/container.vue';
|
||||
import XNotifications from '../components/notifications.vue';
|
||||
import define from './define';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default define({
|
||||
name: 'notifications',
|
||||
props: () => ({
|
||||
compact: false
|
||||
})
|
||||
}).extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkContainer,
|
||||
XNotifications,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
faBell
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
func() {
|
||||
this.props.compact = !this.props.compact;
|
||||
this.save();
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
101
src/client/widgets/rss.vue
Normal file
101
src/client/widgets/rss.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div>
|
||||
<mk-container :show-header="!props.compact">
|
||||
<template #header><fa :icon="faRssSquare"/>RSS</template>
|
||||
<template #func><button class="_button" @click="setting"><fa :icon="faCog"/></button></template>
|
||||
|
||||
<div class="ekmkgxbj">
|
||||
<mk-loading v-if="fetching"/>
|
||||
<div class="feed" v-else>
|
||||
<a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</mk-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { faRssSquare, faCog } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkContainer from '../components/ui/container.vue';
|
||||
import define from './define';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default define({
|
||||
name: 'rss',
|
||||
props: () => ({
|
||||
compact: false,
|
||||
url: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'
|
||||
})
|
||||
}).extend({
|
||||
i18n,
|
||||
components: {
|
||||
MkContainer
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
items: [],
|
||||
fetching: true,
|
||||
clock: null,
|
||||
faRssSquare, faCog
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.fetch();
|
||||
this.clock = setInterval(this.fetch, 60000);
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.clock);
|
||||
},
|
||||
methods: {
|
||||
func() {
|
||||
this.props.compact = !this.props.compact;
|
||||
this.save();
|
||||
},
|
||||
fetch() {
|
||||
fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, {
|
||||
}).then(res => {
|
||||
res.json().then(feed => {
|
||||
this.items = feed.items;
|
||||
this.fetching = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
setting() {
|
||||
this.$root.dialog({
|
||||
title: 'URL',
|
||||
input: {
|
||||
type: 'url',
|
||||
default: this.props.url
|
||||
}
|
||||
}).then(({ canceled, result: url }) => {
|
||||
if (canceled) return;
|
||||
this.props.url = url;
|
||||
this.save();
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ekmkgxbj {
|
||||
> .feed {
|
||||
padding: 0;
|
||||
font-size: 0.9em;
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: rgba(#000, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
113
src/client/widgets/timeline.vue
Normal file
113
src/client/widgets/timeline.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="mkw-timeline">
|
||||
<mk-container :show-header="!props.compact">
|
||||
<template #header>
|
||||
<button @click="choose" class="_button">
|
||||
<fa v-if="props.src === 'home'" :icon="faHome"/>
|
||||
<fa v-if="props.src === 'local'" :icon="faComments"/>
|
||||
<fa v-if="props.src === 'social'" :icon="faShareAlt"/>
|
||||
<fa v-if="props.src === 'global'" :icon="faGlobe"/>
|
||||
<fa v-if="props.src === 'list'" :icon="faListUl"/>
|
||||
<fa v-if="props.src === 'antenna'" :icon="faSatellite"/>
|
||||
<span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span>
|
||||
<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div style="height: 300px; padding: 8px; overflow: auto; background: var(--bg);">
|
||||
<x-timeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list" :antenna="props.antenna"/>
|
||||
</div>
|
||||
</mk-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faComments } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkContainer from '../components/ui/container.vue';
|
||||
import XTimeline from '../components/timeline.vue';
|
||||
import define from './define';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default define({
|
||||
name: 'timeline',
|
||||
props: () => ({
|
||||
src: 'home',
|
||||
list: null,
|
||||
compact: false
|
||||
})
|
||||
}).extend({
|
||||
i18n,
|
||||
|
||||
components: {
|
||||
MkContainer,
|
||||
XTimeline,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
menuOpened: false,
|
||||
faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
func() {
|
||||
this.props.compact = !this.props.compact;
|
||||
this.save();
|
||||
},
|
||||
|
||||
async choose(ev) {
|
||||
this.menuOpened = true;
|
||||
const [antennas, lists] = await Promise.all([
|
||||
this.$root.api('antennas/list'),
|
||||
this.$root.api('users/lists/list')
|
||||
]);
|
||||
const antennaItems = antennas.map(antenna => ({
|
||||
text: antenna.name,
|
||||
icon: faSatellite,
|
||||
action: () => {
|
||||
this.props.antenna = antenna;
|
||||
this.setSrc('antenna');
|
||||
}
|
||||
}));
|
||||
const listItems = lists.map(list => ({
|
||||
text: list.name,
|
||||
icon: faListUl,
|
||||
action: () => {
|
||||
this.props.list = list;
|
||||
this.setSrc('list');
|
||||
}
|
||||
}));
|
||||
this.$root.menu({
|
||||
items: [{
|
||||
text: this.$t('_timelines.home'),
|
||||
icon: faHome,
|
||||
action: () => { this.setSrc('home') }
|
||||
}, {
|
||||
text: this.$t('_timelines.local'),
|
||||
icon: faComments,
|
||||
action: () => { this.setSrc('local') }
|
||||
}, {
|
||||
text: this.$t('_timelines.social'),
|
||||
icon: faShareAlt,
|
||||
action: () => { this.setSrc('social') }
|
||||
}, {
|
||||
text: this.$t('_timelines.global'),
|
||||
icon: faGlobe,
|
||||
action: () => { this.setSrc('global') }
|
||||
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems],
|
||||
noCenter: true,
|
||||
source: ev.currentTarget || ev.target
|
||||
}).then(() => {
|
||||
this.menuOpened = false;
|
||||
});
|
||||
},
|
||||
|
||||
setSrc(src) {
|
||||
this.props.src = src;
|
||||
this.save();
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
89
src/client/widgets/trends.chart.vue
Normal file
89
src/client/widgets/trends.chart.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible">
|
||||
<defs>
|
||||
<linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0">
|
||||
<stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop>
|
||||
<stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop>
|
||||
</linearGradient>
|
||||
<mask :id="maskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
|
||||
<polygon
|
||||
:points="polygonPoints"
|
||||
fill="#fff"
|
||||
fill-opacity="0.5"/>
|
||||
<polyline
|
||||
:points="polylinePoints"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
stroke-width="2"/>
|
||||
<circle
|
||||
:cx="headX"
|
||||
:cy="headY"
|
||||
r="3"
|
||||
fill="#fff"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
x="-10" y="-10"
|
||||
:width="viewBoxX + 20" :height="viewBoxY + 20"
|
||||
:style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
src: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
viewBoxX: 50,
|
||||
viewBoxY: 30,
|
||||
gradientId: uuid(),
|
||||
maskId: uuid(),
|
||||
polylinePoints: '',
|
||||
polygonPoints: '',
|
||||
headX: null,
|
||||
headY: null,
|
||||
clock: null
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
src() {
|
||||
this.draw();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.draw();
|
||||
|
||||
// Vueが何故かWatchを発動させない場合があるので
|
||||
this.clock = setInterval(this.draw, 1000);
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.clock);
|
||||
},
|
||||
methods: {
|
||||
draw() {
|
||||
const stats = this.src.slice().reverse();
|
||||
const peak = Math.max.apply(null, stats) || 1;
|
||||
|
||||
const polylinePoints = stats.map((n, i) => [
|
||||
i * (this.viewBoxX / (stats.length - 1)),
|
||||
(1 - (n / peak)) * this.viewBoxY
|
||||
]);
|
||||
|
||||
this.polylinePoints = polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
|
||||
|
||||
this.polygonPoints = `0,${ this.viewBoxY } ${ this.polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`;
|
||||
|
||||
this.headX = polylinePoints[polylinePoints.length - 1][0];
|
||||
this.headY = polylinePoints[polylinePoints.length - 1][1];
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
124
src/client/widgets/trends.vue
Normal file
124
src/client/widgets/trends.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<mk-container :show-header="!props.compact">
|
||||
<template #header><fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template>
|
||||
|
||||
<div class="wbrkwala">
|
||||
<transition-group tag="div" name="chart">
|
||||
<div v-for="stat in stats" :key="stat.tag">
|
||||
<div class="tag">
|
||||
<router-link :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link>
|
||||
<p>{{ $t('count').replace('{}', stat.usersCount) }}</p>
|
||||
</div>
|
||||
<x-chart class="chart" :src="stat.chart"/>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</mk-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { faHashtag } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkContainer from '../components/ui/container.vue';
|
||||
import define from './define';
|
||||
import i18n from '../i18n';
|
||||
import XChart from './trends.chart.vue';
|
||||
|
||||
export default define({
|
||||
name: 'hashtags',
|
||||
props: () => ({
|
||||
compact: false
|
||||
})
|
||||
}).extend({
|
||||
i18n,
|
||||
components: {
|
||||
MkContainer, XChart
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
stats: [],
|
||||
fetching: true,
|
||||
faHashtag
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.fetch();
|
||||
this.clock = setInterval(this.fetch, 1000 * 60);
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.clock);
|
||||
},
|
||||
methods: {
|
||||
func() {
|
||||
this.props.compact = !this.props.compact;
|
||||
this.save();
|
||||
},
|
||||
fetch() {
|
||||
this.$root.api('hashtags/trend').then(stats => {
|
||||
this.stats = stats;
|
||||
this.fetching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wbrkwala {
|
||||
> .fetching,
|
||||
> .empty {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
opacity: 0.7;
|
||||
|
||||
> [data-icon] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
.chart-move {
|
||||
transition: transform 1s ease;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: solid 1px var(--divider);
|
||||
}
|
||||
|
||||
> .tag {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
color: var(--fg);
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
font-size: 75%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
> .chart {
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user