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:
syuilo
2020-01-30 04:37:25 +09:00
committed by GitHub
parent a5955c1123
commit f6154dc0af
871 changed files with 26140 additions and 71950 deletions

View File

@ -1,3 +0,0 @@
declare module '*/const.json' {
const copyright: string;
}

View File

@ -77,7 +77,6 @@ export async function masterMain() {
if (!program.noDaemons) {
require('../daemons/server-stats').default();
require('../daemons/notes-stats').default();
require('../daemons/queue-stats').default();
require('../daemons/janitor').default();
}

1105
src/client/app.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,150 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="512"
height="512"
viewBox="0 0 135.46667 135.46667"
version="1.1"
id="svg8"
inkscape:version="0.92.1 r15371"
sodipodi:docname="header-icon.dark.svg"
inkscape:export-filename="C:\Users\syuilo\projects\misskey\assets\favicon\32.png"
inkscape:export-xdpi="6"
inkscape:export-ydpi="6">
<defs
id="defs2">
<inkscape:path-effect
effect="simplify"
id="path-effect5115"
is_visible="true"
steps="1"
threshold="0.000408163"
smooth_angles="360"
helper_size="0"
simplify_individual_paths="false"
simplify_just_coalesce="false"
simplifyindividualpaths="false"
simplifyJustCoalesce="false" />
<inkscape:path-effect
effect="simplify"
id="path-effect5111"
is_visible="true"
steps="1"
threshold="0.000408163"
smooth_angles="360"
helper_size="0"
simplify_individual_paths="false"
simplify_just_coalesce="false"
simplifyindividualpaths="false"
simplifyJustCoalesce="false" />
<inkscape:path-effect
effect="simplify"
id="path-effect5104"
is_visible="true"
steps="1"
threshold="0.000408163"
smooth_angles="360"
helper_size="0"
simplify_individual_paths="false"
simplify_just_coalesce="false"
simplifyindividualpaths="false"
simplifyJustCoalesce="false" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4142136"
inkscape:cx="114.309"
inkscape:cy="251.50613"
inkscape:document-units="px"
inkscape:current-layer="g4502"
showgrid="true"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="false"
inkscape:snap-smooth-nodes="true"
inkscape:snap-center="true"
inkscape:snap-page="true"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="-8"
inkscape:window-y="1072"
inkscape:window-maximized="1"
inkscape:snap-object-midpoints="true"
inkscape:snap-midpoints="true"
inkscape:object-paths="true"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
objecttolerance="1"
guidetolerance="1"
inkscape:snap-nodes="false"
inkscape:snap-others="false">
<inkscape:grid
type="xygrid"
id="grid4504"
spacingx="4.2333334"
spacingy="4.2333334"
empcolor="#ff3fff"
empopacity="0.25098039"
empspacing="4" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="レイヤー 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-30.809093,-111.78601)">
<g
id="g4502"
transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)">
<g
style="fill-opacity:1"
transform="translate(-1.3333333e-6,-1.3439941e-6)"
id="g5125">
<g
transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)"
id="text4489"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
aria-label="Mi">
<path
sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz"
inkscape:connector-curvature="0"
id="path5210"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px"
d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" />
<path
inkscape:connector-curvature="0"
id="path5212"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px"
d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" />
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -1,30 +0,0 @@
/**
* Admin
*/
import VueRouter from 'vue-router';
// Style
import './style.styl';
import init from '../init';
import Index from './views/index.vue';
import NotFound from '../common/views/pages/not-found.vue';
init(launch => {
document.title = 'Admin';
// Init router
const router = new VueRouter({
mode: 'history',
base: '/admin/',
routes: [
{ path: '/:page', component: Index },
{ path: '/', redirect: '/dashboard' },
{ path: '*', component: NotFound }
]
});
// Launch the app
launch(router);
});

View File

@ -1,6 +0,0 @@
@import "../app"
@import "../reset"
html
height 100%
background var(--bg)

View File

@ -1,83 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa :icon="faExclamationCircle"/> {{ $t('title') }}</template>
<section class="fit-top">
<sequential-entrance animation="entranceFromTop" delay="25">
<div v-for="report in userReports" :key="report.id" class="haexwsjc">
<ui-horizon-group inputs>
<ui-input :value="report.user | acct" type="text" readonly>
<span>{{ $t('target') }}</span>
</ui-input>
<ui-input :value="report.reporter | acct" type="text" readonly>
<span>{{ $t('reporter') }}</span>
</ui-input>
</ui-horizon-group>
<ui-textarea :value="report.comment" readonly>
<span>{{ $t('details') }}</span>
</ui-textarea>
<ui-button @click="removeReport(report)">{{ $t('remove-report') }}</ui-button>
</div>
</sequential-entrance>
<ui-button v-if="existMore" @click="fetchUserReports">{{ $t('@.load-more') }}</ui-button>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
i18n: i18n('admin/views/abuse.vue'),
data() {
return {
limit: 10,
untilId: undefined,
userReports: [],
existMore: false,
faExclamationCircle
};
},
mounted() {
this.fetchUserReports();
},
methods: {
fetchUserReports() {
this.$root.api('admin/abuse-user-reports', {
untilId: this.untilId,
limit: this.limit + 1
}).then(reports => {
if (reports.length == this.limit + 1) {
reports.pop();
this.existMore = true;
} else {
this.existMore = false;
}
this.userReports = this.userReports.concat(reports);
this.untilId = this.userReports[this.userReports.length - 1].id;
});
},
removeReport(report) {
this.$root.api('admin/remove-abuse-user-report', {
reportId: report.id
}).then(() => {
this.userReports = this.userReports.filter(r => r.id != report.id);
});
}
}
});
</script>
<style lang="stylus" scoped>
.haexwsjc
padding-bottom 16px
border-bottom solid 1px var(--faceDivider)
</style>

View File

@ -1,91 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa :icon="faBroadcastTower"/> {{ $t('announcements') }}</template>
<section v-for="(announcement, i) in announcements" class="fit-top">
<ui-input v-model="announcement.title" @change="save">
<span>{{ $t('title') }}</span>
</ui-input>
<ui-textarea v-model="announcement.text">
<span>{{ $t('text') }}</span>
</ui-textarea>
<ui-input v-model="announcement.image">
<span>{{ $t('image-url') }}</span>
</ui-input>
<ui-horizon-group class="fit-bottom">
<ui-button @click="save()"><fa :icon="['far', 'save']"/> {{ $t('save') }}</ui-button>
<ui-button @click="remove(i)"><fa :icon="['far', 'trash-alt']"/> {{ $t('remove') }}</ui-button>
</ui-horizon-group>
</section>
<section>
<ui-button @click="add"><fa :icon="faPlus"/> {{ $t('add') }}</ui-button>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { faBroadcastTower, faPlus } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
i18n: i18n('admin/views/announcements.vue'),
data() {
return {
announcements: [],
faBroadcastTower, faPlus
};
},
created() {
this.$root.getMeta().then(meta => {
this.announcements = meta.announcements;
});
},
methods: {
add() {
this.announcements.unshift({
title: '',
text: '',
image: null
});
},
remove(i) {
this.$root.dialog({
type: 'warning',
text: this.$t('_remove.are-you-sure').replace('$1', this.announcements.find((_, j) => j == i).title),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.announcements = this.announcements.filter((_, j) => j !== i);
this.save(true);
this.$root.dialog({
type: 'success',
text: this.$t('_remove.removed')
});
});
},
save(silent) {
this.$root.api('admin/update-meta', {
announcements: this.announcements
}).then(() => {
if (!silent) {
this.$root.dialog({
type: 'success',
text: this.$t('saved')
});
}
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
}
}
});
</script>

View File

@ -1,109 +0,0 @@
<template>
<div class="hyhctythnmwihguaaapnbrbszsjqxpio">
<table>
<thead>
<tr>
<th><fa :icon="faExchangeAlt"/> In/Out</th>
<th><fa :icon="faBolt"/> Activity</th>
<th><fa icon="server"/> Host</th>
<th><fa icon="user"/> Actor</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.id">
<td :class="log.direction">{{ log.direction == 'in' ? '<' : '>' }} {{ log.direction }}</td>
<td>{{ log.activity }}</td>
<td>{{ log.host }}</td>
<td>@{{ log.actor }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faBolt, faExchangeAlt } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
data() {
return {
logs: [],
connection: null,
faBolt, faExchangeAlt
};
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('apLog');
this.connection.on('log', this.onLog);
this.connection.on('logs', this.onLogs);
this.connection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: 50
});
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onLog(log) {
log.id = Math.random();
this.logs.unshift(log);
if (this.logs.length > 50) this.logs.pop();
},
onLogs(logs) {
for (const log of logs.reverse()) {
this.onLog(log)
}
}
}
});
</script>
<style lang="stylus" scoped>
.hyhctythnmwihguaaapnbrbszsjqxpio
display block
padding 12px 16px 16px 16px
height 250px
overflow auto
box-shadow 0 2px 4px rgba(0, 0, 0, 0.1)
background var(--adminDashboardCardBg)
border-radius 8px
> table
width 100%
max-width 100%
overflow auto
border-spacing 0
border-collapse collapse
color var(--adminDashboardCardFg)
font-size 14px
thead
border-bottom solid 1px var(--adminDashboardCardDivider)
tr
th
font-weight normal
text-align left
tbody
tr
&:nth-child(odd)
background rgba(0, 0, 0, 0.025)
th, td
padding 8px 16px
min-width 128px
td.in
color #d26755
td.out
color #55bb83
</style>

View File

@ -1,185 +0,0 @@
<template>
<div class="zyknedwtlthezamcjlolyusmipqmjgxz">
<div>
<header>
<span><fa icon="microchip"/> CPU <span>{{ cpuP }}%</span></span>
<span v-if="meta">{{ meta.cpu.model }}</span>
</header>
<div ref="cpu"></div>
</div>
<div>
<header>
<span><fa icon="memory"/> MEM <span>{{ memP }}%</span></span>
<span v-if="meta"></span>
</header>
<div ref="mem"></div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import ApexCharts from 'apexcharts';
export default Vue.extend({
props: ['connection'],
data() {
return {
stats: [],
cpuChart: null,
memChart: null,
cpuP: '',
memP: '',
meta: null
};
},
watch: {
stats(stats) {
this.cpuChart.updateSeries([{
data: stats.map((x, i) => ({ x: i, y: x.cpu_usage }))
}]);
this.memChart.updateSeries([{
data: stats.map((x, i) => ({ x: i, y: (x.mem.used / x.mem.total) }))
}]);
}
},
mounted() {
this.$root.getMeta().then(meta => {
this.meta = meta;
});
this.connection.on('stats', this.onStats);
this.connection.on('statsLog', this.onStatsLog);
this.connection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: 200
});
const chartOpts = {
chart: {
type: 'area',
height: 200,
animations: {
dynamicAnimation: {
enabled: false
}
},
toolbar: {
show: false
},
zoom: {
enabled: false
}
},
dataLabels: {
enabled: false
},
grid: {
clipMarkers: false,
borderColor: 'rgba(0, 0, 0, 0.1)'
},
stroke: {
curve: 'straight',
width: 2
},
tooltip: {
enabled: false
},
series: [{
data: []
}],
xaxis: {
type: 'numeric',
labels: {
show: false
},
tooltip: {
enabled: false
}
},
yaxis: {
show: false,
min: 0,
max: 1
}
};
this.cpuChart = new ApexCharts(this.$refs.cpu, chartOpts);
this.memChart = new ApexCharts(this.$refs.mem, chartOpts);
this.cpuChart.render();
this.memChart.render();
},
beforeDestroy() {
this.connection.off('stats', this.onStats);
this.connection.off('statsLog', this.onStatsLog);
this.cpuChart.destroy();
this.memChart.destroy();
},
methods: {
onStats(stats) {
this.stats.push(stats);
if (this.stats.length > 200) this.stats.shift();
this.cpuP = (stats.cpu_usage * 100).toFixed(0);
this.memP = (stats.mem.used / stats.mem.total * 100).toFixed(0);
},
onStatsLog(statsLog) {
for (const stats of statsLog.reverse()) {
this.onStats(stats);
}
}
}
});
</script>
<style lang="stylus" scoped>
.zyknedwtlthezamcjlolyusmipqmjgxz
display flex
> div
display block
flex 1
padding 20px 12px 0 12px
box-shadow 0 2px 4px rgba(0, 0, 0, 0.1)
background var(--face)
border-radius 8px
&:first-child
margin-right 16px
> header
display flex
padding 0 8px
margin-bottom -16px
color var(--adminDashboardCardFg)
font-size 14px
> span
&:last-child
margin-left auto
opacity 0.7
> span
opacity 0.7
> div
margin-bottom -10px
@media (max-width 1000px)
display block
margin-bottom 26px
> div
&:first-child
margin-right 0
margin-bottom 26px
</style>

View File

@ -1,196 +0,0 @@
<template>
<div class="mzxlfysy">
<div>
<header>
<span><fa :icon="faInbox"/> In</span>
<span v-if="latestStats">{{ latestStats.inbox.activeSincePrevTick | number }} / {{ latestStats.inbox.delayed | number }}</span>
</header>
<div ref="in"></div>
</div>
<div>
<header>
<span><fa :icon="faPaperPlane"/> Out</span>
<span v-if="latestStats">{{ latestStats.deliver.activeSincePrevTick | number }} / {{ latestStats.deliver.delayed | number }}</span>
</header>
<div ref="out"></div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faInbox } from '@fortawesome/free-solid-svg-icons';
import { faPaperPlane } from '@fortawesome/free-regular-svg-icons';
import ApexCharts from 'apexcharts';
const limit = 150;
export default Vue.extend({
data() {
return {
stats: [],
inChart: null,
outChart: null,
faInbox, faPaperPlane
};
},
computed: {
latestStats(): any {
return this.stats[this.stats.length - 1];
}
},
watch: {
stats(stats) {
this.inChart.updateSeries([{
data: stats.map((x, i) => ({ x: i, y: x.inbox.activeSincePrevTick }))
}, {
data: stats.map((x, i) => ({ x: i, y: x.inbox.active }))
}, {
data: stats.map((x, i) => ({ x: i, y: x.inbox.waiting }))
}, {
data: stats.map((x, i) => ({ x: i, y: x.inbox.delayed }))
}]);
this.outChart.updateSeries([{
data: stats.map((x, i) => ({ x: i, y: x.deliver.activeSincePrevTick }))
}, {
data: stats.map((x, i) => ({ x: i, y: x.deliver.active }))
}, {
data: stats.map((x, i) => ({ x: i, y: x.deliver.waiting }))
}, {
data: stats.map((x, i) => ({ x: i, y: x.deliver.delayed }))
}]);
}
},
mounted() {
const chartOpts = {
chart: {
type: 'area',
height: 200,
animations: {
dynamicAnimation: {
enabled: false
}
},
toolbar: {
show: false
},
zoom: {
enabled: false
}
},
dataLabels: {
enabled: false
},
grid: {
clipMarkers: false,
borderColor: 'rgba(0, 0, 0, 0.1)'
},
stroke: {
curve: 'straight',
width: 2
},
tooltip: {
enabled: false
},
legend: {
show: false
},
colors: ['#00E396', '#00BCD4', '#FFB300', '#e53935'],
series: [{ data: [] }, { data: [] }, { data: [] }, { data: [] }] as any,
xaxis: {
type: 'numeric',
labels: {
show: false
},
tooltip: {
enabled: false
}
},
yaxis: {
show: false,
min: 0,
}
};
this.inChart = new ApexCharts(this.$refs.in, chartOpts);
this.outChart = new ApexCharts(this.$refs.out, chartOpts);
this.inChart.render();
this.outChart.render();
const connection = this.$root.stream.useSharedConnection('queueStats');
connection.on('stats', this.onStats);
connection.on('statsLog', this.onStatsLog);
connection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: limit
});
this.$once('hook:beforeDestroy', () => {
connection.dispose();
this.inChart.destroy();
this.outChart.destroy();
});
},
methods: {
onStats(stats) {
this.stats.push(stats);
if (this.stats.length > limit) this.stats.shift();
},
onStatsLog(statsLog) {
for (const stats of statsLog.reverse()) {
this.onStats(stats);
}
}
}
});
</script>
<style lang="stylus" scoped>
.mzxlfysy
display flex
> div
display block
flex 1
padding 20px 12px 0 12px
box-shadow 0 2px 4px rgba(0, 0, 0, 0.1)
background var(--face)
border-radius 8px
&:first-child
margin-right 16px
> header
display flex
padding 0 8px
margin-bottom -16px
color var(--adminDashboardCardFg)
font-size 14px
> span
&:last-child
margin-left auto
opacity 0.7
> span
opacity 0.7
> div
margin-bottom -10px
@media (max-width 1000px)
display block
margin-bottom 26px
> div
&:first-child
margin-right 0
margin-bottom 26px
</style>

View File

@ -1,286 +0,0 @@
<template>
<div class="obdskegsannmntldydackcpzezagxqfy">
<header v-if="meta">
<p><b>Misskey</b><span>{{ meta.version }}</span></p>
<p><b>Machine</b><span>{{ meta.machine }}</span></p>
<p><b>OS</b><span>{{ meta.os }}</span></p>
<p><b>Node</b><span>{{ meta.node }}</span></p>
<p>{{ $t('@.ai-chan-kawaii') }}</p>
</header>
<marquee-text v-if="instances.length > 0" class="instances" :repeat="10" :duration="60">
<span v-for="instance in instances" class="instance">
<b :style="{ background: instance.bg }">{{ instance.host }}</b>{{ instance.notesCount | number }} / {{ instance.usersCount | number }}
</span>
</marquee-text>
<div v-if="stats" class="stats">
<div>
<div>
<div><fa icon="user"/></div>
<div>
<span>{{ $t('accounts') }}</span>
<b>{{ stats.originalUsersCount | number }}</b>
</div>
</div>
<div>
<span><fa icon="home"/> {{ $t('this-instance') }}</span>
<span @click="setChartSrc('users')"><fa :icon="['far', 'chart-bar']"/></span>
</div>
</div>
<div>
<div>
<div><fa icon="pencil-alt"/></div>
<div>
<span>{{ $t('notes') }}</span>
<b>{{ stats.originalNotesCount | number }}</b>
</div>
</div>
<div>
<span><fa icon="home"/> {{ $t('this-instance') }}</span>
<span @click="setChartSrc('notes')"><fa :icon="['far', 'chart-bar']"/></span>
</div>
</div>
<div>
<div>
<div><fa :icon="faDatabase"/></div>
<div>
<span>{{ $t('drive') }}</span>
<b>{{ stats.driveUsageLocal | bytes }}</b>
</div>
</div>
<div>
<span><fa icon="home"/> {{ $t('this-instance') }}</span>
<span @click="setChartSrc('drive')"><fa :icon="['far', 'chart-bar']"/></span>
</div>
</div>
<div>
<div>
<div><fa :icon="['far', 'hdd']"/></div>
<div>
<span>{{ $t('instances') }}</span>
<b>{{ stats.instances | number }}</b>
</div>
</div>
<div>
<span><fa icon="globe"/> {{ $t('federated') }}</span>
<span @click="setChartSrc('federation-instances-total')"><fa :icon="['far', 'chart-bar']"/></span>
</div>
</div>
</div>
<div class="charts">
<x-charts ref="charts"/>
</div>
<div class="queue">
<x-queue/>
</div>
<div class="cpu-memory">
<x-cpu-memory :connection="connection"/>
</div>
<div class="ap">
<x-ap-log/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import XCpuMemory from "./dashboard.cpu-memory.vue";
import XQueue from "./dashboard.queue-charts.vue";
import XCharts from "./dashboard.charts.vue";
import XApLog from "./dashboard.ap-log.vue";
import { faDatabase } from '@fortawesome/free-solid-svg-icons';
import MarqueeText from 'vue-marquee-text-component';
import randomColor from 'randomcolor';
export default Vue.extend({
i18n: i18n('admin/views/dashboard.vue'),
components: {
XCpuMemory,
XQueue,
XCharts,
XApLog,
MarqueeText
},
data() {
return {
stats: null,
connection: null,
meta: null,
instances: [],
faDatabase
};
},
created() {
this.connection = this.$root.stream.useSharedConnection('serverStats');
this.updateStats();
this.$root.getMeta().then(meta => {
this.meta = meta;
});
this.$root.api('federation/instances', {
sort: '+notes'
}).then(instances => {
for (const i of instances) {
i.bg = randomColor({
seed: i.host,
luminosity: 'dark'
});
}
this.instances = instances;
});
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
setChartSrc(src) {
this.$refs.charts.setSrc(src);
},
updateStats() {
this.$root.api('stats', {}, true).then(stats => {
this.stats = stats;
});
}
}
});
</script>
<style lang="stylus" scoped>
.obdskegsannmntldydackcpzezagxqfy
padding 16px
@media (min-width 500px)
padding 16px
> header
display flex
padding-bottom 16px
border-bottom solid 1px var(--adminDashboardHeaderBorder)
color var(--adminDashboardHeaderFg)
font-size 14px
white-space nowrap
@media (max-width 1000px)
display none
> p
display block
margin 0 32px 0 0
overflow hidden
text-overflow ellipsis
> b
&:after
content ':'
margin-right 8px
&:last-child
margin-left auto
margin-right 0
> .instances
padding 16px
color var(--adminDashboardHeaderFg)
font-size 13px
>>> .instance
margin 0 10px
> b
padding 2px 6px
margin-right 4px
border-radius 4px
color #fff
> .stats
display flex
justify-content space-between
margin-bottom 16px
> div
flex 1
margin-right 16px
color var(--adminDashboardCardFg)
box-shadow 0 2px 4px rgba(0, 0, 0, 0.1)
background var(--adminDashboardCardBg)
border-radius 8px
&:last-child
margin-right 0
> div:first-child
display flex
align-items center
text-align center
&:last-child
margin-right 0
> div:first-child
padding 16px 24px
font-size 28px
> div:last-child
flex 1
padding 16px 32px 16px 0
text-align right
> span
font-size 70%
opacity 0.7
> b
display block
> div:last-child
display flex
padding 6px 16px
border-top solid 1px var(--adminDashboardCardDivider)
> span
font-size 70%
opacity 0.7
&:last-child
margin-left auto
cursor pointer
@media (max-width 900px)
display grid
grid-template-columns 1fr 1fr
grid-template-rows 1fr 1fr
gap 16px
> div
margin-right 0
@media (max-width 500px)
display block
> div:not(:last-child)
margin-bottom 16px
> .charts
margin-bottom 16px
> .queue
margin-bottom 16px
> .cpu-memory
margin-bottom 16px
</style>

View File

@ -1,61 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa :icon="faDatabase"/> {{ $t('tables') }}</template>
<section v-if="tables">
<div v-for="table in Object.keys(tables)"><b>{{ table }}</b> {{ tables[table].count | number }} {{ tables[table].size | bytes }}</div>
</section>
<section>
<header><fa :icon="faBroom"/> {{ $t('vacuum') }}</header>
<ui-info>{{ $t('vacuum-info') }}</ui-info>
<ui-switch v-model="fullVacuum">FULL</ui-switch>
<ui-switch v-model="analyzeVacuum">ANALYZE</ui-switch>
<ui-button @click="vacuum()"><fa :icon="faBroom"/> {{ $t('vacuum') }}</ui-button>
<ui-info warn>{{ $t('vacuum-exclamation') }}</ui-info>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { faDatabase, faBroom } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
i18n: i18n('admin/views/db.vue'),
data() {
return {
tables: null,
fullVacuum: true,
analyzeVacuum: true,
faDatabase, faBroom
};
},
mounted() {
this.fetch();
},
methods: {
fetch() {
this.$root.api('admin/get-table-stats').then(tables => {
this.tables = tables;
});
},
vacuum() {
this.$root.api('admin/vacuum', {
full: this.fullVacuum,
analyze: this.analyzeVacuum,
}).then(() => {
this.$root.dialog({
type: 'success',
splash: true
});
});
},
}
});
</script>

View File

@ -1,292 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa :icon="faTerminal"/> {{ $t('operation') }}</template>
<section class="fit-top">
<ui-input v-model="target" type="text">
<span>{{ $t('fileid-or-url') }}</span>
</ui-input>
<ui-horizon-group>
<ui-button @click="findAndToggleSensitive(true)"><fa :icon="faEyeSlash"/> {{ $t('mark-as-sensitive') }}</ui-button>
<ui-button @click="findAndToggleSensitive(false)"><fa :icon="faEye"/> {{ $t('unmark-as-sensitive') }}</ui-button>
</ui-horizon-group>
<ui-button @click="findAndDel()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button>
<ui-button @click="show()"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button>
<ui-textarea v-if="file" :value="file | json5" readonly tall style="margin-top:16px;"></ui-textarea>
</section>
<section>
<ui-button @click="cleanUp()"><fa :icon="faTrashAlt"/> {{ $t('clean-up') }}</ui-button>
<ui-button @click="cleanRemoteFiles()"><fa :icon="faTrashAlt"/> {{ $t('clean-remote-files') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faCloud"/> {{ $t('@.drive') }}</template>
<section class="fit-top">
<ui-horizon-group inputs>
<ui-select v-model="sort">
<template #label>{{ $t('sort.title') }}</template>
<option value="-createdAt">{{ $t('sort.createdAtAsc') }}</option>
<option value="+createdAt">{{ $t('sort.createdAtDesc') }}</option>
<option value="-size">{{ $t('sort.sizeAsc') }}</option>
<option value="+size">{{ $t('sort.sizeDesc') }}</option>
</ui-select>
<ui-select v-model="origin">
<template #label>{{ $t('origin.title') }}</template>
<option value="combined">{{ $t('origin.combined') }}</option>
<option value="local">{{ $t('origin.local') }}</option>
<option value="remote">{{ $t('origin.remote') }}</option>
</ui-select>
</ui-horizon-group>
<sequential-entrance animation="entranceFromTop" delay="25">
<div class="kidvdlkg" v-for="file in files">
<div @click="file._open = !file._open">
<div>
<x-file-thumbnail class="thumbnail" :file="file" fit="contain" @click="showFileMenu(file)"/>
</div>
<div>
<header>
<b>{{ file.name }}</b>
<span class="username">@{{ file.user | acct }}</span>
</header>
<div>
<div>
<span style="margin-right:16px;">{{ file.type }}</span>
<span>{{ file.size | bytes }}</span>
</div>
<div><mk-time :time="file.createdAt" mode="detail"/></div>
</div>
</div>
</div>
<div v-show="file._open">
<ui-input readonly :value="file.url"></ui-input>
<ui-horizon-group>
<ui-button @click="toggleSensitive(file)" v-if="file.isSensitive"><fa :icon="faEye"/> {{ $t('unmark-as-sensitive') }}</ui-button>
<ui-button @click="toggleSensitive(file)" v-else><fa :icon="faEyeSlash"/> {{ $t('mark-as-sensitive') }}</ui-button>
<ui-button @click="del(file)"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button>
</ui-horizon-group>
</div>
</div>
</sequential-entrance>
<ui-button v-if="existMore" @click="fetch">{{ $t('@.load-more') }}</ui-button>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { faCloud, faTerminal, faSearch } from '@fortawesome/free-solid-svg-icons';
import { faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import XFileThumbnail from '../../common/views/components/drive-file-thumbnail.vue';
export default Vue.extend({
i18n: i18n('admin/views/drive.vue'),
components: {
XFileThumbnail
},
data() {
return {
file: null,
target: null,
sort: '+createdAt',
origin: 'combined',
limit: 10,
offset: 0,
files: [],
existMore: false,
faCloud, faTrashAlt, faEye, faEyeSlash, faTerminal, faSearch
};
},
watch: {
sort() {
this.files = [];
this.offset = 0;
this.fetch();
},
origin() {
this.files = [];
this.offset = 0;
this.fetch();
}
},
mounted() {
this.fetch();
},
methods: {
async fetchFile() {
try {
return await this.$root.api('drive/files/show', this.target.startsWith('http') ? { url: this.target } : { fileId: this.target });
} catch (e) {
if (e == 'file-not-found') {
this.$root.dialog({
type: 'error',
text: this.$t('file-not-found')
});
} else {
this.$root.dialog({
type: 'error',
text: e.toString()
});
}
}
},
fetch() {
this.$root.api('admin/drive/files', {
origin: this.origin,
sort: this.sort,
offset: this.offset,
limit: this.limit + 1
}).then(files => {
if (files.length == this.limit + 1) {
files.pop();
this.existMore = true;
} else {
this.existMore = false;
}
for (const x of files) {
x._open = false;
}
this.files = this.files.concat(files);
this.offset += this.limit;
});
},
async del(file: any) {
const process = async () => {
await this.$root.api('drive/files/delete', { fileId: file.id });
this.$root.dialog({
type: 'success',
text: this.$t('deleted')
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
},
toggleSensitive(file: any) {
this.$root.api('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive
}).then(() => {
file.isSensitive = !file.isSensitive;
});
},
async show() {
const file = await this.fetchFile();
this.$root.api('admin/drive/show-file', { fileId: file.id }).then(info => {
this.file = info;
});
},
async findAndToggleSensitive(sensitive) {
const process = async () => {
const file = await this.fetchFile();
await this.$root.api('drive/files/update', {
fileId: file.id,
isSensitive: sensitive
});
this.$root.dialog({
type: 'success',
text: sensitive ? this.$t('marked-as-sensitive') : this.$t('unmarked-as-sensitive')
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
},
async findAndDel() {
const process = async () => {
const file = await this.fetchFile();
await this.$root.api('drive/files/delete', { fileId: file.id });
this.$root.dialog({
type: 'success',
text: this.$t('deleted')
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
},
cleanRemoteFiles() {
this.$root.dialog({
type: 'warning',
text: this.$t('clean-remote-files-are-you-sure'),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.$root.api('admin/drive/clean-remote-files');
this.$root.dialog({
type: 'success',
splash: true
});
});
},
cleanUp() {
this.$root.api('admin/drive/cleanup');
this.$root.dialog({
type: 'success',
splash: true
});
}
}
});
</script>
<style lang="stylus" scoped>
.kidvdlkg
padding 16px 0
border-top solid 1px var(--faceDivider)
> div:first-child
display flex
cursor pointer
> div:nth-child(1)
> .thumbnail
display flex
width 64px
height 64px
background-size cover
background-position center center
> div:nth-child(2)
flex 1
padding-left 16px
@media (max-width 500px)
font-size 14px
> header
word-break break-word
> .username
margin-left 8px
opacity 0.7
</style>

View File

@ -1,185 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa icon="plus"/> {{ $t('add-emoji.title') }}</template>
<section class="fit-top">
<ui-horizon-group inputs>
<ui-input v-model="name">
<span>{{ $t('add-emoji.name') }}</span>
<template #desc>{{ $t('add-emoji.name-desc') }}</template>
</ui-input>
<ui-input v-model="category" :datalist="categoryList">
<span>{{ $t('add-emoji.category') }}</span>
</ui-input>
<ui-input v-model="aliases">
<span>{{ $t('add-emoji.aliases') }}</span>
<template #desc>{{ $t('add-emoji.aliases-desc') }}</template>
</ui-input>
</ui-horizon-group>
<ui-input v-model="url">
<template #icon><fa icon="link"/></template>
<span>{{ $t('add-emoji.url') }}</span>
</ui-input>
<ui-info>{{ $t('add-emoji.info') }}</ui-info>
<ui-button @click="add">{{ $t('add-emoji.add') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faGrin"/> {{ $t('emojis.title') }}</template>
<section v-for="emoji in emojis" :key="emoji.name" class="oryfrbft">
<div>
<img :src="emoji.url" :alt="emoji.name" style="width: 64px;"/>
</div>
<div>
<ui-horizon-group>
<ui-input v-model="emoji.name">
<span>{{ $t('add-emoji.name') }}</span>
</ui-input>
<ui-input v-model="emoji.category" :datalist="categoryList">
<span>{{ $t('add-emoji.category') }}</span>
</ui-input>
<ui-input v-model="emoji.aliases">
<span>{{ $t('add-emoji.aliases') }}</span>
</ui-input>
</ui-horizon-group>
<ui-input v-model="emoji.url">
<template #icon><fa icon="link"/></template>
<span>{{ $t('add-emoji.url') }}</span>
</ui-input>
<ui-horizon-group class="fit-bottom">
<ui-button @click="updateEmoji(emoji)"><fa :icon="['far', 'save']"/> {{ $t('emojis.update') }}</ui-button>
<ui-button @click="removeEmoji(emoji)"><fa :icon="['far', 'trash-alt']"/> {{ $t('emojis.remove') }}</ui-button>
</ui-horizon-group>
</div>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { faGrin } from '@fortawesome/free-regular-svg-icons';
import { unique } from '../../../../prelude/array';
export default Vue.extend({
i18n: i18n('admin/views/emoji.vue'),
data() {
return {
name: '',
category: '',
url: '',
aliases: '',
emojis: [],
faGrin
};
},
mounted() {
this.fetchEmojis();
},
computed: {
categoryList() {
return unique(this.emojis.map((x: any) => x.category || '').filter((x: string) => x !== ''));
}
},
methods: {
add() {
this.$root.api('admin/emoji/add', {
name: this.name,
category: this.category,
url: this.url,
aliases: this.aliases.split(' ').filter(x => x.length > 0)
}).then(() => {
this.$root.dialog({
type: 'success',
text: this.$t('add-emoji.added')
});
this.fetchEmojis();
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
},
fetchEmojis() {
this.$root.api('admin/emoji/list').then(emojis => {
for (const e of emojis) {
e.aliases = (e.aliases || []).join(' ');
}
this.emojis = emojis;
});
},
updateEmoji(emoji) {
this.$root.api('admin/emoji/update', {
id: emoji.id,
name: emoji.name,
category: emoji.category,
url: emoji.url,
aliases: emoji.aliases.split(' ').filter(x => x.length > 0)
}).then(() => {
this.$root.dialog({
type: 'success',
text: this.$t('updated')
});
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
},
removeEmoji(emoji) {
this.$root.dialog({
type: 'warning',
text: this.$t('remove-emoji.are-you-sure').replace('$1', emoji.name),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.$root.api('admin/emoji/remove', {
id: emoji.id
}).then(() => {
this.$root.dialog({
type: 'success',
text: this.$t('remove-emoji.removed')
});
this.fetchEmojis();
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
});
}
}
});
</script>
<style lang="stylus" scoped>
.oryfrbft
@media (min-width 500px)
display flex
> div:first-child
@media (max-width 500px)
padding-bottom 16px
> img
vertical-align bottom
> div:last-child
flex 1
@media (min-width 500px)
padding-left 16px
</style>

View File

@ -1,553 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa :icon="faTerminal"/> {{ $t('instance') }}</template>
<section class="fit-top">
<ui-input class="target" v-model="target" type="text" @enter="showInstance()">
<span>{{ $t('host') }}</span>
<template #prefix><fa :icon="faServer"/></template>
</ui-input>
<ui-button @click="showInstance()"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button>
<div class="instance" v-if="instance">
<ui-horizon-group inputs>
<ui-input :value="instance.host" type="text" readonly>
<span>{{ $t('host') }}</span>
<template #prefix><fa :icon="faServer"/></template>
</ui-input>
<ui-input :value="instance.caughtAt | date" type="text" readonly>
<span>{{ $t('caught-at') }}</span>
<template #prefix><fa :icon="faCrosshairs"/></template>
</ui-input>
</ui-horizon-group>
<ui-horizon-group inputs>
<ui-input :value="instance.notesCount | number" type="text" readonly>
<span>{{ $t('notes') }}</span>
<template #prefix><fa :icon="faEnvelopeOpenText"/></template>
</ui-input>
<ui-input :value="instance.usersCount | number" type="text" readonly>
<span>{{ $t('users') }}</span>
<template #prefix><fa :icon="faUsers"/></template>
</ui-input>
</ui-horizon-group>
<ui-horizon-group inputs>
<ui-input :value="instance.followingCount | number" type="text" readonly>
<span>{{ $t('following') }}</span>
<template #prefix><fa :icon="faCaretDown"/></template>
</ui-input>
<ui-input :value="instance.followersCount | number" type="text" readonly>
<span>{{ $t('followers') }}</span>
<template #prefix><fa :icon="faCaretUp"/></template>
</ui-input>
</ui-horizon-group>
<ui-horizon-group inputs>
<ui-input :value="instance.latestRequestSentAt | date" type="text" readonly>
<span>{{ $t('latest-request-sent-at') }}</span>
<template #prefix><fa :icon="faPaperPlane"/></template>
</ui-input>
<ui-input :value="instance.latestStatus" type="text" readonly>
<span>{{ $t('status') }}</span>
<template #prefix><fa :icon="faTrafficLight"/></template>
</ui-input>
</ui-horizon-group>
<ui-input :value="instance.latestRequestReceivedAt | date" type="text" readonly>
<span>{{ $t('latest-request-received-at') }}</span>
<template #prefix><fa :icon="faInbox"/></template>
</ui-input>
<ui-switch v-model="instance.isMarkedAsClosed" @change="updateInstance()">{{ $t('marked-as-closed') }}</ui-switch>
<details>
<summary>{{ $t('charts') }}</summary>
<ui-horizon-group inputs>
<ui-select v-model="chartSrc">
<option value="requests">{{ $t('chart-srcs.requests') }}</option>
<option value="users">{{ $t('chart-srcs.users') }}</option>
<option value="users-total">{{ $t('chart-srcs.users-total') }}</option>
<option value="notes">{{ $t('chart-srcs.notes') }}</option>
<option value="notes-total">{{ $t('chart-srcs.notes-total') }}</option>
<option value="ff">{{ $t('chart-srcs.ff') }}</option>
<option value="ff-total">{{ $t('chart-srcs.ff-total') }}</option>
<option value="drive-usage">{{ $t('chart-srcs.drive-usage') }}</option>
<option value="drive-usage-total">{{ $t('chart-srcs.drive-usage-total') }}</option>
<option value="drive-files">{{ $t('chart-srcs.drive-files') }}</option>
<option value="drive-files-total">{{ $t('chart-srcs.drive-files-total') }}</option>
</ui-select>
<ui-select v-model="chartSpan">
<option value="hour">{{ $t('chart-spans.hour') }}</option>
<option value="day">{{ $t('chart-spans.day') }}</option>
</ui-select>
</ui-horizon-group>
<div ref="chart"></div>
</details>
<details>
<summary>{{ $t('delete-all-files') }}</summary>
<ui-button @click="deleteAllFiles()" style="margin-top: 16px;"><fa :icon="faTrashAlt"/> {{ $t('delete-all-files') }}</ui-button>
</details>
<details>
<summary>{{ $t('remove-all-following') }}</summary>
<ui-button @click="removeAllFollowing()" style="margin-top: 16px;"><fa :icon="faMinusCircle"/> {{ $t('remove-all-following') }}</ui-button>
<ui-info warn>{{ $t('remove-all-following-info', { host: instance.host }) }}</ui-info>
</details>
</div>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faServer"/> {{ $t('instances') }}</template>
<section class="fit-top">
<ui-horizon-group inputs>
<ui-select v-model="sort">
<template #label>{{ $t('sort') }}</template>
<option value="-caughtAt">{{ $t('sorts.caughtAtAsc') }}</option>
<option value="+caughtAt">{{ $t('sorts.caughtAtDesc') }}</option>
<option value="-lastCommunicatedAt">{{ $t('sorts.lastCommunicatedAtAsc') }}</option>
<option value="+lastCommunicatedAt">{{ $t('sorts.lastCommunicatedAtDesc') }}</option>
<option value="-notes">{{ $t('sorts.notesAsc') }}</option>
<option value="+notes">{{ $t('sorts.notesDesc') }}</option>
<option value="-users">{{ $t('sorts.usersAsc') }}</option>
<option value="+users">{{ $t('sorts.usersDesc') }}</option>
<option value="-following">{{ $t('sorts.followingAsc') }}</option>
<option value="+following">{{ $t('sorts.followingDesc') }}</option>
<option value="-followers">{{ $t('sorts.followersAsc') }}</option>
<option value="+followers">{{ $t('sorts.followersDesc') }}</option>
<option value="-driveUsage">{{ $t('sorts.driveUsageAsc') }}</option>
<option value="+driveUsage">{{ $t('sorts.driveUsageDesc') }}</option>
<option value="-driveFiles">{{ $t('sorts.driveFilesAsc') }}</option>
<option value="+driveFiles">{{ $t('sorts.driveFilesDesc') }}</option>
</ui-select>
<ui-select v-model="state">
<template #label>{{ $t('state') }}</template>
<option value="all">{{ $t('states.all') }}</option>
<option value="blocked">{{ $t('states.blocked') }}</option>
<option value="notResponding">{{ $t('states.not-responding') }}</option>
<option value="markedAsClosed">{{ $t('states.marked-as-closed') }}</option>
</ui-select>
</ui-horizon-group>
<div class="instances">
<header>
<span>{{ $t('host') }}</span>
<span>{{ $t('notes') }}</span>
<span>{{ $t('users') }}</span>
<span>{{ $t('following') }}</span>
<span>{{ $t('followers') }}</span>
<span>{{ $t('status') }}</span>
</header>
<div v-for="instance in instances" :style="{ opacity: instance.isNotResponding ? 0.5 : 1 }">
<a @click.prevent="showInstance(instance.host)" rel="nofollow noopener" target="_blank" :href="`https://${instance.host}`" :style="{ textDecoration: instance.isMarkedAsClosed ? 'line-through' : 'none' }">{{ instance.host }}</a>
<span>{{ instance.notesCount | number }}</span>
<span>{{ instance.usersCount | number }}</span>
<span>{{ instance.followingCount | number }}</span>
<span>{{ instance.followersCount | number }}</span>
<span>{{ instance.latestStatus }}</span>
</div>
</div>
<ui-info v-if="instances.length == limit">{{ $t('result-is-truncated', { n: limit }) }}</ui-info>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faBan"/> {{ $t('blocked-hosts') }}</template>
<section class="fit-top">
<ui-textarea v-model="blockedHosts">
<template #desc>{{ $t('blocked-hosts-info') }}</template>
</ui-textarea>
<ui-button @click="saveBlockedHosts">{{ $t('save') }}</ui-button>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { faPaperPlane } from '@fortawesome/free-regular-svg-icons';
import { faTrashAlt, faBan, faGlobe, faTerminal, faSearch, faMinusCircle, faServer, faCrosshairs, faEnvelopeOpenText, faUsers, faCaretDown, faCaretUp, faTrafficLight, faInbox } from '@fortawesome/free-solid-svg-icons';
import ApexCharts from 'apexcharts';
import * as tinycolor from 'tinycolor2';
const chartLimit = 90;
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
const negate = arr => arr.map(x => -x);
export default Vue.extend({
i18n: i18n('admin/views/federation.vue'),
filters: {
date: v => v ? new Date(v).toLocaleString() : 'N/A'
},
data() {
return {
instance: null,
target: null,
sort: '+lastCommunicatedAt',
state: 'all',
limit: 100,
instances: [],
chart: null,
chartSrc: 'requests',
chartSpan: 'hour',
chartInstance: null,
blockedHosts: '',
faTrashAlt, faBan, faGlobe, faTerminal, faSearch, faMinusCircle, faServer, faCrosshairs, faEnvelopeOpenText, faUsers, faCaretDown, faCaretUp, faPaperPlane, faTrafficLight, faInbox
};
},
computed: {
data(): any {
if (this.chart == null) return null;
switch (this.chartSrc) {
case 'requests': return this.requestsChart();
case 'users': return this.usersChart(false);
case 'users-total': return this.usersChart(true);
case 'notes': return this.notesChart(false);
case 'notes-total': return this.notesChart(true);
case 'ff': return this.ffChart(false);
case 'ff-total': return this.ffChart(true);
case 'drive-usage': return this.driveUsageChart(false);
case 'drive-usage-total': return this.driveUsageChart(true);
case 'drive-files': return this.driveFilesChart(false);
case 'drive-files-total': return this.driveFilesChart(true);
}
},
stats(): any[] {
const stats =
this.chartSpan == 'day' ? this.chart.perDay :
this.chartSpan == 'hour' ? this.chart.perHour :
null;
return stats;
}
},
watch: {
sort() {
this.fetchInstances();
},
state() {
this.fetchInstances();
},
async instance() {
this.now = new Date();
const [perHour, perDay] = await Promise.all([
this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }),
this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }),
]);
const chart = {
perHour: perHour,
perDay: perDay
};
this.chart = chart;
this.renderChart();
},
chartSrc() {
this.renderChart();
},
chartSpan() {
this.renderChart();
}
},
mounted() {
this.fetchInstances();
this.$root.getMeta().then(meta => {
this.blockedHosts = meta.blockedHosts.join('\n');
});
},
beforeDestroy() {
this.chartInstance.destroy();
},
methods: {
showInstance(target?: string) {
this.$root.api('federation/show-instance', {
host: target || this.target
}).then(instance => {
if (instance == null) {
this.$root.dialog({
type: 'error',
text: this.$t('instance-not-registered')
});
} else {
this.instance = instance;
this.target = '';
}
});
},
fetchInstances() {
this.instances = [];
this.$root.api('federation/instances', {
blocked: this.state === 'blocked' ? true : null,
notResponding: this.state === 'notResponding' ? true : null,
markedAsClosed: this.state === 'markedAsClosed' ? true : null,
sort: this.sort,
limit: this.limit
}).then(instances => {
this.instances = instances;
});
},
removeAllFollowing() {
this.$root.api('admin/federation/remove-all-following', {
host: this.instance.host
}).then(() => {
this.$root.dialog({
type: 'success',
splash: true
});
});
},
deleteAllFiles() {
this.$root.api('admin/federation/delete-all-files', {
host: this.instance.host
}).then(() => {
this.$root.dialog({
type: 'success',
splash: true
});
});
},
updateInstance() {
this.$root.api('admin/federation/update-instance', {
host: this.instance.host,
isBlocked: this.instance.isBlocked || false,
isClosed: this.instance.isMarkedAsClosed || false
});
},
setSrc(src) {
this.chartSrc = src;
},
renderChart() {
if (this.chartInstance) {
this.chartInstance.destroy();
}
this.chartInstance = new ApexCharts(this.$refs.chart, {
chart: {
type: 'area',
height: 300,
animations: {
dynamicAnimation: {
enabled: false
}
},
toolbar: {
show: false
},
zoom: {
enabled: false
}
},
dataLabels: {
enabled: false
},
grid: {
clipMarkers: false,
borderColor: 'rgba(0, 0, 0, 0.1)'
},
stroke: {
curve: 'straight',
width: 2
},
tooltip: {
theme: this.$store.state.device.darkmode ? 'dark' : 'light'
},
legend: {
labels: {
colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString()
},
},
xaxis: {
type: 'datetime',
labels: {
style: {
colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString()
}
},
axisBorder: {
color: 'rgba(0, 0, 0, 0.1)'
},
axisTicks: {
color: 'rgba(0, 0, 0, 0.1)'
},
},
yaxis: {
labels: {
formatter: this.data.bytes ? v => Vue.filter('bytes')(v, 0) : v => Vue.filter('number')(v),
style: {
color: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString()
}
}
},
series: this.data.series
});
this.chartInstance.render();
},
getDate(i: number) {
const y = this.now.getFullYear();
const m = this.now.getMonth();
const d = this.now.getDate();
const h = this.now.getHours();
return (
this.chartSpan == 'day' ? new Date(y, m, d - i) :
this.chartSpan == 'hour' ? new Date(y, m, d, h - i) :
null
);
},
format(arr) {
return arr.map((v, i) => ({ x: this.getDate(i).getTime(), y: v }));
},
requestsChart(): any {
return {
series: [{
name: 'Incoming',
data: this.format(this.stats.requests.received)
}, {
name: 'Outgoing (succeeded)',
data: this.format(this.stats.requests.succeeded)
}, {
name: 'Outgoing (failed)',
data: this.format(this.stats.requests.failed)
}]
};
},
usersChart(total: boolean): any {
return {
series: [{
name: 'Users',
type: 'area',
data: this.format(total
? this.stats.users.total
: sum(this.stats.users.inc, negate(this.stats.users.dec))
)
}]
};
},
notesChart(total: boolean): any {
return {
series: [{
name: 'Notes',
type: 'area',
data: this.format(total
? this.stats.notes.total
: sum(this.stats.notes.inc, negate(this.stats.notes.dec))
)
}]
};
},
ffChart(total: boolean): any {
return {
series: [{
name: 'Following',
type: 'area',
data: this.format(total
? this.stats.following.total
: sum(this.stats.following.inc, negate(this.stats.following.dec))
)
}, {
name: 'Followers',
type: 'area',
data: this.format(total
? this.stats.followers.total
: sum(this.stats.followers.inc, negate(this.stats.followers.dec))
)
}]
};
},
driveUsageChart(total: boolean): any {
return {
bytes: true,
series: [{
name: 'Drive usage',
type: 'area',
data: this.format(total
? this.stats.drive.totalUsage
: sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage))
)
}]
};
},
driveFilesChart(total: boolean): any {
return {
series: [{
name: 'Drive files',
type: 'area',
data: this.format(total
? this.stats.drive.totalFiles
: sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles))
)
}]
};
},
saveBlockedHosts() {
this.$root.api('admin/update-meta', {
blockedHosts: this.blockedHosts ? this.blockedHosts.split('\n') : []
}).then(() => {
this.$root.dialog({
type: 'success',
text: this.$t('saved')
});
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
}
}
});
</script>
<style lang="stylus" scoped>
.target
margin-bottom 16px !important
.instances
width 100%
> header
display flex
> *
color var(--text)
font-weight bold
> div
display flex
> * > *
flex 1
overflow auto
&:first-child
min-width 200px
</style>

View File

@ -1,297 +0,0 @@
<template>
<div class="mk-admin" :class="{ isMobile }">
<header v-show="isMobile">
<button class="nav" @click="navOpend = true"><fa icon="bars"/></button>
<span>MisskeyMyAdmin</span>
</header>
<div class="nav-backdrop"
v-if="navOpend && isMobile"
@click="navOpend = false"
@touchstart="navOpend = false"
></div>
<nav v-show="navOpend">
<div class="mi">
<img svg-inline src="../assets/header-icon.svg"/>
</div>
<div class="me">
<img class="avatar" :src="$store.state.i.avatarUrl" alt="avatar"/>
<p class="name"><mk-user-name :user="$store.state.i"/></p>
</div>
<ul>
<li><router-link to="/dashboard" active-class="active"><fa icon="home" fixed-width/>{{ $t('dashboard') }}</router-link></li>
<li><router-link to="/instance" active-class="active"><fa icon="cog" fixed-width/>{{ $t('instance') }}</router-link></li>
<li><router-link to="/queue" active-class="active"><fa :icon="faTasks" fixed-width/>{{ $t('queue') }}</router-link></li>
<li><router-link to="/logs" active-class="active"><fa :icon="faStream" fixed-width/>{{ $t('logs') }}</router-link></li>
<li><router-link to="/db" active-class="active"><fa :icon="faDatabase" fixed-width/>{{ $t('db') }}</router-link></li>
<li><router-link to="/moderators" active-class="active"><fa :icon="faHeadset" fixed-width/>{{ $t('moderators') }}</router-link></li>
<li><router-link to="/users" active-class="active"><fa icon="users" fixed-width/>{{ $t('users') }}</router-link></li>
<li><router-link to="/drive" active-class="active"><fa icon="cloud" fixed-width/>{{ $t('@.drive') }}</router-link></li>
<li><router-link to="/federation" active-class="active"><fa :icon="faGlobe" fixed-width/>{{ $t('federation') }}</router-link></li>
<li><router-link to="/emoji" active-class="active"><fa :icon="faGrin" fixed-width/>{{ $t('emoji') }}</router-link></li>
<li><router-link to="/announcements" active-class="active"><fa icon="broadcast-tower" fixed-width/>{{ $t('announcements') }}</router-link></li>
<li><router-link to="/abuse" active-class="active"><fa :icon="faExclamationCircle" fixed-width/>{{ $t('abuse') }}</router-link></li>
</ul>
<div class="back-to-misskey">
<a href="/"><fa :icon="faArrowLeft"/> {{ $t('back-to-misskey') }}</a>
</div>
<div class="version">
<small>Misskey {{ version }}</small>
</div>
</nav>
<main>
<div class="page">
<div v-if="page == 'dashboard'"><x-dashboard/></div>
<div v-if="page == 'instance'"><x-instance/></div>
<div v-if="page == 'queue'"><x-queue/></div>
<div v-if="page == 'logs'"><x-logs/></div>
<div v-if="page == 'db'"><x-db/></div>
<div v-if="page == 'moderators'"><x-moderators/></div>
<div v-if="page == 'users'"><x-users/></div>
<div v-if="page == 'emoji'"><x-emoji/></div>
<div v-if="page == 'announcements'"><x-announcements/></div>
<div v-if="page == 'drive'"><x-drive/></div>
<div v-if="page == 'federation'"><x-federation/></div>
<div v-if="page == 'abuse'"><x-abuse/></div>
</div>
</main>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { version } from '../../config';
import XDashboard from './dashboard.vue';
import XInstance from './instance.vue';
import XQueue from './queue.vue';
import XLogs from './logs.vue';
import XDb from './db.vue';
import XModerators from './moderators.vue';
import XEmoji from './emoji.vue';
import XAnnouncements from './announcements.vue';
import XUsers from './users.vue';
import XDrive from './drive.vue';
import XAbuse from './abuse.vue';
import XFederation from './federation.vue';
import { faHeadset, faArrowLeft, faGlobe, faExclamationCircle, faTasks, faStream, faDatabase } from '@fortawesome/free-solid-svg-icons';
import { faGrin } from '@fortawesome/free-regular-svg-icons';
// Detect the user agent
const ua = navigator.userAgent.toLowerCase();
const isMobile = /mobile|iphone|ipad|android/.test(ua);
export default Vue.extend({
i18n: i18n('admin/views/index.vue'),
components: {
XDashboard,
XInstance,
XQueue,
XLogs,
XDb,
XModerators,
XEmoji,
XAnnouncements,
XUsers,
XDrive,
XAbuse,
XFederation,
},
provide: {
isMobile
},
data() {
return {
version,
isMobile,
navOpend: !isMobile,
faGrin,
faArrowLeft,
faHeadset,
faGlobe,
faExclamationCircle,
faTasks,
faStream,
faDatabase,
};
},
computed: {
page() {
return this.$route.params.page;
}
}
});
</script>
<style lang="stylus" scoped>
.mk-admin
$headerHeight = 48px
display flex
height 100%
> header
position fixed
top 0
z-index 10000
width 100%
color var(--mobileHeaderFg)
background-color var(--mobileHeaderBg)
box-shadow 0 1px 0 rgba(#000, 0.075)
&, *
user-select none
> span
display block
line-height $headerHeight
text-align center
> .nav
display block
position absolute
top 0
left 0
z-index 10001
padding 0
width $headerHeight
font-size 1.4em
line-height $headerHeight
border-right solid 1px rgba(#000, 0.1)
> [data-icon]
transition all 0.2s ease
> nav
position fixed
z-index 20001
top 0
left 0
width 250px
height 100vh
overflow auto
background #333
color #fff
> .mi
text-align center
> svg
width 24px
height 82px
vertical-align top
fill #fff
opacity 0.7
> .me
display flex
margin 0 16px 16px 16px
padding 16px 0
align-items center
border-top solid 1px #555
border-bottom solid 1px #555
> .avatar
height 48px
border-radius 100%
vertical-align middle
> .name
margin 0 16px
padding 0
color #fff
overflow hidden
text-overflow ellipsis
white-space nowrap
font-size 15px
> .back-to-misskey
margin 16px 16px 0 16px
padding 0
border-top solid 1px #555
> a
display block
padding 16px 4px
color inherit
text-decoration none
color #eee
font-size 15px
&:hover
color #fff
> [data-icon]
margin-right 6px
> .version
margin 0 16px 16px 16px
padding-top 16px
border-top solid 1px #555
text-align center
> small
opacity 0.7
> ul
margin 0
padding 0
list-style none
font-size 15px
> li > a
display block
padding 10px 16px
margin 0
user-select none
color #eee
transition margin-left 0.2s ease
&:hover
color #fff
> [data-icon]
margin-right 6px
&.active
margin-left 8px
color var(--primary) !important
&:after
content ""
display block
position absolute
top 0
right 0
bottom 0
margin auto 0
height 0
border-top solid 16px transparent
border-right solid 16px var(--bg)
border-bottom solid 16px transparent
border-left solid 16px transparent
> .nav-backdrop
position fixed
top 0
left 0
z-index 20000
width 100%
height 100%
background var(--mobileNavBackdrop)
> main
width 100%
padding 0 0 0 250px
> .page
max-width 1150px
@media (min-width 500px)
padding 16px
&.isMobile
> main
padding $headerHeight 0 0 0
</style>

View File

@ -1,523 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa icon="cog"/> {{ $t('instance') }}</template>
<section class="fit-top">
<ui-input :value="host" readonly>{{ $t('host') }}</ui-input>
<ui-input v-model="name">{{ $t('instance-name') }}</ui-input>
<ui-textarea v-model="description">{{ $t('instance-description') }}</ui-textarea>
<ui-input v-model="iconUrl"><template #icon><fa icon="link"/></template>{{ $t('icon-url') }}</ui-input>
<ui-input v-model="mascotImageUrl"><template #icon><fa icon="link"/></template>{{ $t('logo-url') }}</ui-input>
<ui-input v-model="bannerUrl"><template #icon><fa icon="link"/></template>{{ $t('banner-url') }}</ui-input>
<ui-input v-model="ToSUrl"><template #icon><fa icon="link"/></template>{{ $t('tos-url') }}</ui-input>
<details>
<summary>{{ $t('advanced-config') }}</summary>
<ui-input v-model="errorImageUrl"><template #icon><fa icon="link"/></template>{{ $t('error-image-url') }}</ui-input>
<ui-input v-model="languages"><template #icon><fa icon="language"/></template>{{ $t('languages') }}<template #desc>{{ $t('languages-desc') }}</template></ui-input>
<ui-input v-model="repositoryUrl"><template #icon><fa icon="link"/></template>{{ $t('repository-url') }}</ui-input>
<ui-input v-model="feedbackUrl"><template #icon><fa icon="link"/></template>{{ $t('feedback-url') }}</ui-input>
</details>
</section>
<section class="fit-bottom">
<header><fa :icon="faHeadset"/> {{ $t('maintainer-config') }}</header>
<ui-input v-model="maintainerName">{{ $t('maintainer-name') }}</ui-input>
<ui-input v-model="maintainerEmail" type="email"><template #icon><fa :icon="farEnvelope"/></template>{{ $t('maintainer-email') }}</ui-input>
</section>
<section>
<ui-switch v-model="disableRegistration">{{ $t('disable-registration') }}</ui-switch>
<ui-button v-if="disableRegistration" @click="invite">{{ $t('invite') }}</ui-button>
</section>
<section>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faPencilAlt"/> {{ $t('note-and-tl') }}</template>
<section class="fit-top fit-bottom">
<ui-input v-model="maxNoteTextLength">{{ $t('max-note-text-length') }}</ui-input>
</section>
<section>
<ui-switch v-model="disableLocalTimeline">{{ $t('disable-local-timeline') }}</ui-switch>
<ui-switch v-model="disableGlobalTimeline">{{ $t('disable-global-timeline') }}</ui-switch>
<ui-info>{{ $t('disabling-timelines-info') }}</ui-info>
</section>
<section>
<ui-switch v-model="enableEmojiReaction">{{ $t('enable-emoji-reaction') }}</ui-switch>
<ui-switch v-model="useStarForReactionFallback">{{ $t('use-star-for-reaction-fallback') }}</ui-switch>
</section>
<section>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa icon="cloud"/> {{ $t('drive-config') }}</template>
<section>
<ui-switch v-model="useObjectStorage">{{ $t('use-object-storage') }}</ui-switch>
<template v-if="useObjectStorage">
<ui-info>
<i18n path="object-storage-s3-info">
<a href="https://docs.aws.amazon.com/general/latest/gr/rande.html" target="_blank">{{ $t('object-storage-s3-info-here') }}</a>
</i18n>
</ui-info>
<ui-info>{{ $t('object-storage-gcs-info') }}</ui-info>
<ui-input v-model="objectStorageBaseUrl" :disabled="!useObjectStorage">{{ $t('object-storage-base-url') }}</ui-input>
<ui-horizon-group inputs>
<ui-input v-model="objectStorageBucket" :disabled="!useObjectStorage">{{ $t('object-storage-bucket') }}</ui-input>
<ui-input v-model="objectStoragePrefix" :disabled="!useObjectStorage">{{ $t('object-storage-prefix') }}</ui-input>
</ui-horizon-group>
<ui-input v-model="objectStorageEndpoint" :disabled="!useObjectStorage">{{ $t('object-storage-endpoint') }}</ui-input>
<ui-horizon-group inputs>
<ui-input v-model="objectStorageRegion" :disabled="!useObjectStorage">{{ $t('object-storage-region') }}</ui-input>
<ui-input v-model="objectStoragePort" type="number" :disabled="!useObjectStorage">{{ $t('object-storage-port') }}</ui-input>
</ui-horizon-group>
<ui-horizon-group inputs>
<ui-input v-model="objectStorageAccessKey" :disabled="!useObjectStorage"><template #icon><fa icon="key"/></template>{{ $t('object-storage-access-key') }}</ui-input>
<ui-input v-model="objectStorageSecretKey" :disabled="!useObjectStorage"><template #icon><fa icon="key"/></template>{{ $t('object-storage-secret-key') }}</ui-input>
</ui-horizon-group>
<ui-switch v-model="objectStorageUseSSL" :disabled="!useObjectStorage">{{ $t('object-storage-use-ssl') }}</ui-switch>
</template>
</section>
<section>
<ui-switch v-model="cacheRemoteFiles">{{ $t('cache-remote-files') }}<template #desc>{{ $t('cache-remote-files-desc') }}</template></ui-switch>
<ui-switch v-model="proxyRemoteFiles">{{ $t('proxy-remote-files') }}<template #desc>{{ $t('proxy-remote-files-desc') }}</template></ui-switch>
</section>
<section class="fit-top fit-bottom">
<ui-input v-model="localDriveCapacityMb" type="number">{{ $t('local-drive-capacity-mb') }}<template #suffix>MB</template><template #desc>{{ $t('mb') }}</template></ui-input>
<ui-input v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles">{{ $t('remote-drive-capacity-mb') }}<template #suffix>MB</template><template #desc>{{ $t('mb') }}</template></ui-input>
</section>
<section>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faThumbtack"/> {{ $t('pinned-users') }}</template>
<section class="fit-top">
<ui-textarea v-model="pinnedUsers">
<template #desc>{{ $t('pinned-users-info') }}</template>
</ui-textarea>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faGhost"/> {{ $t('proxy-account-config') }}</template>
<section>
<ui-info>{{ $t('proxy-account-info') }}</ui-info>
<ui-input v-model="proxyAccount"><template #prefix>@</template>{{ $t('proxy-account-username') }}<template #desc>{{ $t('proxy-account-username-desc') }}</template></ui-input>
<ui-info warn>{{ $t('proxy-account-warn') }}</ui-info>
</section>
<section>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="farEnvelope"/> {{ $t('email-config') }}</template>
<section>
<ui-switch v-model="enableEmail">{{ $t('enable-email') }}<template #desc>{{ $t('email-config-info') }}</template></ui-switch>
<template v-if="enableEmail">
<ui-input v-model="email" type="email" :disabled="!enableEmail">{{ $t('email') }}</ui-input>
<ui-horizon-group inputs>
<ui-input v-model="smtpHost" :disabled="!enableEmail">{{ $t('smtp-host') }}</ui-input>
<ui-input v-model="smtpPort" type="number" :disabled="!enableEmail">{{ $t('smtp-port') }}</ui-input>
</ui-horizon-group>
<ui-switch v-model="smtpAuth">{{ $t('smtp-auth') }}</ui-switch>
<ui-horizon-group inputs>
<ui-input v-model="smtpUser" :disabled="!enableEmail || !smtpAuth">{{ $t('smtp-user') }}</ui-input>
<ui-input v-model="smtpPass" type="password" :with-password-toggle="true" :disabled="!enableEmail || !smtpAuth">{{ $t('smtp-pass') }}</ui-input>
</ui-horizon-group>
<ui-switch v-model="smtpSecure" :disabled="!enableEmail">{{ $t('smtp-secure') }}<template #desc>{{ $t('smtp-secure-info') }}</template></ui-switch>
<ui-button @click="testEmail()">{{ $t('test-email') }}</ui-button>
</template>
</section>
<section>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faBolt"/> {{ $t('serviceworker-config') }}</template>
<section>
<ui-switch v-model="enableServiceWorker">{{ $t('enable-serviceworker') }}<template #desc>{{ $t('serviceworker-info') }}</template></ui-switch>
<template v-if="enableServiceWorker">
<ui-info>{{ $t('vapid-info') }}<br><code>npm i web-push -g<br>web-push generate-vapid-keys</code></ui-info>
<ui-horizon-group inputs class="fit-bottom">
<ui-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa icon="key"/></template>{{ $t('vapid-publickey') }}</ui-input>
<ui-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa icon="key"/></template>{{ $t('vapid-privatekey') }}</ui-input>
</ui-horizon-group>
</template>
</section>
<section>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faShieldAlt"/> {{ $t('recaptcha-config') }}</template>
<section :class="enableRecaptcha ? 'fit-bottom' : ''">
<ui-switch v-model="enableRecaptcha">{{ $t('enable-recaptcha') }}</ui-switch>
<template v-if="enableRecaptcha">
<ui-info>{{ $t('recaptcha-info') }}</ui-info>
<ui-info warn>{{ $t('recaptcha-info2') }}</ui-info>
<ui-horizon-group inputs>
<ui-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa icon="key"/></template>{{ $t('recaptcha-site-key') }}</ui-input>
<ui-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa icon="key"/></template>{{ $t('recaptcha-secret-key') }}</ui-input>
</ui-horizon-group>
</template>
</section>
<section v-if="enableRecaptcha && recaptchaSiteKey">
<header>{{ $t('recaptcha-preview') }}</header>
<div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div>
</section>
<section>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faShieldAlt"/> {{ $t('external-service-integration-config') }}</template>
<section>
<header><fa :icon="['fab', 'twitter']"/> {{ $t('twitter-integration-config') }}</header>
<ui-switch v-model="enableTwitterIntegration">{{ $t('enable-twitter-integration') }}</ui-switch>
<template v-if="enableTwitterIntegration">
<ui-horizon-group>
<ui-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa icon="key"/></template>{{ $t('twitter-integration-consumer-key') }}</ui-input>
<ui-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa icon="key"/></template>{{ $t('twitter-integration-consumer-secret') }}</ui-input>
</ui-horizon-group>
<ui-info>{{ $t('twitter-integration-info', { url: `${url}/api/tw/cb` }) }}</ui-info>
</template>
</section>
<section>
<header><fa :icon="['fab', 'github']"/> {{ $t('github-integration-config') }}</header>
<ui-switch v-model="enableGithubIntegration">{{ $t('enable-github-integration') }}</ui-switch>
<template v-if="enableGithubIntegration">
<ui-horizon-group>
<ui-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa icon="key"/></template>{{ $t('github-integration-client-id') }}</ui-input>
<ui-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa icon="key"/></template>{{ $t('github-integration-client-secret') }}</ui-input>
</ui-horizon-group>
<ui-info>{{ $t('github-integration-info', { url: `${url}/api/gh/cb` }) }}</ui-info>
</template>
</section>
<section>
<header><fa :icon="['fab', 'discord']"/> {{ $t('discord-integration-config') }}</header>
<ui-switch v-model="enableDiscordIntegration">{{ $t('enable-discord-integration') }}</ui-switch>
<template v-if="enableDiscordIntegration">
<ui-horizon-group>
<ui-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa icon="key"/></template>{{ $t('discord-integration-client-id') }}</ui-input>
<ui-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa icon="key"/></template>{{ $t('discord-integration-client-secret') }}</ui-input>
</ui-horizon-group>
<ui-info>{{ $t('discord-integration-info', { url: `${url}/api/dc/cb` }) }}</ui-info>
</template>
</section>
<section>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<details>
<summary style="color:var(--text);">{{ $t('advanced-config') }}</summary>
<ui-card>
<template #title><fa :icon="faHashtag"/> {{ $t('hidden-tags') }}</template>
<section class="fit-top">
<ui-textarea v-model="hiddenTags">
<template #desc>{{ $t('hidden-tags-info') }}</template>
</ui-textarea>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title>summaly Proxy</template>
<section class="fit-top fit-bottom">
<ui-input v-model="summalyProxy">URL</ui-input>
</section>
<section>
<ui-button @click="updateMeta"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</section>
</ui-card>
</details>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { url, host } from '../../config';
import { toUnicode } from 'punycode';
import { faHeadset, faShieldAlt, faGhost, faUserPlus, faBolt, faThumbtack, faPencilAlt, faHashtag } from '@fortawesome/free-solid-svg-icons';
import { faEnvelope as farEnvelope, faSave } from '@fortawesome/free-regular-svg-icons';
export default Vue.extend({
i18n: i18n('admin/views/instance.vue'),
data() {
return {
url,
host: toUnicode(host),
maintainerName: null,
maintainerEmail: null,
ToSUrl: null,
repositoryUrl: "https://github.com/syuilo/misskey",
feedbackUrl: null,
disableRegistration: false,
disableLocalTimeline: false,
disableGlobalTimeline: false,
enableEmojiReaction: true,
useStarForReactionFallback: false,
mascotImageUrl: null,
bannerUrl: null,
errorImageUrl: null,
iconUrl: null,
name: null,
description: null,
languages: null,
cacheRemoteFiles: false,
proxyRemoteFiles: false,
localDriveCapacityMb: null,
remoteDriveCapacityMb: null,
maxNoteTextLength: null,
enableRecaptcha: false,
recaptchaSiteKey: null,
recaptchaSecretKey: null,
enableTwitterIntegration: false,
twitterConsumerKey: null,
twitterConsumerSecret: null,
enableGithubIntegration: false,
githubClientId: null,
githubClientSecret: null,
enableDiscordIntegration: false,
discordClientId: null,
discordClientSecret: null,
proxyAccount: null,
summalyProxy: null,
enableEmail: false,
email: null,
smtpSecure: false,
smtpHost: null,
smtpPort: null,
smtpUser: null,
smtpPass: null,
smtpAuth: false,
enableServiceWorker: false,
swPublicKey: null,
swPrivateKey: null,
pinnedUsers: '',
hiddenTags: '',
useObjectStorage: false,
objectStorageBaseUrl: null,
objectStorageBucket: null,
objectStoragePrefix: null,
objectStorageEndpoint: null,
objectStorageRegion: null,
objectStoragePort: null,
objectStorageAccessKey: null,
objectStorageSecretKey: null,
objectStorageUseSSL: false,
faHeadset, faShieldAlt, faGhost, faUserPlus, farEnvelope, faBolt, faThumbtack, faPencilAlt, faSave, faHashtag
};
},
created() {
this.$root.getMeta(true).then(meta => {
this.maintainerName = meta.maintainerName;
this.maintainerEmail = meta.maintainerEmail;
this.ToSUrl = meta.ToSUrl;
this.repositoryUrl = meta.repositoryUrl;
this.feedbackUrl = meta.feedbackUrl;
this.disableRegistration = meta.disableRegistration;
this.disableLocalTimeline = meta.disableLocalTimeline;
this.disableGlobalTimeline = meta.disableGlobalTimeline;
this.enableEmojiReaction = meta.enableEmojiReaction;
this.useStarForReactionFallback = meta.useStarForReactionFallback;
this.mascotImageUrl = meta.mascotImageUrl;
this.bannerUrl = meta.bannerUrl;
this.errorImageUrl = meta.errorImageUrl;
this.iconUrl = meta.iconUrl;
this.name = meta.name;
this.description = meta.description;
this.languages = meta.langs.join(' ');
this.cacheRemoteFiles = meta.cacheRemoteFiles;
this.proxyRemoteFiles = meta.proxyRemoteFiles;
this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
this.maxNoteTextLength = meta.maxNoteTextLength;
this.enableRecaptcha = meta.enableRecaptcha;
this.recaptchaSiteKey = meta.recaptchaSiteKey;
this.recaptchaSecretKey = meta.recaptchaSecretKey;
this.proxyAccount = meta.proxyAccount;
this.enableTwitterIntegration = meta.enableTwitterIntegration;
this.twitterConsumerKey = meta.twitterConsumerKey;
this.twitterConsumerSecret = meta.twitterConsumerSecret;
this.enableGithubIntegration = meta.enableGithubIntegration;
this.githubClientId = meta.githubClientId;
this.githubClientSecret = meta.githubClientSecret;
this.enableDiscordIntegration = meta.enableDiscordIntegration;
this.discordClientId = meta.discordClientId;
this.discordClientSecret = meta.discordClientSecret;
this.summalyProxy = meta.summalyProxy;
this.enableEmail = meta.enableEmail;
this.email = meta.email;
this.smtpSecure = meta.smtpSecure;
this.smtpHost = meta.smtpHost;
this.smtpPort = meta.smtpPort;
this.smtpUser = meta.smtpUser;
this.smtpPass = meta.smtpPass;
this.smtpAuth = meta.smtpUser != null && meta.smtpUser !== '';
this.enableServiceWorker = meta.enableServiceWorker;
this.swPublicKey = meta.swPublickey;
this.swPrivateKey = meta.swPrivateKey;
this.pinnedUsers = meta.pinnedUsers.join('\n');
this.hiddenTags = meta.hiddenTags.join('\n');
this.useObjectStorage = meta.useObjectStorage;
this.objectStorageBaseUrl = meta.objectStorageBaseUrl;
this.objectStorageBucket = meta.objectStorageBucket;
this.objectStoragePrefix = meta.objectStoragePrefix;
this.objectStorageEndpoint = meta.objectStorageEndpoint;
this.objectStorageRegion = meta.objectStorageRegion;
this.objectStoragePort = meta.objectStoragePort;
this.objectStorageAccessKey = meta.objectStorageAccessKey;
this.objectStorageSecretKey = meta.objectStorageSecretKey;
this.objectStorageUseSSL = meta.objectStorageUseSSL;
});
},
mounted() {
const renderRecaptchaPreview = () => {
if (!(window as any).grecaptcha) return;
if (!this.$refs.recaptcha) return;
if (!this.recaptchaSiteKey) return;
(window as any).grecaptcha.render(this.$refs.recaptcha, {
sitekey: this.recaptchaSiteKey
});
};
window.onRecaotchaLoad = () => {
renderRecaptchaPreview();
};
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaotchaLoad');
head.appendChild(script);
this.$watch('enableRecaptcha', () => {
renderRecaptchaPreview();
});
this.$watch('recaptchaSiteKey', () => {
renderRecaptchaPreview();
});
},
methods: {
invite() {
this.$root.api('admin/invite').then(x => {
this.$root.dialog({
type: 'info',
text: x.code
});
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
},
async testEmail() {
this.$root.api('admin/send-email', {
to: this.maintainerEmail,
subject: 'Test email',
text: 'Yo'
}).then(x => {
this.$root.dialog({
type: 'success',
splash: true
});
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
},
updateMeta() {
this.$root.api('admin/update-meta', {
maintainerName: this.maintainerName,
maintainerEmail: this.maintainerEmail,
ToSUrl: this.ToSUrl,
repositoryUrl: this.repositoryUrl,
feedbackUrl: this.feedbackUrl,
disableRegistration: this.disableRegistration,
disableLocalTimeline: this.disableLocalTimeline,
disableGlobalTimeline: this.disableGlobalTimeline,
enableEmojiReaction: this.enableEmojiReaction,
useStarForReactionFallback: this.useStarForReactionFallback,
mascotImageUrl: this.mascotImageUrl,
bannerUrl: this.bannerUrl,
errorImageUrl: this.errorImageUrl,
iconUrl: this.iconUrl,
name: this.name,
description: this.description,
langs: this.languages ? this.languages.split(' ') : [],
cacheRemoteFiles: this.cacheRemoteFiles,
proxyRemoteFiles: this.proxyRemoteFiles,
localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
maxNoteTextLength: parseInt(this.maxNoteTextLength, 10),
enableRecaptcha: this.enableRecaptcha,
recaptchaSiteKey: this.recaptchaSiteKey,
recaptchaSecretKey: this.recaptchaSecretKey,
proxyAccount: this.proxyAccount,
enableTwitterIntegration: this.enableTwitterIntegration,
twitterConsumerKey: this.twitterConsumerKey,
twitterConsumerSecret: this.twitterConsumerSecret,
enableGithubIntegration: this.enableGithubIntegration,
githubClientId: this.githubClientId,
githubClientSecret: this.githubClientSecret,
enableDiscordIntegration: this.enableDiscordIntegration,
discordClientId: this.discordClientId,
discordClientSecret: this.discordClientSecret,
summalyProxy: this.summalyProxy,
enableEmail: this.enableEmail,
email: this.email,
smtpSecure: this.smtpSecure,
smtpHost: this.smtpHost,
smtpPort: parseInt(this.smtpPort, 10),
smtpUser: this.smtpAuth ? this.smtpUser : '',
smtpPass: this.smtpAuth ? this.smtpPass : '',
enableServiceWorker: this.enableServiceWorker,
swPublicKey: this.swPublicKey,
swPrivateKey: this.swPrivateKey,
pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [],
hiddenTags: this.hiddenTags ? this.hiddenTags.split('\n') : [],
useObjectStorage: this.useObjectStorage,
objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null,
objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null,
objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null,
objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null,
objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null,
objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null,
objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null,
objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null,
objectStorageUseSSL: this.objectStorageUseSSL,
}).then(() => {
this.$root.dialog({
type: 'success',
text: this.$t('saved')
});
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
}
}
});
</script>

View File

@ -1,119 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa :icon="faStream"/> {{ $t('logs') }}</template>
<section class="fit-top">
<ui-horizon-group inputs>
<ui-input v-model="domain" :debounce="true">
<span>{{ $t('domain') }}</span>
</ui-input>
<ui-select v-model="level">
<template #label>{{ $t('level') }}</template>
<option value="all">{{ $t('levels.all') }}</option>
<option value="info">{{ $t('levels.info') }}</option>
<option value="success">{{ $t('levels.success') }}</option>
<option value="warning">{{ $t('levels.warning') }}</option>
<option value="error">{{ $t('levels.error') }}</option>
<option value="debug">{{ $t('levels.debug') }}</option>
</ui-select>
</ui-horizon-group>
<div class="nqjzuvev">
<code v-for="log in logs" :key="log.id" :class="log.level">
<details>
<summary><mk-time :time="log.createdAt"/> [{{ log.domain.join('.') }}] {{ log.message }}</summary>
<vue-json-pretty v-if="log.data" :data="log.data"></vue-json-pretty>
</details>
</code>
</div>
<ui-button @click="deleteAll()">{{ $t('delete-all') }}</ui-button>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { faStream } from '@fortawesome/free-solid-svg-icons';
import VueJsonPretty from 'vue-json-pretty';
export default Vue.extend({
i18n: i18n('admin/views/logs.vue'),
components: {
VueJsonPretty
},
data() {
return {
logs: [],
level: 'all',
domain: '',
faStream
};
},
watch: {
level() {
this.logs = [];
this.fetch();
},
domain() {
this.logs = [];
this.fetch();
}
},
mounted() {
this.fetch();
},
methods: {
fetch() {
this.$root.api('admin/logs', {
level: this.level === 'all' ? null : this.level,
domain: this.domain === '' ? null : this.domain,
limit: 100
}).then(logs => {
this.logs = logs.reverse();
});
},
deleteAll() {
this.$root.api('admin/delete-logs').then(() => {
this.$root.dialog({
type: 'success',
splash: true
});
});
}
}
});
</script>
<style lang="stylus" scoped>
.nqjzuvev
padding 8px
background #000
color #fff
font-size 14px
> code
display block
&.error
color #f00
&.warning
color #ff0
&.success
color #0f0
&.debug
opacity 0.7
</style>

View File

@ -1,127 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa icon="plus"/> {{ $t('add-moderator.title') }}</template>
<section class="fit-top">
<ui-input v-model="username" type="text">
<template #prefix>@</template>
</ui-input>
<ui-horizon-group>
<ui-button @click="add" :disabled="changing">{{ $t('add-moderator.add') }}</ui-button>
<ui-button @click="remove" :disabled="changing">{{ $t('add-moderator.remove') }}</ui-button>
</ui-horizon-group>
</section>
</ui-card>
<ui-card>
<template #title>{{ $t('logs.title') }}</template>
<section class="fit-top">
<sequential-entrance animation="entranceFromTop" delay="25">
<div v-for="log in logs" :key="log.id" class="">
<ui-horizon-group inputs>
<ui-input :value="log.user | acct" type="text" readonly>
<span>{{ $t('logs.moderator') }}</span>
</ui-input>
<ui-input :value="log.type" type="text" readonly>
<span>{{ $t('logs.type') }}</span>
</ui-input>
<ui-input :value="log.createdAt | date" type="text" readonly>
<span>{{ $t('logs.at') }}</span>
</ui-input>
</ui-horizon-group>
<ui-textarea :value="JSON.stringify(log.info, null, 4)" readonly>
<span>{{ $t('logs.info') }}</span>
</ui-textarea>
</div>
</sequential-entrance>
<ui-button v-if="existMoreLogs" @click="fetchLogs">{{ $t('@.load-more') }}</ui-button>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import parseAcct from "../../../../misc/acct/parse";
export default Vue.extend({
i18n: i18n('admin/views/moderators.vue'),
data() {
return {
username: '',
changing: false,
logs: [],
untilLogId: null,
existMoreLogs: false
};
},
created() {
this.fetchLogs();
},
methods: {
async add() {
this.changing = true;
const process = async () => {
const user = await this.$root.api('users/show', parseAcct(this.username));
await this.$root.api('admin/moderators/add', { userId: user.id });
this.$root.dialog({
type: 'success',
text: this.$t('add-moderator.added')
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
this.changing = false;
},
async remove() {
this.changing = true;
const process = async () => {
const user = await this.$root.api('users/show', parseAcct(this.username));
await this.$root.api('admin/moderators/remove', { userId: user.id });
this.$root.dialog({
type: 'success',
text: this.$t('add-moderator.removed')
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
this.changing = false;
},
fetchLogs() {
this.$root.api('admin/show-moderation-logs', {
untilId: this.untilId,
limit: 10 + 1
}).then(logs => {
if (logs.length == 10 + 1) {
logs.pop();
this.existMoreLogs = true;
} else {
this.existMoreLogs = false;
}
this.logs = this.logs.concat(logs);
this.untilLogId = this.logs[this.logs.length - 1].id;
});
},
}
});
</script>

View File

@ -1,181 +0,0 @@
<template>
<div>
<ui-info warn v-if="latestStats && latestStats.waiting > 0">The queue is jammed.</ui-info>
<ui-horizon-group inputs v-if="latestStats" class="fit-bottom">
<ui-input :value="latestStats.activeSincePrevTick | number" type="text" readonly>
<span>Process</span>
<template #prefix><fa :icon="fasPlayCircle"/></template>
<template #suffix>jobs/tick</template>
</ui-input>
<ui-input :value="latestStats.active | number" type="text" readonly>
<span>Active</span>
<template #prefix><fa :icon="farPlayCircle"/></template>
<template #suffix>jobs</template>
</ui-input>
<ui-input :value="latestStats.waiting | number" type="text" readonly>
<span>Waiting</span>
<template #prefix><fa :icon="faStopCircle"/></template>
<template #suffix>jobs</template>
</ui-input>
<ui-input :value="latestStats.delayed | number" type="text" readonly>
<span>Delayed</span>
<template #prefix><fa :icon="faStopwatch"/></template>
<template #suffix>jobs</template>
</ui-input>
</ui-horizon-group>
<div ref="chart" class="wptihjuy"></div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import ApexCharts from 'apexcharts';
import * as tinycolor from 'tinycolor2';
import { faStopwatch, faPlayCircle as fasPlayCircle } from '@fortawesome/free-solid-svg-icons';
import { faStopCircle, faPlayCircle as farPlayCircle } from '@fortawesome/free-regular-svg-icons';
export default Vue.extend({
i18n: i18n('admin/views/queue.vue'),
props: {
type: {
type: String,
required: true
},
connection: {
required: true
},
limit: {
type: Number,
required: true
}
},
data() {
return {
stats: [],
chart: null,
faStopwatch, faStopCircle, farPlayCircle, fasPlayCircle
};
},
computed: {
latestStats(): any {
return this.stats.length > 0 ? this.stats[this.stats.length - 1][this.type] : null;
}
},
watch: {
stats(stats) {
this.chart.updateSeries([{
name: 'Process',
type: 'area',
data: stats.map((x, i) => ({ x: i, y: x[this.type].activeSincePrevTick }))
}, {
name: 'Active',
type: 'area',
data: stats.map((x, i) => ({ x: i, y: x[this.type].active }))
}, {
name: 'Waiting',
type: 'line',
data: stats.map((x, i) => ({ x: i, y: x[this.type].waiting }))
}, {
name: 'Delayed',
type: 'line',
data: stats.map((x, i) => ({ x: i, y: x[this.type].delayed }))
}]);
},
},
mounted() {
this.chart = new ApexCharts(this.$refs.chart, {
chart: {
id: this.type,
group: 'queue',
type: 'area',
height: 200,
animations: {
dynamicAnimation: {
enabled: false
}
},
toolbar: {
show: false
},
zoom: {
enabled: false
}
},
dataLabels: {
enabled: false
},
grid: {
clipMarkers: false,
borderColor: 'rgba(0, 0, 0, 0.1)',
xaxis: {
lines: {
show: true,
}
},
},
stroke: {
curve: 'straight',
width: 2
},
tooltip: {
enabled: false
},
legend: {
labels: {
colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString()
},
},
series: [] as any,
colors: ['#00E396', '#00BCD4', '#FFB300', '#e53935'],
xaxis: {
type: 'numeric',
labels: {
show: false
},
tooltip: {
enabled: false
}
},
yaxis: {
show: false,
min: 0,
}
});
this.chart.render();
this.connection.on('stats', this.onStats);
this.connection.on('statsLog', this.onStatsLog);
this.$once('hook:beforeDestroy', () => {
if (this.chart) this.chart.destroy();
});
},
methods: {
onStats(stats) {
this.stats.push(stats);
if (this.stats.length > this.limit) this.stats.shift();
},
onStatsLog(statsLog) {
for (const stats of statsLog.reverse()) {
this.onStats(stats);
}
},
}
});
</script>
<style lang="stylus" scoped>
.wptihjuy
min-height 200px !important
margin -8px
</style>

View File

@ -1,159 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa :icon="faChartBar"/> {{ $t('title') }}</template>
<section>
<header><fa :icon="faPaperPlane"/> {{ $t('domains.deliver') }}</header>
<x-chart v-if="connection" :connection="connection" :limit="chartLimit" type="deliver"/>
</section>
<section>
<header><fa :icon="faInbox"/> {{ $t('domains.inbox') }}</header>
<x-chart v-if="connection" :connection="connection" :limit="chartLimit" type="inbox"/>
</section>
<section>
<details>
<summary>{{ $t('other-queues') }}</summary>
<section>
<header><fa :icon="faDatabase"/> {{ $t('domains.db') }}</header>
<x-chart v-if="connection" :connection="connection" :limit="chartLimit" type="db"/>
</section>
<ui-hr/>
<section>
<header><fa :icon="faCloud"/> {{ $t('domains.objectStorage') }}</header>
<x-chart v-if="connection" :connection="connection" :limit="chartLimit" type="objectStorage"/>
</section>
</details>
</section>
<section>
<ui-button @click="removeAllJobs">{{ $t('remove-all-jobs') }}</ui-button>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faTasks"/> {{ $t('jobs') }}</template>
<section class="fit-top">
<ui-horizon-group inputs>
<ui-select v-model="domain">
<template #label>{{ $t('queue') }}</template>
<option value="deliver">{{ $t('domains.deliver') }}</option>
<option value="inbox">{{ $t('domains.inbox') }}</option>
<option value="db">{{ $t('domains.db') }}</option>
<option value="objectStorage">{{ $t('domains.objectStorage') }}</option>
</ui-select>
<ui-select v-model="state">
<template #label>{{ $t('state') }}</template>
<option value="active">{{ $t('states.active') }}</option>
<option value="waiting">{{ $t('states.waiting') }}</option>
<option value="delayed">{{ $t('states.delayed') }}</option>
</ui-select>
</ui-horizon-group>
<sequential-entrance animation="entranceFromTop" delay="25">
<div class="xvvuvgsv" v-for="job in jobs" :key="job.id">
<b>{{ job.id }}</b>
<template v-if="domain === 'deliver'">
<span>{{ job.data.to }}</span>
</template>
<template v-if="domain === 'inbox'">
<span>{{ job.data.activity.id }}</span>
</template>
<span>{{ `(${job.attempts}/${job.maxAttempts}, ${Math.floor((jobsFetched - job.timestamp) / 1000 / 60)}min)` }}</span>
</div>
</sequential-entrance>
<ui-info v-if="jobs.length == jobsLimit">{{ $t('result-is-truncated', { n: jobsLimit }) }}</ui-info>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faTasks, faInbox, faDatabase, faCloud } from '@fortawesome/free-solid-svg-icons';
import { faPaperPlane, faChartBar } from '@fortawesome/free-regular-svg-icons';
import i18n from '../../i18n';
import XChart from './queue.chart.vue';
export default Vue.extend({
i18n: i18n('admin/views/queue.vue'),
components: {
XChart
},
data() {
return {
connection: null,
chartLimit: 200,
jobs: [],
jobsLimit: 50,
jobsFetched: Date.now(),
domain: 'deliver',
state: 'delayed',
faTasks, faPaperPlane, faInbox, faChartBar, faDatabase, faCloud
};
},
watch: {
domain() {
this.jobs = [];
this.fetchJobs();
},
state() {
this.jobs = [];
this.fetchJobs();
},
},
mounted() {
this.fetchJobs();
this.connection = this.$root.stream.useSharedConnection('queueStats');
this.connection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: this.chartLimit
});
this.$once('hook:beforeDestroy', () => {
this.connection.dispose();
});
},
methods: {
async removeAllJobs() {
const process = async () => {
await this.$root.api('admin/queue/clear');
this.$root.dialog({
type: 'success',
splash: true
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
},
fetchJobs() {
this.$root.api('admin/queue/jobs', {
domain: this.domain,
state: this.state,
limit: this.jobsLimit
}).then(jobs => {
this.jobsFetched = Date.now(),
this.jobs = jobs;
});
},
}
});
</script>
<style lang="stylus" scoped>
.xvvuvgsv
margin-left -6px
> b, span
margin 0 6px
</style>

View File

@ -1,95 +0,0 @@
<template>
<div class="kofvwchc">
<div>
<a :href="user | userPage(null, true)">
<mk-avatar class="avatar" :user="user" :disable-link="true"/>
</a>
</div>
<div @click="click(user.id)">
<header>
<b><mk-user-name :user="user"/></b>
<span class="username">@{{ user | acct }}</span>
<span class="is-admin" v-if="user.isAdmin">admin</span>
<span class="is-moderator" v-if="user.isModerator">moderator</span>
<span class="is-silenced" v-if="user.isSilenced" :title="$t('@.silenced-user')"><fa :icon="faMicrophoneSlash"/></span>
<span class="is-suspended" v-if="user.isSuspended" :title="$t('@.suspended-user')"><fa :icon="faSnowflake"/></span>
</header>
<div>
<span>{{ $t('users.updatedAt') }}: <mk-time :time="user.updatedAt" mode="detail"/></span>
</div>
<div>
<span>{{ $t('users.createdAt') }}: <mk-time :time="user.createdAt" mode="detail"/></span>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons';
import { faSnowflake } from '@fortawesome/free-regular-svg-icons';
export default Vue.extend({
i18n: i18n('admin/views/users.vue'),
props: ['user', 'click'],
data() {
return {
faSnowflake, faMicrophoneSlash
};
},
});
</script>
<style lang="stylus" scoped>
.kofvwchc
display flex
padding 16px
border-top solid 1px var(--faceDivider)
> div:first-child
> a
> .avatar
width 64px
height 64px
> div:last-child
flex 1
cursor pointer
padding-left 16px
@media (max-width 500px)
font-size 14px
> header
> .username
margin-left 8px
opacity 0.7
> .is-admin
> .is-moderator
flex-shrink 0
align-self center
margin 0 0 0 .5em
padding 1px 6px
font-size 80%
border-radius 3px
background var(--noteHeaderAdminBg)
color var(--noteHeaderAdminFg)
> .is-silenced
> .is-suspended
margin 0 0 0 .5em
color #4dabf7
&:hover
color var(--primaryForeground)
background var(--primary)
text-decoration none
border-radius 3px
&:active
color var(--primaryForeground)
background var(--primaryDarken10)
border-radius 3px
</style>

View File

@ -1,366 +0,0 @@
<template>
<div>
<ui-card>
<template #title><fa :icon="faTerminal"/> {{ $t('operation') }}</template>
<section class="fit-top">
<ui-input class="target" v-model="target" type="text" @enter="showUser">
<span>{{ $t('username-or-userid') }}</span>
</ui-input>
<ui-button @click="showUser"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button>
<div ref="user" class="user" v-if="user" :key="user.id">
<x-user :user="user"/>
<div class="actions">
<ui-button v-if="user.host != null" @click="updateRemoteUser"><fa :icon="faSync"/> {{ $t('update-remote-user') }}</ui-button>
<ui-button @click="resetPassword"><fa :icon="faKey"/> {{ $t('reset-password') }}</ui-button>
<ui-horizon-group>
<ui-button @click="silenceUser"><fa :icon="faMicrophoneSlash"/> {{ $t('make-silence') }}</ui-button>
<ui-button @click="unsilenceUser">{{ $t('unmake-silence') }}</ui-button>
</ui-horizon-group>
<ui-horizon-group>
<ui-button @click="suspendUser" :disabled="suspending"><fa :icon="faSnowflake"/> {{ $t('suspend') }}</ui-button>
<ui-button @click="unsuspendUser" :disabled="unsuspending">{{ $t('unsuspend') }}</ui-button>
</ui-horizon-group>
<ui-button @click="deleteAllFiles"><fa :icon="faTrashAlt"/> {{ $t('delete-all-files') }}</ui-button>
<ui-textarea v-if="user" :value="user | json5" readonly tall style="margin-top:16px;"></ui-textarea>
</div>
</div>
</section>
</ui-card>
<ui-card>
<template #title><fa :icon="faUsers"/> {{ $t('users.title') }}</template>
<section class="fit-top">
<ui-horizon-group inputs>
<ui-select v-model="sort">
<template #label>{{ $t('users.sort.title') }}</template>
<option value="-createdAt">{{ $t('users.sort.createdAtAsc') }}</option>
<option value="+createdAt">{{ $t('users.sort.createdAtDesc') }}</option>
<option value="-updatedAt">{{ $t('users.sort.updatedAtAsc') }}</option>
<option value="+updatedAt">{{ $t('users.sort.updatedAtDesc') }}</option>
</ui-select>
<ui-select v-model="state">
<template #label>{{ $t('users.state.title') }}</template>
<option value="all">{{ $t('users.state.all') }}</option>
<option value="available">{{ $t('users.state.available') }}</option>
<option value="admin">{{ $t('users.state.admin') }}</option>
<option value="moderator">{{ $t('users.state.moderator') }}</option>
<option value="silenced">{{ $t('users.state.silenced') }}</option>
<option value="suspended">{{ $t('users.state.suspended') }}</option>
</ui-select>
<ui-select v-model="origin">
<template #label>{{ $t('users.origin.title') }}</template>
<option value="combined">{{ $t('users.origin.combined') }}</option>
<option value="local">{{ $t('users.origin.local') }}</option>
<option value="remote">{{ $t('users.origin.remote') }}</option>
</ui-select>
</ui-horizon-group>
<ui-horizon-group searchboxes>
<ui-input v-model="searchUsername" type="text" spellcheck="false" @input="fetchUsers(true)">
<span>{{ $t('username') }}</span>
</ui-input>
<ui-input v-model="searchHost" type="text" spellcheck="false" @input="fetchUsers(true)" :disabled="origin === 'local'">
<span>{{ $t('host') }}</span>
</ui-input>
</ui-horizon-group>
<sequential-entrance animation="entranceFromTop" delay="25">
<x-user v-for="user in users" :key="user.id" :user='user' :click="showUserOnClick"/>
</sequential-entrance>
<ui-button v-if="existMore" @click="fetchUsers">{{ $t('@.load-more') }}</ui-button>
</section>
</ui-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import parseAcct from "../../../../misc/acct/parse";
import { faUsers, faTerminal, faSearch, faKey, faSync, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons';
import { faSnowflake, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
import XUser from './users.user.vue';
export default Vue.extend({
i18n: i18n('admin/views/users.vue'),
components: {
XUser
},
data() {
return {
user: null,
target: null,
suspending: false,
unsuspending: false,
sort: '+createdAt',
state: 'all',
origin: 'local',
searchUsername: '',
searchHost: '',
limit: 10,
offset: 0,
users: [],
existMore: false,
faTerminal, faUsers, faSnowflake, faSearch, faKey, faSync, faMicrophoneSlash, faTrashAlt
};
},
watch: {
sort() {
this.users = [];
this.offset = 0;
this.fetchUsers();
},
state() {
this.users = [];
this.offset = 0;
this.fetchUsers();
},
origin() {
if (this.origin === 'local') this.searchHost = '';
this.users = [];
this.offset = 0;
this.fetchUsers();
}
},
mounted() {
this.fetchUsers();
},
methods: {
/** テキストエリアのユーザーを解決する */
fetchUser() {
return new Promise((res) => {
const usernamePromise = this.$root.api('users/show', parseAcct(this.target));
const idPromise = this.$root.api('users/show', { userId: this.target });
let _notFound = false;
const notFound = () => {
if (_notFound) {
this.$root.dialog({
type: 'error',
text: this.$t('user-not-found')
});
} else {
_notFound = true;
}
};
usernamePromise.then(res).catch(e => {
if (e == 'user not found') {
notFound();
}
});
idPromise.then(res).catch(e => {
notFound();
});
});
},
/** テキストエリアから処理対象ユーザーを設定する */
async showUser() {
this.user = null;
const user = await this.fetchUser();
this.$root.api('admin/show-user', { userId: user.id }).then(info => {
this.user = info;
});
this.target = '';
},
async showUserOnClick(userId: string) {
this.$root.api('admin/show-user', { userId: userId }).then(info => {
this.user = info;
this.$nextTick(() => {
this.$refs.user.scrollIntoView();
});
});
},
/** 処理対象ユーザーの情報を更新する */
async refreshUser() {
this.$root.api('admin/show-user', { userId: this.user.id }).then(info => {
this.user = info;
});
},
async resetPassword() {
if (!await this.getConfirmed(this.$t('reset-password-confirm'))) return;
this.$root.api('admin/reset-password', { userId: this.user.id }).then(res => {
this.$root.dialog({
type: 'success',
text: this.$t('password-updated', { password: res.password })
});
});
},
async silenceUser() {
if (!await this.getConfirmed(this.$t('silence-confirm'))) return;
const process = async () => {
await this.$root.api('admin/silence-user', { userId: this.user.id });
this.$root.dialog({
type: 'success',
splash: true
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
this.refreshUser();
},
async unsilenceUser() {
if (!await this.getConfirmed(this.$t('unsilence-confirm'))) return;
const process = async () => {
await this.$root.api('admin/unsilence-user', { userId: this.user.id });
this.$root.dialog({
type: 'success',
splash: true
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
this.refreshUser();
},
async suspendUser() {
if (!await this.getConfirmed(this.$t('suspend-confirm'))) return;
this.suspending = true;
const process = async () => {
await this.$root.api('admin/suspend-user', { userId: this.user.id });
this.$root.dialog({
type: 'success',
text: this.$t('suspended')
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
this.suspending = false;
this.refreshUser();
},
async unsuspendUser() {
if (!await this.getConfirmed(this.$t('unsuspend-confirm'))) return;
this.unsuspending = true;
const process = async () => {
await this.$root.api('admin/unsuspend-user', { userId: this.user.id });
this.$root.dialog({
type: 'success',
text: this.$t('unsuspended')
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
this.unsuspending = false;
this.refreshUser();
},
async updateRemoteUser() {
this.$root.api('admin/update-remote-user', { userId: this.user.id }).then(res => {
this.$root.dialog({
type: 'success',
text: this.$t('remote-user-updated')
});
});
this.refreshUser();
},
async deleteAllFiles() {
if (!await this.getConfirmed(this.$t('delete-all-files-confirm'))) return;
const process = async () => {
await this.$root.api('admin/delete-all-files-of-a-user', { userId: this.user.id });
this.$root.dialog({
type: 'success',
splash: true
});
};
await process().catch(e => {
this.$root.dialog({
type: 'error',
text: e.toString()
});
});
},
async getConfirmed(text: string): Promise<Boolean> {
const confirm = await this.$root.dialog({
type: 'warning',
showCancelButton: true,
title: 'confirm',
text,
});
return !confirm.canceled;
},
fetchUsers(truncate?: boolean) {
if (truncate) this.offset = 0;
this.$root.api('admin/show-users', {
state: this.state,
origin: this.origin,
sort: this.sort,
offset: this.offset,
limit: this.limit + 1,
username: this.searchUsername,
hostname: this.searchHost
}).then(users => {
if (users.length == this.limit + 1) {
users.pop();
this.existMore = true;
} else {
this.existMore = false;
}
this.users = truncate ? users : this.users.concat(users);
this.offset += this.limit;
});
}
}
});
</script>
<style lang="stylus" scoped>
.target
margin-bottom 16px !important
.user
margin-top 32px
> .actions
margin-left 80px
</style>

View File

@ -1,47 +0,0 @@
.zoom-in-top-enter-active,
.zoom-in-top-leave-active {
opacity: 1;
transform: scaleY(1);
transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: center top;
}
.zoom-in-top-enter,
.zoom-in-top-leave-active {
opacity: 0;
transform: scaleY(0);
}
.entranceFromTop {
animation-duration: 0.5s;
animation-name: entranceFromTop;
}
@keyframes entranceFromTop {
from {
opacity: 0;
transform: translateY(-64px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes jump {
0% { transform: translateY(0); }
25% { transform: translateY(-16px); }
50% { transform: translateY(0); }
75% { transform: translateY(-8px); }
100% { transform: translateY(0); }
}
@keyframes blink {
0% { opacity: 1; }
30% { opacity: 1; }
90% { opacity: 0; }
}

View File

@ -1,84 +0,0 @@
@import "../style"
@import "../animation"
html
&.progress
&, *
cursor progress !important
html
// iOS
overflow auto
body
overflow-wrap break-word
#nprogress
pointer-events none
position absolute
z-index 65536
.bar
background var(--primary)
position fixed
z-index 65537
top 0
left 0
width 100%
height 2px
/* Fancy blur effect */
.peg
display block
position absolute
right 0
width 100px
height 100%
box-shadow 0 0 10px var(--primary), 0 0 5px var(--primary)
opacity 1
transform rotate(3deg) translate(0px, -4px)
#wait
display block
position fixed
z-index 65537
top 15px
right 15px
&:before
content ""
display block
width 18px
height 18px
box-sizing border-box
border solid 2px transparent
border-top-color var(--primary)
border-left-color var(--primary)
border-radius 50%
animation progress-spinner 400ms linear infinite
@keyframes progress-spinner
0%
transform rotate(0deg)
100%
transform rotate(360deg)
code
font-family Consolas, 'Courier New', Courier, Monaco, monospace
pre
display block
> code
display block
overflow auto
tab-size 2
[data-icon]
display inline-block

View File

@ -1,32 +0,0 @@
<template>
<router-view id="app" v-hotkey.global="keymap"></router-view>
</template>
<script lang="ts">
import Vue from 'vue';
import { url, lang } from './config';
export default Vue.extend({
computed: {
keymap(): any {
return {
'h|slash': this.help,
'd': this.dark
};
}
},
methods: {
help() {
window.open(`${url}/docs/${lang}/keyboard-shortcut`, '_blank');
},
dark() {
this.$store.commit('device/set', {
key: 'darkmode',
value: !this.$store.state.device.darkmode
});
}
}
});
</script>

View File

@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 512 512" width="512" height="512"><defs><clipPath id="_clipPath_P6eAE2OaBltOJ3gHGVajfqsOnfv4xIns"><rect width="512" height="512"/></clipPath></defs><g clip-path="url(#_clipPath_P6eAE2OaBltOJ3gHGVajfqsOnfv4xIns)"><clipPath id="_clipPath_P6q7MZAUp3XpQhVgs2GuAbegX9v4gkom"><rect x="0" y="0" width="512" height="512" transform="matrix(1,0,0,1,0,0)" fill="rgb(255,255,255)"/></clipPath><g clip-path="url(#_clipPath_P6q7MZAUp3XpQhVgs2GuAbegX9v4gkom)"><g id="Group"><g id="g4502"><g id="g5125"><g id="text4489"><path d=" M 190.093 359.243 C 167.923 359.32 148.881 345.963 139.9 330.409 C 135.104 323.615 125.617 321.198 125.482 330.409 L 125.482 372.939 C 125.482 390.026 119.253 404.799 106.794 417.258 C 94.69 429.362 79.917 435.413 62.474 435.413 C 45.387 435.413 30.614 429.362 18.155 417.258 C 6.052 404.799 0 390.026 0 372.939 L 0 139.061 C 0 125.89 3.738 113.965 11.213 103.285 C 19.045 92.25 29.012 84.596 41.116 80.325 C 47.879 77.833 54.999 76.587 62.474 76.587 C 81.697 76.587 97.716 84.062 110.531 99.013 C 117.295 106.489 121.211 110.405 122.279 110.761 C 122.279 110.761 173.043 172.145 174.467 173.213 C 175.891 174.281 180.073 182.446 190.093 182.446 C 200.112 182.446 204.829 174.281 206.253 173.213 C 207.676 172.145 258.44 110.761 258.44 110.761 C 258.796 111.117 262.534 107.201 269.654 99.013 C 282.825 84.062 299.022 76.587 318.245 76.587 C 325.364 76.587 332.484 77.833 339.603 80.325 C 351.707 84.596 361.496 92.25 368.972 103.285 C 376.803 113.965 380.719 125.89 380.719 139.061 L 380.719 372.939 C 380.719 390.026 374.489 404.799 362.03 417.258 C 349.927 429.362 335.154 435.413 317.711 435.413 C 300.624 435.413 285.851 429.362 273.391 417.258 C 261.288 404.799 255.237 390.026 255.237 372.939 L 255.237 330.409 C 254.184 318.802 243.925 326.116 240.285 330.409 C 230.674 348.208 212.262 359.167 190.093 359.243 Z M 457.535 184.448 Q 435.109 184.448 419.09 168.963 Q 403.605 152.944 403.605 130.518 Q 403.605 108.091 419.09 92.606 Q 435.109 76.587 457.535 76.587 Q 479.962 76.587 495.981 92.606 Q 512 108.091 512 130.518 Q 512 152.944 495.981 168.963 Q 479.962 184.448 457.535 184.448 Z M 458.069 195.128 Q 480.496 195.128 495.981 211.147 Q 512 227.166 512 249.592 L 512 381.482 Q 512 403.909 495.981 419.928 Q 480.496 435.413 458.069 435.413 Q 435.643 435.413 419.624 419.928 Q 403.605 403.909 403.605 381.482 L 403.605 249.592 Q 403.605 227.166 419.624 211.147 Q 435.643 195.128 458.069 195.128 Z " fill-rule="evenodd" fill="rgb(157,157,157)"/></g></g></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,30 +0,0 @@
/**
* Authorize Form
*/
import VueRouter from 'vue-router';
// Style
import './style.styl';
import init from '../init';
import Index from './views/index.vue';
import NotFound from '../common/views/pages/not-found.vue';
/**
* init
*/
init(launch => {
// Init router
const router = new VueRouter({
mode: 'history',
base: '/auth/',
routes: [
{ path: '/:token', component: Index },
{ path: '*', component: NotFound }
]
});
// Launch the app
launch(router);
});

View File

@ -1,15 +0,0 @@
@import "../app"
@import "../reset"
html
background #eee
@media (max-width 600px)
background #fff
body
margin 0
padding 32px 0
@media (max-width 600px)
padding 0

View File

@ -1,141 +0,0 @@
<template>
<div class="form">
<header>
<h1 v-html="$t('share-access', { name })"></h1>
<img :src="app.iconUrl"/>
</header>
<div class="app">
<section>
<h2>{{ app.name }}</h2>
<p class="id">{{ app.id }}</p>
<p class="description">{{ app.description }}</p>
</section>
<section>
<h2>{{ $t('permission-ask') }}</h2>
<ul>
<template v-for="p in app.permission">
<li :key="p">{{ $t(`@.permissions.${p}`) }}</li>
</template>
</ul>
</section>
</div>
<div class="action">
<button @click="cancel">{{ $t('cancel') }}</button>
<button @click="accept">{{ $t('accept') }}</button>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
export default Vue.extend({
i18n: i18n('auth/views/form.vue'),
props: ['session'],
computed: {
name(): string {
const el = document.createElement('div');
el.textContent = this.app.name
return el.innerHTML;
},
app(): any {
return this.session.app;
}
},
methods: {
cancel() {
this.$root.api('auth/deny', {
token: this.session.token
}).then(() => {
this.$emit('denied');
});
},
accept() {
this.$root.api('auth/accept', {
token: this.session.token
}).then(() => {
this.$emit('accepted');
});
}
}
});
</script>
<style lang="stylus" scoped>
.form
> header
> h1
margin 0
padding 32px 32px 20px 32px
font-size 24px
font-weight normal
color #777
i
color #77aeca
&:before
content '「'
&:after
content '」'
b
color #666
> img
display block
z-index 1
width 84px
height 84px
margin 0 auto -38px auto
border solid 5px #fff
border-radius 100%
box-shadow 0 2px 2px rgba(#000, 0.1)
> .app
padding 44px 16px 0 16px
color #555
background #eee
box-shadow 0 2px 2px rgba(#000, 0.1) inset
&:after
content ''
display block
clear both
> section
float left
width 50%
padding 8px
text-align left
> h2
margin 0
font-size 16px
color #777
> .action
padding 16px
> button
margin 0 8px
padding 0
@media (max-width 600px)
> header
> img
box-shadow none
> .app
box-shadow none
@media (max-width 500px)
> header
> h1
font-size 16px
</style>

View File

@ -1,153 +0,0 @@
<template>
<div class="index">
<main v-if="$store.getters.isSignedIn">
<p class="fetching" v-if="fetching">{{ $t('loading') }}<mk-ellipsis/></p>
<x-form
class="form"
ref="form"
v-if="state == 'waiting'"
:session="session"
@denied="state = 'denied'"
@accepted="accepted"
/>
<div class="denied" v-if="state == 'denied'">
<h1>{{ $t('denied') }}</h1>
<p>{{ $t('denied-paragraph') }}</p>
</div>
<div class="accepted" v-if="state == 'accepted'">
<h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$t('allowed') }}</h1>
<p v-if="session.app.callbackUrl">{{ $t('callback-url') }}<mk-ellipsis/></p>
<p v-if="!session.app.callbackUrl">{{ $t('please-go-back') }}</p>
</div>
<div class="error" v-if="state == 'fetch-session-error'">
<p>{{ $t('error') }}</p>
</div>
</main>
<main class="signin" v-if="!$store.getters.isSignedIn">
<h1>{{ $t('sign-in') }}</h1>
<mk-signin/>
</main>
<footer><img src="/assets/auth/icon.svg" alt="Misskey"/></footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import XForm from './form.vue';
export default Vue.extend({
i18n: i18n('auth/views/index.vue'),
components: {
XForm
},
data() {
return {
state: null,
session: null,
fetching: true
};
},
computed: {
token(): string {
return this.$route.params.token;
}
},
mounted() {
if (!this.$store.getters.isSignedIn) return;
// Fetch session
this.$root.api('auth/session/show', {
token: this.token
}).then(session => {
this.session = session;
this.fetching = false;
// 既に連携していた場合
if (this.session.app.isAuthorized) {
this.$root.api('auth/accept', {
token: this.session.token
}).then(() => {
this.accepted();
});
} else {
this.state = 'waiting';
}
}).catch(error => {
this.state = 'fetch-session-error';
this.fetching = false;
});
},
methods: {
accepted() {
this.state = 'accepted';
if (this.session.app.callbackUrl) {
location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`;
}
}
}
});
</script>
<style lang="stylus" scoped>
.index
> main
width 100%
max-width 500px
margin 0 auto
text-align center
background #fff
box-shadow 0 4px 16px rgba(#000, 0.2)
> .fetching
margin 0
padding 32px
color #555
> div:not(.form)
padding 64px
> h1
margin 0 0 8px 0
padding 0
font-size 20px
font-weight normal
> p
margin 0
color #555
&.denied > h1
color #e65050
&.accepted > h1
color #54af7c
&.signin
padding 32px 32px 16px 32px
> h1
margin 0 0 22px 0
padding 0
font-size 20px
font-weight normal
color #555
@media (max-width 600px)
max-width none
box-shadow none
@media (max-width 500px)
> div
> h1
font-size 16px
> footer
> img
display block
width 32px
height 32px
margin 16px auto
</style>

View File

@ -1,171 +0,0 @@
/**
* MISSKEY BOOT LOADER
* (ENTRY POINT)
*/
'use strict';
(async function() {
// キャッシュ削除要求があれば従う
if (localStorage.getItem('shouldFlush') == 'true') {
refresh();
return;
}
const langs = LANGS;
//#region Apply theme
const theme = localStorage.getItem('theme');
if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
}
}
//#endregion
//#region Load settings
let settings = null;
const vuex = localStorage.getItem('vuex');
if (vuex) {
settings = JSON.parse(vuex);
}
//#endregion
// Get the current url information
const url = new URL(location.href);
//#region Detect app name
let app = null;
if (`${url.pathname}/`.startsWith('/docs/')) app = 'docs';
if (`${url.pathname}/`.startsWith('/dev/')) app = 'dev';
if (`${url.pathname}/`.startsWith('/auth/')) app = 'auth';
if (`${url.pathname}/`.startsWith('/admin/')) app = 'admin';
//#endregion
// Script version
const ver = localStorage.getItem('v') || VERSION;
//#region Detect the user language
let lang = null;
if (langs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = langs.find(x => x.split('-')[0] == navigator.language);
if (lang == null) {
// Fallback
lang = 'en-US';
}
}
if (settings && settings.device.lang &&
langs.includes(settings.device.lang))
{
lang = settings.device.lang;
}
localStorage.setItem('lang', lang);
//#endregion
//#region Fetch locale data
const cachedLocale = localStorage.getItem('locale');
const localeKey = localStorage.getItem('localeKey');
let localeData = null;
if (cachedLocale == null || localeKey != `${ver}.${lang}`) {
const locale = await fetch(`/assets/locales/${lang}.json?ver=${ver}`)
.then(response => response.json());
localeData = locale;
localStorage.setItem('locale', JSON.stringify(locale));
localStorage.setItem('localeKey', `${ver}.${lang}`);
} else {
localeData = JSON.parse(cachedLocale);
}
//#endregion
// Detect the user agent
const ua = navigator.userAgent.toLowerCase();
let isMobile = /mobile|iphone|ipad|android/.test(ua) || window.innerWidth < 576;
if (settings && settings.device.appTypeForce) {
if (settings.device.appTypeForce === 'mobile') {
isMobile = true;
} else if (settings.device.appTypeForce === 'desktop') {
isMobile = false;
}
}
// Get the <head> element
const head = document.getElementsByTagName('head')[0];
// If mobile, insert the viewport meta tag
if (isMobile) {
const viewport = document.getElementsByName("viewport").item(0);
viewport.content = `${viewport.content},minimum-scale=1,maximum-scale=1,user-scalable=no`;
head.appendChild(viewport);
}
// Switch desktop or mobile version
if (app == null) {
app = isMobile ? 'mobile' : 'desktop';
}
// Load an app script
// Note: 'async' make it possible to load the script asyncly.
// 'defer' make it possible to run the script when the dom loaded.
const script = document.createElement('script');
script.src = `/assets/${app}.${ver}.js`;
script.async = true;
script.defer = true;
head.appendChild(script);
// 3秒経ってもスクリプトがロードされない場合はバージョンが古くて
// 404になっているせいかもしれないので、バージョンを確認して古ければ更新する
//
// 読み込まれたスクリプトからこのタイマーを解除できるように、
// グローバルにタイマーIDを代入しておく
window.mkBootTimer = window.setTimeout(async () => {
// Fetch meta
const res = await fetch('/api/meta', {
method: 'POST',
cache: 'no-cache'
});
// Parse
const meta = await res.json();
// Compare versions
if (meta.version != ver) {
localStorage.setItem('v', meta.version);
alert(
localeData.common._settings["update-available"] +
'\n' +
localeData.common._settings["update-available-desc"]
);
refresh();
}
}, 3000);
function refresh() {
localStorage.setItem('shouldFlush', 'false');
localStorage.removeItem('locale');
// Clear cache (service worker)
try {
navigator.serviceWorker.controller.postMessage('clear');
navigator.serviceWorker.getRegistrations().then(registrations => {
for (const registration of registrations) registration.unregister();
});
} catch (e) {
console.error(e);
}
// Force reload
location.reload(true);
}
})();

View File

@ -1,36 +0,0 @@
import { version as current } from '../../config';
export default async function($root: any, force = false, silent = false) {
const meta = await $root.getMeta(force);
const newer = meta.version;
if (newer != current) {
localStorage.setItem('should-refresh', 'true');
localStorage.setItem('v', newer);
// Clear cache (service worker)
try {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage('clear');
}
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
registration.unregister();
}
} catch (e) {
console.error(e);
}
/*if (!silent) {
$root.dialog({
title: $root.$t('@.update-available-title'),
text: $root.$t('@.update-available', { newer, current })
});
}*/
return newer;
} else {
return null;
}
}

View File

@ -1,25 +0,0 @@
/**
* Format like the uptime command
*/
export default function(sec) {
if (!sec) return sec;
const day = Math.floor(sec / 86400);
const tod = sec % 86400;
// Days part in string: 2 days, 1 day, null
const d = day >= 2 ? `${day} days` : day >= 1 ? `${day} day` : null;
// Time part in string: 1 sec, 1 min, 1:01
const t
= tod < 60 ? `${Math.floor(tod)} sec`
: tod < 3600 ? `${Math.floor(tod / 60)} min`
: `${Math.floor(tod / 60 / 60)}:${Math.floor((tod / 60) % 60).toString().padStart(2, '0')}`;
let str = '';
if (d) str += `${d}, `;
str += t;
return str;
}

View File

@ -1,11 +0,0 @@
const faces = [
'(=^・・^=)',
'v(\'ω\')v',
'🐡( \'-\' 🐡 )フグパンチ!!!!',
'✌️(´・_・`)✌️',
'(。><。)',
'(Δ・x・Δ)',
'(コ`・ヘ・´ケ)'
];
export default () => faces[Math.floor(Math.random() * faces.length)];

View File

@ -1,239 +0,0 @@
import { parse } from '../../../../mfm/parse';
import { sum, unique } from '../../../../prelude/array';
import shouldMuteNote from './should-mute-note';
import MkNoteMenu from '../views/components/note-menu.vue';
import MkReactionPicker from '../views/components/reaction-picker.vue';
import pleaseLogin from './please-login';
import i18n from '../../i18n';
function focus(el, fn) {
const target = fn(el);
if (target) {
if (target.hasAttribute('tabindex')) {
target.focus();
} else {
focus(target, fn);
}
}
}
type Opts = {
mobile?: boolean;
};
export default (opts: Opts = {}) => ({
i18n: i18n(),
data() {
return {
showContent: false,
hideThisNote: false,
openingMenu: false
};
},
computed: {
keymap(): any {
return {
'r': () => this.reply(true),
'e|a|plus': () => this.react(true),
'q': () => this.renote(true),
'f|b': this.favorite,
'delete|ctrl+d': this.del,
'ctrl+q': this.renoteDirectly,
'up|k|shift+tab': this.focusBefore,
'down|j|tab': this.focusAfter,
//'esc': this.blur,
'm|o': () => this.menu(true),
's': this.toggleShowContent,
'1': () => this.reactDirectly('like'),
'2': () => this.reactDirectly('love'),
'3': () => this.reactDirectly('laugh'),
'4': () => this.reactDirectly('hmm'),
'5': () => this.reactDirectly('surprise'),
'6': () => this.reactDirectly('congrats'),
'7': () => this.reactDirectly('angry'),
'8': () => this.reactDirectly('confused'),
'9': () => this.reactDirectly('rip'),
'0': () => this.reactDirectly('pudding'),
};
},
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
appearNote(): any {
return this.isRenote ? this.note.renote : this.note;
},
isMyNote(): boolean {
return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId);
},
reactionsCount(): number {
return this.appearNote.reactions
? sum(Object.values(this.appearNote.reactions))
: 0;
},
title(): string {
return '';
},
urls(): string[] {
if (this.appearNote.text) {
const ast = parse(this.appearNote.text);
// TODO: 再帰的にURL要素がないか調べる
const urls = unique(ast
.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
.map(t => t.node.props.url));
// unique without hash
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
const removeHash = x => x.replace(/#[^#]*$/, '');
return urls.reduce((array, url) => {
const removed = removeHash(url);
if (!array.map(x => removeHash(x)).includes(removed)) array.push(url);
return array;
}, []);
} else {
return null;
}
}
},
created() {
this.hideThisNote = shouldMuteNote(this.$store.state.i, this.$store.state.settings, this.appearNote);
},
methods: {
reply(viaKeyboard = false) {
pleaseLogin(this.$root);
this.$root.$post({
reply: this.appearNote,
animation: !viaKeyboard,
cb: () => {
this.focus();
}
});
},
renote(viaKeyboard = false) {
pleaseLogin(this.$root);
this.$root.$post({
renote: this.appearNote,
animation: !viaKeyboard,
cb: () => {
this.focus();
}
});
},
renoteDirectly() {
(this as any).api('notes/create', {
renoteId: this.appearNote.id
});
},
react(viaKeyboard = false) {
pleaseLogin(this.$root);
this.blur();
const w = this.$root.new(MkReactionPicker, {
source: this.$refs.reactButton,
showFocus: viaKeyboard,
animation: !viaKeyboard
});
w.$once('chosen', reaction => {
this.$root.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
}).then(() => {
w.close();
});
});
w.$once('closed', this.focus);
},
reactDirectly(reaction) {
this.$root.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
},
undoReact(note) {
const oldReaction = note.myReaction;
if (!oldReaction) return;
this.$root.api('notes/reactions/delete', {
noteId: note.id
});
},
favorite() {
pleaseLogin(this.$root);
this.$root.api('notes/favorites/create', {
noteId: this.appearNote.id
}).then(() => {
this.$root.dialog({
type: 'success',
splash: true
});
});
},
del() {
this.$root.dialog({
type: 'warning',
text: this.$t('@.delete-confirm'),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.$root.api('notes/delete', {
noteId: this.appearNote.id
});
});
},
menu(viaKeyboard = false) {
if (this.openingMenu) return;
this.openingMenu = true;
const w = this.$root.new(MkNoteMenu, {
source: this.$refs.menuButton,
note: this.appearNote,
animation: !viaKeyboard
}).$once('closed', () => {
this.openingMenu = false;
this.focus();
});
this.$once('hook:beforeDestroy', () => {
w.destroyDom();
});
},
toggleShowContent() {
this.showContent = !this.showContent;
},
focus() {
this.$el.focus();
},
blur() {
this.$el.blur();
},
focusBefore() {
focus(this.$el, e => e.previousElementSibling);
},
focusAfter() {
focus(this.$el, e => e.nextElementSibling);
}
}
});

View File

@ -1,149 +0,0 @@
import Vue from 'vue';
export default prop => ({
data() {
return {
connection: null
};
},
computed: {
$_ns_note_(): any {
return this[prop];
},
$_ns_isRenote(): boolean {
return (this.$_ns_note_.renote != null &&
this.$_ns_note_.text == null &&
this.$_ns_note_.fileIds.length == 0 &&
this.$_ns_note_.poll == null);
},
$_ns_target(): any {
return this.$_ns_isRenote ? this.$_ns_note_.renote : this.$_ns_note_;
},
},
created() {
if (this.$store.getters.isSignedIn) {
this.connection = this.$root.stream;
}
},
mounted() {
this.capture(true);
if (this.$store.getters.isSignedIn) {
this.connection.on('_connected_', this.onStreamConnected);
}
},
beforeDestroy() {
this.decapture(true);
if (this.$store.getters.isSignedIn) {
this.connection.off('_connected_', this.onStreamConnected);
}
},
methods: {
capture(withHandler = false) {
if (this.$store.getters.isSignedIn) {
const data = {
id: this.$_ns_target.id
} as any;
if (
(this.$_ns_target.visibleUserIds || []).includes(this.$store.state.i.id) ||
(this.$_ns_target.mentions || []).includes(this.$store.state.i.id)
) {
data.read = true;
}
this.connection.send('sn', data);
if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
}
},
decapture(withHandler = false) {
if (this.$store.getters.isSignedIn) {
this.connection.send('un', {
id: this.$_ns_target.id
});
if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
}
},
onStreamConnected() {
this.capture();
},
onStreamNoteUpdated(data) {
const { type, id, body } = data;
if (id !== this.$_ns_target.id) return;
switch (type) {
case 'reacted': {
const reaction = body.reaction;
if (this.$_ns_target.reactions == null) {
Vue.set(this.$_ns_target, 'reactions', {});
}
if (this.$_ns_target.reactions[reaction] == null) {
Vue.set(this.$_ns_target.reactions, reaction, 0);
}
// Increment the count
this.$_ns_target.reactions[reaction]++;
if (body.userId == this.$store.state.i.id) {
Vue.set(this.$_ns_target, 'myReaction', reaction);
}
break;
}
case 'unreacted': {
const reaction = body.reaction;
if (this.$_ns_target.reactions == null) {
return;
}
if (this.$_ns_target.reactions[reaction] == null) {
return;
}
// Decrement the count
if (this.$_ns_target.reactions[reaction] > 0) this.$_ns_target.reactions[reaction]--;
if (body.userId == this.$store.state.i.id) {
Vue.set(this.$_ns_target, 'myReaction', null);
}
break;
}
case 'pollVoted': {
const choice = body.choice;
this.$_ns_target.poll.choices[choice].votes++;
if (body.userId == this.$store.state.i.id) {
Vue.set(this.$_ns_target.poll.choices[choice], 'isVoted', true);
}
break;
}
case 'deleted': {
Vue.set(this.$_ns_target, 'deletedAt', body.deletedAt);
Vue.set(this.$_ns_target, 'renote', null);
this.$_ns_target.text = null;
this.$_ns_target.fileIds = [];
this.$_ns_target.poll = null;
this.$_ns_target.geo = null;
this.$_ns_target.cw = null;
break;
}
}
},
}
});

View File

@ -1,21 +0,0 @@
export type RoomInfo = {
roomType: string;
carpetColor: string;
furnitures: Furniture[];
};
export type Furniture = {
id: string; // 同じ家具が複数ある場合にそれぞれを識別するためのIDであり、家具IDではない
type: string; // こっちが家具ID(chairとか)
position: {
x: number;
y: number;
z: number;
};
rotation: {
x: number;
y: number;
z: number;
};
props?: Record<string, any>;
};

View File

@ -1,397 +0,0 @@
// 家具メタデータ
// 家具にはユーザーが設定できるプロパティを設定可能です:
//
// props: {
// <propname>: <proptype>
// }
//
// proptype一覧:
// * image ... 画像選択ダイアログを出し、その画像のURLが格納されます
// * color ... 色選択コントロールを出し、選択された色が格納されます
// 家具にカスタムテクスチャを適用できるようにするには、textureプロパティに以下の追加の情報を含めます:
// 便宜上そのUVのどの部分にカスタムテクスチャを貼り合わせるかのエリアをテクスチャエリアと呼びます。
// UVは1024*1024だと仮定します。
//
// <key>: {
// prop: <プロパティ名>,
// uv: {
// x: <テクスチャエリアX座標>,
// y: <テクスチャエリアY座標>,
// width: <テクスチャエリアの幅>,
// height: <テクスチャエリアの高さ>,
// },
// }
//
// <key>には、カスタムテクスチャを適用したいメッシュ名を指定します
// <プロパティ名>には、カスタムテクスチャとして使用する画像を格納するプロパティ(前述)名を指定します
// 家具にカスタムカラーを適用できるようにするには、colorプロパティに以下の追加の情報を含めます:
//
// <key>: <プロパティ名>
//
// <key>には、カスタムカラーを適用したいマテリアル名を指定します
// <プロパティ名>には、カスタムカラーとして使用する色を格納するプロパティ(前述)名を指定します
[
{
id: "milk",
place: "floor"
},
{
id: "bed",
place: "floor"
},
{
id: "low-table",
place: "floor",
props: {
color: 'color'
},
color: {
Table: 'color'
}
},
{
id: "desk",
place: "floor",
props: {
color: 'color'
},
color: {
Board: 'color'
}
},
{
id: "chair",
place: "floor",
props: {
color: 'color'
},
color: {
Chair: 'color'
}
},
{
id: "chair2",
place: "floor",
props: {
color1: 'color',
color2: 'color'
},
color: {
Cushion: 'color1',
Leg: 'color2'
}
},
{
id: "fan",
place: "wall"
},
{
id: "pc",
place: "floor"
},
{
id: "plant",
place: "floor"
},
{
id: "plant2",
place: "floor"
},
{
id: "eraser",
place: "floor"
},
{
id: "pencil",
place: "floor"
},
{
id: "pudding",
place: "floor"
},
{
id: "cardboard-box",
place: "floor"
},
{
id: "cardboard-box2",
place: "floor"
},
{
id: "cardboard-box3",
place: "floor"
},
{
id: "book",
place: "floor",
props: {
color: 'color'
},
color: {
Cover: 'color'
}
},
{
id: "book2",
place: "floor"
},
{
id: "piano",
place: "floor"
},
{
id: "facial-tissue",
place: "floor"
},
{
id: "server",
place: "floor"
},
{
id: "moon",
place: "floor"
},
{
id: "corkboard",
place: "wall"
},
{
id: "mousepad",
place: "floor",
props: {
color: 'color'
},
color: {
Pad: 'color'
}
},
{
id: "monitor",
place: "floor",
props: {
screen: 'image'
},
texture: {
Screen: {
prop: 'screen',
uv: {
x: 0,
y: 434,
width: 1024,
height: 588,
},
},
},
},
{
id: "tv",
place: "floor",
props: {
screen: 'image'
},
texture: {
Screen: {
prop: 'screen',
uv: {
x: 0,
y: 434,
width: 1024,
height: 588,
},
},
},
},
{
id: "keyboard",
place: "floor"
},
{
id: "carpet-stripe",
place: "floor",
props: {
color1: 'color',
color2: 'color'
},
color: {
CarpetAreaA: 'color1',
CarpetAreaB: 'color2'
},
},
{
id: "mat",
place: "floor",
props: {
color: 'color'
},
color: {
Mat: 'color'
}
},
{
id: "color-box",
place: "floor",
props: {
color: 'color'
},
color: {
main: 'color'
}
},
{
id: "wall-clock",
place: "wall"
},
{
id: "cube",
place: "floor",
props: {
color: 'color'
},
color: {
Cube: 'color'
}
},
{
id: "photoframe",
place: "wall",
props: {
photo: 'image',
color: 'color'
},
texture: {
Photo: {
prop: 'photo',
uv: {
x: 0,
y: 342,
width: 1024,
height: 683,
},
},
},
color: {
Frame: 'color'
}
},
{
id: "pinguin",
place: "floor",
props: {
body: 'color',
belly: 'color'
},
color: {
Body: 'body',
Belly: 'belly',
}
},
{
id: "rubik-cube",
place: "floor",
},
{
id: "poster-h",
place: "wall",
props: {
picture: 'image'
},
texture: {
Poster: {
prop: 'picture',
uv: {
x: 0,
y: 277,
width: 1024,
height: 745,
},
},
},
},
{
id: "poster-v",
place: "wall",
props: {
picture: 'image'
},
texture: {
Poster: {
prop: 'picture',
uv: {
x: 0,
y: 0,
width: 745,
height: 1024,
},
},
},
},
{
id: "sofa",
place: "floor",
props: {
color: 'color'
},
color: {
Sofa: 'color'
}
},
{
id: "spiral",
place: "floor",
props: {
color: 'color'
},
color: {
Step: 'color'
}
},
{
id: "bin",
place: "floor",
props: {
color: 'color'
},
color: {
Bin: 'color'
}
},
{
id: "cup-noodle",
place: "floor"
},
{
id: "holo-display",
place: "floor",
props: {
image: 'image'
},
texture: {
Image_Front: {
prop: 'image',
uv: {
x: 0,
y: 0,
width: 1024,
height: 1024,
},
},
Image_Back: {
prop: 'image',
uv: {
x: 0,
y: 0,
width: 1024,
height: 1024,
},
},
},
},
{
id: 'energy-drink',
place: "floor",
}
]

View File

@ -1,776 +0,0 @@
import autobind from 'autobind-decorator';
import { v4 as uuid } from 'uuid';
import * as THREE from 'three';
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass.js';
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import { Furniture, RoomInfo } from './furniture';
import { query as urlQuery } from '../../../../../prelude/url';
const furnitureDefs = require('./furnitures.json5');
THREE.ImageUtils.crossOrigin = '';
type Options = {
graphicsQuality: Room['graphicsQuality'];
onChangeSelect: Room['onChangeSelect'];
useOrthographicCamera: boolean;
};
/**
* MisskeyRoom Core Engine
*/
export class Room {
private clock: THREE.Clock;
private scene: THREE.Scene;
private renderer: THREE.WebGLRenderer;
private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
private controls: OrbitControls;
private composer: EffectComposer;
private mixers: THREE.AnimationMixer[] = [];
private furnitureControl: TransformControls;
private roomInfo: RoomInfo;
private graphicsQuality: 'cheep' | 'low' | 'medium' | 'high' | 'ultra';
private roomObj: THREE.Object3D;
private objects: THREE.Object3D[] = [];
private selectedObject: THREE.Object3D = null;
private onChangeSelect: Function;
private isTransformMode = false;
private renderFrameRequestId: number;
private get canvas(): HTMLCanvasElement {
return this.renderer.domElement;
}
private get furnitures(): Furniture[] {
return this.roomInfo.furnitures;
}
private set furnitures(furnitures: Furniture[]) {
this.roomInfo.furnitures = furnitures;
}
private get enableShadow() {
return this.graphicsQuality != 'cheep';
}
private get usePostFXs() {
return this.graphicsQuality !== 'cheep' && this.graphicsQuality !== 'low';
}
private get shadowQuality() {
return (
this.graphicsQuality === 'ultra' ? 16384 :
this.graphicsQuality === 'high' ? 8192 :
this.graphicsQuality === 'medium' ? 4096 :
this.graphicsQuality === 'low' ? 1024 :
0); // cheep
}
constructor(user, isMyRoom, roomInfo: RoomInfo, container, options: Options) {
this.roomInfo = roomInfo;
this.graphicsQuality = options.graphicsQuality;
this.onChangeSelect = options.onChangeSelect;
this.clock = new THREE.Clock(true);
//#region Init a scene
this.scene = new THREE.Scene();
const width = window.innerWidth;
const height = window.innerHeight;
//#region Init a renderer
this.renderer = new THREE.WebGLRenderer({
antialias: false,
stencil: false,
alpha: false,
powerPreference:
this.graphicsQuality === 'ultra' ? 'high-performance' :
this.graphicsQuality === 'high' ? 'high-performance' :
this.graphicsQuality === 'medium' ? 'default' :
this.graphicsQuality === 'low' ? 'low-power' :
'low-power' // cheep
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(width, height);
this.renderer.autoClear = false;
this.renderer.setClearColor(new THREE.Color(0x051f2d));
this.renderer.shadowMap.enabled = this.enableShadow;
this.renderer.shadowMap.type =
this.graphicsQuality === 'ultra' ? THREE.PCFSoftShadowMap :
this.graphicsQuality === 'high' ? THREE.PCFSoftShadowMap :
this.graphicsQuality === 'medium' ? THREE.PCFShadowMap :
this.graphicsQuality === 'low' ? THREE.BasicShadowMap :
THREE.BasicShadowMap; // cheep
container.appendChild(this.canvas);
//#endregion
//#region Init a camera
this.camera = options.useOrthographicCamera
? new THREE.OrthographicCamera(
width / - 2, width / 2, height / 2, height / - 2, -10, 10)
: new THREE.PerspectiveCamera(45, width / height);
if (options.useOrthographicCamera) {
this.camera.position.x = 2;
this.camera.position.y = 2;
this.camera.position.z = 2;
this.camera.zoom = 100;
this.camera.updateProjectionMatrix();
} else {
this.camera.position.x = 5;
this.camera.position.y = 2;
this.camera.position.z = 5;
}
this.scene.add(this.camera);
//#endregion
//#region AmbientLight
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
this.scene.add(ambientLight);
//#endregion
if (this.graphicsQuality !== 'cheep') {
//#region Room light
const roomLight = new THREE.SpotLight(0xffffff, 0.1);
roomLight.position.set(0, 8, 0);
roomLight.castShadow = this.enableShadow;
roomLight.shadow.bias = -0.0001;
roomLight.shadow.mapSize.width = this.shadowQuality;
roomLight.shadow.mapSize.height = this.shadowQuality;
roomLight.shadow.camera.near = 0.1;
roomLight.shadow.camera.far = 9;
roomLight.shadow.camera.fov = 45;
this.scene.add(roomLight);
//#endregion
}
//#region Out light
const outLight1 = new THREE.SpotLight(0xffffff, 0.4);
outLight1.position.set(9, 3, -2);
outLight1.castShadow = this.enableShadow;
outLight1.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
outLight1.shadow.mapSize.width = this.shadowQuality;
outLight1.shadow.mapSize.height = this.shadowQuality;
outLight1.shadow.camera.near = 6;
outLight1.shadow.camera.far = 15;
outLight1.shadow.camera.fov = 45;
this.scene.add(outLight1);
const outLight2 = new THREE.SpotLight(0xffffff, 0.2);
outLight2.position.set(-2, 3, 9);
outLight2.castShadow = false;
outLight2.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
outLight2.shadow.camera.near = 6;
outLight2.shadow.camera.far = 15;
outLight2.shadow.camera.fov = 45;
this.scene.add(outLight2);
//#endregion
//#region Init a controller
this.controls = new OrbitControls(this.camera, this.canvas);
this.controls.target.set(0, 1, 0);
this.controls.enableZoom = true;
this.controls.enablePan = isMyRoom;
this.controls.minPolarAngle = 0;
this.controls.maxPolarAngle = Math.PI / 2;
this.controls.minAzimuthAngle = 0;
this.controls.maxAzimuthAngle = Math.PI / 2;
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.2;
this.controls.mouseButtons.LEFT = 1;
this.controls.mouseButtons.MIDDLE = 2;
this.controls.mouseButtons.RIGHT = 0;
//#endregion
//#region POST FXs
if (!this.usePostFXs) {
this.composer = null;
} else {
const renderTarget = new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBFormat,
stencilBuffer: false,
});
const fxaa = new ShaderPass(FXAAShader);
fxaa.uniforms['resolution'].value = new THREE.Vector2(1 / width, 1 / height);
fxaa.renderToScreen = true;
this.composer = new EffectComposer(this.renderer, renderTarget);
this.composer.addPass(new RenderPass(this.scene, this.camera));
if (this.graphicsQuality === 'ultra') {
this.composer.addPass(new BloomPass(0.25, 30, 128.0, 512));
}
this.composer.addPass(fxaa);
}
//#endregion
//#endregion
//#region Label
//#region Avatar
const avatarUrl = `/proxy/?${urlQuery({ url: user.avatarUrl })}`;
const textureLoader = new THREE.TextureLoader();
textureLoader.crossOrigin = 'anonymous';
const iconTexture = textureLoader.load(avatarUrl);
iconTexture.wrapS = THREE.RepeatWrapping;
iconTexture.wrapT = THREE.RepeatWrapping;
iconTexture.anisotropy = 16;
const avatarMaterial = new THREE.MeshBasicMaterial({
map: iconTexture,
side: THREE.DoubleSide,
alphaTest: 0.5
});
const iconGeometry = new THREE.PlaneGeometry(1, 1);
const avatarObject = new THREE.Mesh(iconGeometry, avatarMaterial);
avatarObject.position.set(-3, 2.5, 2);
avatarObject.rotation.y = Math.PI / 2;
avatarObject.castShadow = false;
this.scene.add(avatarObject);
//#endregion
//#region Username
const name = user.username;
new THREE.FontLoader().load('/assets/fonts/helvetiker_regular.typeface.json', font => {
const nameGeometry = new THREE.TextGeometry(name, {
size: 0.5,
height: 0,
curveSegments: 8,
font: font,
bevelThickness: 0,
bevelSize: 0,
bevelEnabled: false
});
const nameMaterial = new THREE.MeshLambertMaterial({
color: 0xffffff
});
const nameObject = new THREE.Mesh(nameGeometry, nameMaterial);
nameObject.position.set(-3, 2.25, 1.25);
nameObject.rotation.y = Math.PI / 2;
nameObject.castShadow = false;
this.scene.add(nameObject);
});
//#endregion
//#endregion
//#region Interaction
if (isMyRoom) {
this.furnitureControl = new TransformControls(this.camera, this.canvas);
this.scene.add(this.furnitureControl);
// Hover highlight
this.canvas.onmousemove = this.onmousemove;
// Click
this.canvas.onmousedown = this.onmousedown;
}
//#endregion
//#region Init room
this.loadRoom();
//#endregion
//#region Load furnitures
for (const furniture of this.furnitures) {
this.loadFurniture(furniture).then(obj => {
this.scene.add(obj.scene);
this.objects.push(obj.scene);
});
}
//#endregion
// Start render
if (this.usePostFXs) {
this.renderWithPostFXs();
} else {
this.renderWithoutPostFXs();
}
}
@autobind
private renderWithoutPostFXs() {
this.renderFrameRequestId =
window.requestAnimationFrame(this.renderWithoutPostFXs);
// Update animations
const clock = this.clock.getDelta();
for (const mixer of this.mixers) {
mixer.update(clock);
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
@autobind
private renderWithPostFXs() {
this.renderFrameRequestId =
window.requestAnimationFrame(this.renderWithPostFXs);
// Update animations
const clock = this.clock.getDelta();
for (const mixer of this.mixers) {
mixer.update(clock);
}
this.controls.update();
this.renderer.clear();
this.composer.render();
}
@autobind
private loadRoom() {
const type = this.roomInfo.roomType;
new GLTFLoader().load(`/assets/room/rooms/${type}/${type}.glb`, gltf => {
gltf.scene.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
child.receiveShadow = this.enableShadow;
child.material = new THREE.MeshLambertMaterial({
color: (child.material as THREE.MeshStandardMaterial).color,
map: (child.material as THREE.MeshStandardMaterial).map,
name: (child.material as THREE.MeshStandardMaterial).name,
});
// 異方性フィルタリング
if ((child.material as THREE.MeshLambertMaterial).map && this.graphicsQuality !== 'cheep') {
(child.material as THREE.MeshLambertMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
(child.material as THREE.MeshLambertMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
(child.material as THREE.MeshLambertMaterial).map.anisotropy = 8;
}
});
gltf.scene.position.set(0, 0, 0);
this.scene.add(gltf.scene);
this.roomObj = gltf.scene;
if (this.roomInfo.roomType === 'default') {
this.applyCarpetColor();
}
});
}
@autobind
private loadFurniture(furniture: Furniture) {
const def = furnitureDefs.find(d => d.id === furniture.type);
return new Promise<GLTF>((res, rej) => {
const loader = new GLTFLoader();
loader.load(`/assets/room/furnitures/${furniture.type}/${furniture.type}.glb`, gltf => {
const model = gltf.scene;
// Load animation
if (gltf.animations.length > 0) {
const mixer = new THREE.AnimationMixer(model);
this.mixers.push(mixer);
for (const clip of gltf.animations) {
mixer.clipAction(clip).play();
}
}
model.name = furniture.id;
model.position.x = furniture.position.x;
model.position.y = furniture.position.y;
model.position.z = furniture.position.z;
model.rotation.x = furniture.rotation.x;
model.rotation.y = furniture.rotation.y;
model.rotation.z = furniture.rotation.z;
model.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
child.castShadow = this.enableShadow;
child.receiveShadow = this.enableShadow;
(child.material as THREE.MeshStandardMaterial).metalness = 0;
// 異方性フィルタリング
if ((child.material as THREE.MeshStandardMaterial).map && this.graphicsQuality !== 'cheep') {
(child.material as THREE.MeshStandardMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
(child.material as THREE.MeshStandardMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
(child.material as THREE.MeshStandardMaterial).map.anisotropy = 8;
}
});
if (def.color) { // カスタムカラー
this.applyCustomColor(model);
}
if (def.texture) { // カスタムテクスチャ
this.applyCustomTexture(model);
}
res(gltf);
}, null, rej);
});
}
@autobind
private applyCarpetColor() {
this.roomObj.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
if (child.material &&
(child.material as THREE.MeshStandardMaterial).name &&
(child.material as THREE.MeshStandardMaterial).name === 'Carpet'
) {
const colorHex = parseInt(this.roomInfo.carpetColor.substr(1), 16);
(child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
}
});
}
@autobind
private applyCustomColor(model: THREE.Object3D) {
const furniture = this.furnitures.find(furniture => furniture.id === model.name);
const def = furnitureDefs.find(d => d.id === furniture.type);
if (def.color == null) return;
model.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
for (const t of Object.keys(def.color)) {
if (!child.material ||
!(child.material as THREE.MeshStandardMaterial).name ||
(child.material as THREE.MeshStandardMaterial).name !== t
) continue;
const prop = def.color[t];
const val = furniture.props ? furniture.props[prop] : undefined;
if (val == null) continue;
const colorHex = parseInt(val.substr(1), 16);
(child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
}
});
}
@autobind
private applyCustomTexture(model: THREE.Object3D) {
const furniture = this.furnitures.find(furniture => furniture.id === model.name);
const def = furnitureDefs.find(d => d.id === furniture.type);
if (def.texture == null) return;
model.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
for (const t of Object.keys(def.texture)) {
if (child.name !== t) continue;
const prop = def.texture[t].prop;
const val = furniture.props ? furniture.props[prop] : undefined;
if (val == null) continue;
const canvas = document.createElement('canvas');
canvas.height = 1024;
canvas.width = 1024;
child.material = new THREE.MeshLambertMaterial({
emissive: 0x111111,
side: THREE.DoubleSide,
alphaTest: 0.5,
});
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const uvInfo = def.texture[t].uv;
const ctx = canvas.getContext('2d');
ctx.drawImage(img,
0, 0, img.width, img.height,
uvInfo.x, uvInfo.y, uvInfo.width, uvInfo.height);
const texture = new THREE.Texture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.anisotropy = 16;
texture.flipY = false;
(child.material as THREE.MeshLambertMaterial).map = texture;
(child.material as THREE.MeshLambertMaterial).needsUpdate = true;
(child.material as THREE.MeshLambertMaterial).map.needsUpdate = true;
};
img.src = val;
}
});
}
@autobind
private onmousemove(ev: MouseEvent) {
if (this.isTransformMode) return;
const rect = (ev.target as HTMLElement).getBoundingClientRect();
const x = (((ev.clientX * window.devicePixelRatio) - rect.left) / this.canvas.width) * 2 - 1;
const y = -(((ev.clientY * window.devicePixelRatio) - rect.top) / this.canvas.height) * 2 + 1;
const pos = new THREE.Vector2(x, y);
this.camera.updateMatrixWorld();
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pos, this.camera);
const intersects = raycaster.intersectObjects(this.objects, true);
for (const object of this.objects) {
if (this.isSelectedObject(object)) continue;
object.traverse(child => {
if (child instanceof THREE.Mesh) {
(child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
}
});
}
if (intersects.length > 0) {
const intersected = this.getRoot(intersects[0].object);
if (this.isSelectedObject(intersected)) return;
intersected.traverse(child => {
if (child instanceof THREE.Mesh) {
(child.material as THREE.MeshStandardMaterial).emissive.setHex(0x191919);
}
});
}
}
@autobind
private onmousedown(ev: MouseEvent) {
if (this.isTransformMode) return;
if (ev.target !== this.canvas || ev.button !== 0) return;
const rect = (ev.target as HTMLElement).getBoundingClientRect();
const x = (((ev.clientX * window.devicePixelRatio) - rect.left) / this.canvas.width) * 2 - 1;
const y = -(((ev.clientY * window.devicePixelRatio) - rect.top) / this.canvas.height) * 2 + 1;
const pos = new THREE.Vector2(x, y);
this.camera.updateMatrixWorld();
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pos, this.camera);
const intersects = raycaster.intersectObjects(this.objects, true);
for (const object of this.objects) {
object.traverse(child => {
if (child instanceof THREE.Mesh) {
(child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
}
});
}
if (intersects.length > 0) {
const selectedObj = this.getRoot(intersects[0].object);
this.selectFurniture(selectedObj);
} else {
this.selectedObject = null;
this.onChangeSelect(null);
}
}
@autobind
private getRoot(obj: THREE.Object3D): THREE.Object3D {
let found = false;
let x = obj.parent;
while (!found) {
if (x.parent.parent == null) {
found = true;
} else {
x = x.parent;
}
}
return x;
}
@autobind
private isSelectedObject(obj: THREE.Object3D): boolean {
if (this.selectedObject == null) {
return false;
} else {
return obj.name === this.selectedObject.name;
}
}
@autobind
private selectFurniture(obj: THREE.Object3D) {
this.selectedObject = obj;
this.onChangeSelect(obj);
obj.traverse(child => {
if (child instanceof THREE.Mesh) {
(child.material as THREE.MeshStandardMaterial).emissive.setHex(0xff0000);
}
});
}
/**
* 家具の移動/回転モードにします
* @param type 移動か回転か
*/
@autobind
public enterTransformMode(type: 'translate' | 'rotate') {
this.isTransformMode = true;
this.furnitureControl.setMode(type);
this.furnitureControl.attach(this.selectedObject);
}
/**
* 家具の移動/回転モードを終了します
*/
@autobind
public exitTransformMode() {
this.isTransformMode = false;
this.furnitureControl.detach();
}
/**
* 家具プロパティを更新します
* @param key プロパティ名
* @param value 値
*/
@autobind
public updateProp(key: string, value: any) {
const furniture = this.furnitures.find(furniture => furniture.id === this.selectedObject.name);
if (furniture.props == null) furniture.props = {};
furniture.props[key] = value;
this.applyCustomColor(this.selectedObject);
this.applyCustomTexture(this.selectedObject);
}
/**
* 部屋に家具を追加します
* @param type 家具の種類
*/
@autobind
public addFurniture(type: string) {
const furniture = {
id: uuid(),
type: type,
position: {
x: 0,
y: 0,
z: 0,
},
rotation: {
x: 0,
y: 0,
z: 0,
},
};
this.furnitures.push(furniture);
this.loadFurniture(furniture).then(obj => {
this.scene.add(obj.scene);
this.objects.push(obj.scene);
});
}
/**
* 現在選択されている家具を部屋から削除します
*/
@autobind
public removeFurniture() {
this.exitTransformMode();
const obj = this.selectedObject;
this.scene.remove(obj);
this.objects = this.objects.filter(object => object.name !== obj.name);
this.furnitures = this.furnitures.filter(furniture => furniture.id !== obj.name);
this.selectedObject = null;
this.onChangeSelect(null);
}
/**
* 全ての家具を部屋から削除します
*/
@autobind
public removeAllFurnitures() {
this.exitTransformMode();
for (const obj of this.objects) {
this.scene.remove(obj);
}
this.objects = [];
this.furnitures = [];
this.selectedObject = null;
this.onChangeSelect(null);
}
/**
* 部屋の床の色を変更します
* @param color 色
*/
@autobind
public updateCarpetColor(color: string) {
this.roomInfo.carpetColor = color;
this.applyCarpetColor();
}
/**
* 部屋の種類を変更します
* @param type 種類
*/
@autobind
public changeRoomType(type: string) {
this.roomInfo.roomType = type;
this.scene.remove(this.roomObj);
this.loadRoom();
}
/**
* 部屋データを取得します
*/
@autobind
public getRoomInfo() {
for (const obj of this.objects) {
const furniture = this.furnitures.find(f => f.id === obj.name);
furniture.position.x = obj.position.x;
furniture.position.y = obj.position.y;
furniture.position.z = obj.position.z;
furniture.rotation.x = obj.rotation.x;
furniture.rotation.y = obj.rotation.y;
furniture.rotation.z = obj.rotation.z;
}
return this.roomInfo;
}
/**
* 選択されている家具を取得します
*/
@autobind
public getSelectedObject() {
return this.selectedObject;
}
@autobind
public findFurnitureById(id: string) {
return this.furnitures.find(furniture => furniture.id === id);
}
/**
* レンダリングを終了します
*/
@autobind
public destroy() {
// Stop render loop
window.cancelAnimationFrame(this.renderFrameRequestId);
this.controls.dispose();
this.scene.dispose();
}
}

View File

@ -1,19 +0,0 @@
export default function(me, settings, note) {
const isMyNote = me && (note.userId == me.id);
const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null;
const includesMutedWords = (text: string) =>
text
? settings.mutedWords.some(q => q.length > 0 && !q.some(word =>
word.startsWith('/') && word.endsWith('/') ? !(new RegExp(word.substr(1, word.length - 2)).test(text)) : !text.includes(word)))
: false;
return (
(!isMyNote && note.reply && includesMutedWords(note.reply.text)) ||
(!isMyNote && note.renote && includesMutedWords(note.renote.text)) ||
(!settings.showMyRenotes && isMyNote && isPureRenote) ||
(!settings.showRenotedMyNotes && isPureRenote && note.renote.userId == me.id) ||
(!settings.showLocalRenotes && isPureRenote && note.renote.user.host == null) ||
(!isMyNote && includesMutedWords(note.text))
);
}

View File

@ -1,18 +0,0 @@
export default {
install(Vue) {
Vue.directive('size', {
inserted(el, binding) {
const query = binding.value;
const width = el.clientWidth;
for (const q of query) {
if (q.lt && (width <= q.lt)) {
el.classList.add(q.class);
}
if (q.gt && (width >= q.gt)) {
el.classList.add(q.class);
}
}
}
});
}
};

View File

@ -1,31 +0,0 @@
<template>
<span class="mk-acct" v-once>
<span class="name">@{{ user.username }}</span>
<span class="host" :class="{ fade: $store.state.settings.contrastedAcct }" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span>
<fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import { host } from '../../../config';
import { toUnicode } from 'punycode';
export default Vue.extend({
props: ['user', 'detail'],
data() {
return {
host: toUnicode(host)
};
}
});
</script>
<style lang="stylus" scoped>
.mk-acct
> .host.fade
opacity 0.5
> .locked
opacity 0.8
margin-left 0.5em
</style>

View File

@ -1,140 +0,0 @@
<template>
<svg class="mk-analog-clock" viewBox="0 0 10 10" preserveAspectRatio="none">
<circle v-for="angle, i in graduations"
:cx="5 + (Math.sin(angle) * (5 - graduationsPadding))"
:cy="5 - (Math.cos(angle) * (5 - graduationsPadding))"
:r="i % 5 == 0 ? 0.125 : 0.05"
:fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor"/>
<line
:x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))"
:y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))"
:x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
:y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
:stroke="sHandColor"
stroke-width="0.05"/>
<line
:x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))"
:y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))"
:x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
:y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
:stroke="mHandColor"
stroke-width="0.1"/>
<line
:x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))"
:y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))"
:x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
:y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
:stroke="hHandColor"
stroke-width="0.1"/>
</svg>
</template>
<script lang="ts">
import Vue from 'vue';
import * as tinycolor from 'tinycolor2';
export default Vue.extend({
props: {
dark: {
type: Boolean,
default: false
},
smooth: {
type: Boolean,
default: false
}
},
data() {
return {
now: new Date(),
enabled: true,
graduationsPadding: 0.5,
handsPadding: 1,
handsTailLength: 0.7,
hHandLengthRatio: 0.75,
mHandLengthRatio: 1,
sHandLengthRatio: 1
};
},
computed: {
majorGraduationColor(): string {
return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
},
minorGraduationColor(): string {
return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
},
sHandColor(): string {
return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
},
mHandColor(): string {
return this.dark ? '#fff' : '#777';
},
hHandColor(): string {
return tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--primary')).toHexString();
},
ms(): number {
return this.now.getMilliseconds() * this.smooth;
},
s(): number {
return this.now.getSeconds();
},
m(): number {
return this.now.getMinutes();
},
h(): number {
return this.now.getHours();
},
hAngle(): number {
return Math.PI * (this.h % 12 + (this.m + (this.s + this.ms / 1000) / 60) / 60) / 6;
},
mAngle(): number {
return Math.PI * (this.m + (this.s + this.ms / 1000) / 60) / 30;
},
sAngle(): number {
return Math.PI * (this.s + this.ms / 1000) / 30;
},
graduations(): any {
const angles = [];
for (let i = 0; i < 60; i++) {
const angle = Math.PI * i / 30;
angles.push(angle);
}
return angles;
}
},
mounted() {
const update = () => {
if (this.enabled) {
this.tick();
requestAnimationFrame(update);
}
};
update();
},
beforeDestroy() {
this.enabled = false;
},
methods: {
tick() {
this.now = new Date();
}
}
});
</script>
<style lang="stylus" scoped>
.mk-analog-clock
display block
</style>

View File

@ -1,116 +0,0 @@
<template>
<span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick" v-once>
<span class="inner" :style="icon"></span>
</span>
<span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick" v-once>
<span class="inner" :style="icon"></span>
</span>
<router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id" v-once>
<span class="inner" :style="icon"></span>
</router-link>
<router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview" v-once>
<span class="inner" :style="icon"></span>
</router-link>
</template>
<script lang="ts">
import Vue from 'vue';
import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url';
export default Vue.extend({
props: {
user: {
type: Object,
required: true
},
target: {
required: false,
default: null
},
disableLink: {
required: false,
default: false
},
disablePreview: {
required: false,
default: false
}
},
computed: {
lightmode(): boolean {
return this.$store.state.device.lightmode;
},
cat(): boolean {
return this.user.isCat && this.$store.state.settings.circleIcons;
},
style(): any {
return {
borderRadius: this.$store.state.settings.circleIcons ? '100%' : null
};
},
url(): string {
return this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(this.user.avatarUrl)
: this.user.avatarUrl;
},
icon(): any {
return {
backgroundColor: this.user.avatarColor,
backgroundImage: this.lightmode ? null : `url(${this.url})`,
borderRadius: this.$store.state.settings.circleIcons ? '100%' : null
};
}
},
mounted() {
if (this.user.avatarColor) {
this.$el.style.color = this.user.avatarColor;
}
},
methods: {
onClick(e) {
this.$emit('click', e);
}
}
});
</script>
<style lang="stylus" scoped>
.mk-avatar
display inline-block
vertical-align bottom
flex-shrink 0
&:not(.cat)
overflow hidden
border-radius 8px
&.cat::before,
&.cat::after
background #df548f
border solid 4px currentColor
box-sizing border-box
content ''
display inline-block
height 50%
width 50%
&.cat::before
border-radius 0 75% 75%
transform rotate(37.5deg) skew(30deg)
&.cat::after
border-radius 75% 0 75% 75%
transform rotate(-37.5deg) skew(-30deg)
.inner
background-position center center
background-size cover
bottom 0
left 0
position absolute
right 0
top 0
transition border-radius 1s ease
z-index 1
</style>

View File

@ -1,148 +0,0 @@
<template>
<div class="troubleshooter">
<div class="body">
<h1><fa icon="wrench"/>{{ $t('title') }}</h1>
<div>
<p :data-wip="network == null">
<template v-if="network != null">
<template v-if="network"><fa icon="check"/></template>
<template v-if="!network"><fa icon="times"/></template>
</template>
{{ network == null ? this.$t('checking-network') : this.$t('network') }}<mk-ellipsis v-if="network == null"/>
</p>
<p v-if="network == true" :data-wip="internet == null">
<template v-if="internet != null">
<template v-if="internet"><fa icon="check"/></template>
<template v-if="!internet"><fa icon="times"/></template>
</template>
{{ internet == null ? this.$t('checking-internet') : this.$t('internet') }}<mk-ellipsis v-if="internet == null"/>
</p>
<p v-if="internet == true" :data-wip="server == null">
<template v-if="server != null">
<template v-if="server"><fa icon="check"/></template>
<template v-if="!server"><fa icon="times"/></template>
</template>
{{ server == null ? this.$t('checking-server') : this.$t('server') }}<mk-ellipsis v-if="server == null"/>
</p>
</div>
<p v-if="!end">{{ $t('finding') }}<mk-ellipsis/></p>
<p v-if="network === false"><b><fa icon="exclamation-triangle"/>{{ $t('no-network') }}</b><br>{{ $t('no-network-desc') }}</p>
<p v-if="internet === false"><b><fa icon="exclamation-triangle"/>{{ $t('no-internet') }}</b><br>{{ $t('no-internet-desc') }}</p>
<p v-if="server === false"><b><fa icon="exclamation-triangle"/>{{ $t('no-server') }}</b><br>{{ $t('no-server-desc') }}</p>
<p v-if="server === true" class="success"><b><fa icon="info-circle"/>{{ $t('success') }}</b><br>{{ $t('success-desc') }}</p>
</div>
<footer>
<a href="/assets/flush.html">{{ $t('flush') }}</a> | <a href="/assets/version.html">{{ $t('set-version') }}</a>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import { apiUrl } from '../../../config';
export default Vue.extend({
i18n: i18n('common/views/components/connect-failed.troubleshooter.vue'),
data() {
return {
network: navigator.onLine,
end: false,
internet: null,
server: null
};
},
mounted() {
if (!this.network) {
this.end = true;
return;
}
// Check internet connection
fetch(`https://google.com?rand=${Math.random()}`, {
mode: 'no-cors'
}).then(() => {
this.internet = true;
// Check misskey server is available
fetch(`${apiUrl}/meta`).then(() => {
this.end = true;
this.server = true;
})
.catch(() => {
this.end = true;
this.server = false;
});
})
.catch(() => {
this.end = true;
this.internet = false;
});
}
});
</script>
<style lang="stylus" scoped>
.troubleshooter
margin-top 1em
> .body
width 100%
max-width 500px
margin 0 auto
text-align left
background #fff
border-radius 8px
border solid 1px #ddd
> h1
margin 0
padding 0.6em 1.2em
font-size 1em
color #444
border-bottom solid 1px #eee
> [data-icon]
margin-right 0.25em
> div
overflow hidden
padding 0.6em 1.2em
> p
margin 0.5em 0
font-size 0.9em
color #444
&[data-wip]
color #888
> [data-icon]
margin-right 0.25em
&.times
color #e03524
&.check
color #84c32f
> p
margin 0
padding 0.7em 1.2em
font-size 1em
color #444
border-top solid 1px #eee
> b
> [data-icon]
margin-right 0.25em
&.success
> b
color #39adad
&:not(.success)
> b
color #ad4339
</style>

View File

@ -1,105 +0,0 @@
<template>
<div class="mk-connect-failed">
<img src="/assets/error.jpg" onerror="this.src='https://raw.githubusercontent.com/syuilo/misskey/develop/src/client/assets/error.jpg';" alt=""/>
<h1>{{ $t('title') }}</h1>
<p class="text">
<span>{{ this.$t('description').substr(0, this.$t('description').indexOf('{')) }}</span>
<a @click="reload">{{ this.$t('description').match(/\{(.+?)\}/)[1] }}</a>
<span>{{ this.$t('description').substr(this.$t('description').indexOf('}') + 1) }}</span>
</p>
<button v-if="!troubleshooting" @click="troubleshooting = true">{{ $t('troubleshoot') }}</button>
<x-troubleshooter v-if="troubleshooting"/>
<p class="thanks">{{ $t('thanks') }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XTroubleshooter from './connect-failed.troubleshooter.vue';
export default Vue.extend({
i18n: i18n('common/views/components/connect-failed.vue'),
components: {
XTroubleshooter
},
data() {
return {
troubleshooting: false
};
},
mounted() {
document.title = 'Oops!';
document.documentElement.style.setProperty('background', '#f8f8f8', 'important');
},
methods: {
reload() {
location.reload(true);
}
}
});
</script>
<style lang="stylus" scoped>
.mk-connect-failed
width 100%
padding 32px 18px
text-align center
> img
display block
height 200px
margin 0 auto
pointer-events none
user-select none
> h1
display block
margin 1.25em auto 0.65em auto
font-size 1.5em
color #555
> .text
display block
margin 0 auto
max-width 600px
font-size 1em
color #666
> button
display block
margin 1em auto 0 auto
padding 8px 10px
color var(--primaryForeground)
background var(--primary)
&:focus
outline solid 3px var(--primaryAlpha03)
&:hover
background var(--primaryLighten10)
&:active
background var(--primaryDarken10)
> .thanks
display block
margin 2em auto 0 auto
padding 2em 0 0 0
max-width 600px
font-size 0.9em
font-style oblique
color #aaa
border-top solid 1px #eee
@media (max-width 500px)
padding 24px 18px
font-size 80%
> img
height 150px
</style>

View File

@ -1,70 +0,0 @@
<template>
<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh" @click="toggle">
<b>{{ value ? this.$t('hide') : this.$t('show') }}</b>
<span v-if="!value">{{ this.label }}</span>
</button>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import { length } from 'stringz';
import { concat } from '../../../../../prelude/array';
export default Vue.extend({
i18n: i18n('common/views/components/cw-button.vue'),
props: {
value: {
type: Boolean,
required: true
},
note: {
type: Object,
required: true
}
},
computed: {
label(): string {
return concat([
this.note.text ? [this.$t('chars', { count: length(this.note.text) })] : [],
this.note.files && this.note.files.length !== 0 ? [this.$t('files', { count: this.note.files.length }) ] : [],
this.note.poll != null ? [this.$t('poll')] : []
] as string[][]).join(' / ');
}
},
methods: {
length,
toggle() {
this.$emit('input', !this.value);
}
}
});
</script>
<style lang="stylus" scoped>
.nrvgflfuaxwgkxoynpnumyookecqrrvh
display inline-block
padding 4px 8px
font-size 0.7em
color var(--cwButtonFg)
background var(--cwButtonBg)
border-radius 2px
cursor pointer
user-select none
&:hover
background var(--cwButtonHoverBg)
> span
margin-left 4px
&:before
content '('
&:after
content ')'
</style>

View File

@ -1,263 +0,0 @@
<template>
<ui-modal
ref="modal"
class="modal"
:class="{ splash }"
:close-anime-duration="300"
:close-on-bg-click="false"
@bg-click="onBgClick"
@before-close="onBeforeClose">
<div class="main" ref="main" :class="{ round: $store.state.device.roundedCorners }">
<template v-if="type == 'signin'">
<mk-signin/>
</template>
<template v-else>
<div class="icon" v-if="icon">
<fa :icon="icon"/>
</div>
<div class="icon" v-else-if="!input && !select && !user" :class="type">
<fa icon="check" v-if="type === 'success'"/>
<fa :icon="faTimesCircle" v-if="type === 'error'"/>
<fa icon="exclamation-triangle" v-if="type === 'warning'"/>
<fa icon="info-circle" v-if="type === 'info'"/>
<fa :icon="faQuestionCircle" v-if="type === 'question'"/>
<fa icon="spinner" pulse v-if="type === 'waiting'"/>
</div>
<header v-if="title" v-html="title"></header>
<header v-if="title == null && user">{{ $t('@.enter-username') }}</header>
<div class="body" v-if="text" v-html="text"></div>
<ui-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></ui-input>
<ui-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></ui-input>
<ui-select v-if="select" v-model="selectedValue" autofocus>
<template v-if="select.items">
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
</template>
<template v-else>
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
</optgroup>
</template>
</ui-select>
<ui-horizon-group no-grow class="buttons fit-bottom" v-if="!splash && (showOkButton || showCancelButton)">
<ui-button @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user" :disabled="!canOk">{{ (showCancelButton || input || select || user) ? $t('@.ok') : $t('@.got-it') }}</ui-button>
<ui-button @click="cancel" v-if="showCancelButton || input || select || user">{{ $t('@.cancel') }}</ui-button>
</ui-horizon-group>
</template>
</div>
</ui-modal>
</template>
<script lang="ts">
import Vue from 'vue';
import anime from 'animejs';
import { faTimesCircle, faQuestionCircle } from '@fortawesome/free-regular-svg-icons';
import parseAcct from "../../../../../misc/acct/parse";
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n(),
props: {
type: {
type: String,
required: false,
default: 'info'
},
title: {
type: String,
required: false
},
text: {
type: String,
required: false
},
input: {
required: false
},
select: {
required: false
},
user: {
required: false
},
icon: {
required: false
},
showOkButton: {
type: Boolean,
default: true
},
showCancelButton: {
type: Boolean,
default: false
},
cancelableByBgClick: {
type: Boolean,
default: true
},
splash: {
type: Boolean,
default: false
}
},
data() {
return {
inputValue: this.input && this.input.default ? this.input.default : null,
userInputValue: null,
selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null,
canOk: true,
faTimesCircle, faQuestionCircle
};
},
watch: {
userInputValue() {
if (this.user) {
this.$root.api('users/show', parseAcct(this.userInputValue)).then(u => {
this.canOk = u != null;
}).catch(() => {
this.canOk = false;
});
}
}
},
mounted() {
if (this.user) this.canOk = false;
this.$nextTick(() => {
anime({
targets: this.$refs.main,
opacity: 1,
scale: [1.2, 1],
duration: 300,
easing: 'cubicBezier(0, 0.5, 0.5, 1)'
});
if (this.splash) {
setTimeout(() => {
this.close();
}, 1000);
}
});
},
methods: {
async ok() {
if (!this.canOk) return;
if (!this.showOkButton) return;
if (this.user) {
const user = await this.$root.api('users/show', parseAcct(this.userInputValue));
if (user) {
this.$emit('ok', user);
this.close();
}
} else {
const result =
this.input ? this.inputValue :
this.select ? this.selectedValue :
true;
this.$emit('ok', result);
this.close();
}
},
cancel() {
this.$emit('cancel');
this.close();
},
onBgClick() {
if (this.cancelableByBgClick) this.cancel();
}
close() {
this.$refs.modal.close();
},
onBeforeClose() {
this.$el.style.pointerEvents = 'none';
(this.$refs.main as any).style.pointerEvents = 'none';
anime({
targets: this.$refs.main,
opacity: 0,
scale: 0.8,
duration: 300,
easing: 'cubicBezier(0, 0.5, 0.5, 1)',
});
},
onInputKeydown(e) {
if (e.which == 13) { // Enter
e.preventDefault();
e.stopPropagation();
this.ok();
}
}
}
});
</script>
<style lang="stylus" scoped>
.modal
display flex
align-items center
justify-content center
&.splash
> .main
min-width 0
width initial
.main
display block
position fixed
margin auto
padding 32px
min-width 320px
max-width 480px
width calc(100% - 32px)
text-align center
background var(--face)
color var(--faceText)
opacity 0
&.round
border-radius 8px
> .icon
font-size 32px
&.success
color #85da5a
&.error
color #ec4137
&.warning
color #ecb637
> *
display block
margin 0 auto
& + header
margin-top 16px
> header
margin 0 0 8px 0
font-weight bold
font-size 20px
& + .body
margin-top 8px
> .body
margin 16px 0 0 0
> .buttons
margin-top 16px
</style>

View File

@ -1,11 +0,0 @@
<template>
<div>
<slot></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
});
</script>

View File

@ -1,26 +0,0 @@
<template>
<span class="mk-ellipsis">
<span>.</span><span>.</span><span>.</span>
</span>
</template>
<style lang="stylus" scoped>
.mk-ellipsis
> span
animation ellipsis 1.4s infinite ease-in-out both
&:nth-child(1)
animation-delay 0s
&:nth-child(2)
animation-delay 0.16s
&:nth-child(3)
animation-delay 0.32s
@keyframes ellipsis
0%, 80%, 100%
opacity 1
40%
opacity 0
</style>

View File

@ -1,243 +0,0 @@
<template>
<div class="prlncendiewqqkrevzeruhndoakghvtx">
<header>
<button v-for="category in categories"
:title="category.text"
@click="go(category)"
:class="{ active: category.isActive }"
:key="category.text"
>
<fa :icon="category.icon" fixed-width/>
</button>
</header>
<div class="emojis">
<template v-if="categories[0].isActive">
<header class="category"><fa :icon="faHistory" fixed-width/> {{ $t('recent-emoji') }}</header>
<div class="list">
<button v-for="(emoji, i) in ($store.state.device.recentEmojis || [])"
:title="emoji.name"
@click="chosen(emoji)"
:key="i"
>
<mk-emoji v-if="emoji.char != null" :emoji="emoji.char"/>
<img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
</button>
</div>
</template>
<header class="category"><fa :icon="categories.find(x => x.isActive).icon" fixed-width/> {{ categories.find(x => x.isActive).text }}</header>
<template v-if="categories.find(x => x.isActive).name">
<div class="list">
<button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)"
:title="emoji.name"
@click="chosen(emoji)"
:key="emoji.name"
>
<mk-emoji :emoji="emoji.char"/>
</button>
</div>
</template>
<template v-else>
<div v-for="(key, i) in Object.keys(customEmojis)" :key="i">
<header class="sub">{{ key || $t('no-category') }}</header>
<div class="list">
<button v-for="emoji in customEmojis[key]"
:title="emoji.name"
@click="chosen(emoji)"
:key="emoji.name"
>
<img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import { emojilist } from '../../../../../misc/emojilist';
import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url';
import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory } from '@fortawesome/free-solid-svg-icons';
import { faHeart, faFlag } from '@fortawesome/free-regular-svg-icons';
import { groupByX } from '../../../../../prelude/array';
export default Vue.extend({
i18n: i18n('common/views/components/emoji-picker.vue'),
data() {
return {
emojilist,
getStaticImageUrl,
customEmojis: {},
faGlobe, faHistory,
categories: [{
text: this.$t('custom-emoji'),
icon: faAsterisk,
isActive: true
}, {
name: 'people',
text: this.$t('people'),
icon: ['far', 'laugh'],
isActive: false
}, {
name: 'animals_and_nature',
text: this.$t('animals-and-nature'),
icon: faLeaf,
isActive: false
}, {
name: 'food_and_drink',
text: this.$t('food-and-drink'),
icon: faUtensils,
isActive: false
}, {
name: 'activity',
text: this.$t('activity'),
icon: faFutbol,
isActive: false
}, {
name: 'travel_and_places',
text: this.$t('travel-and-places'),
icon: faCity,
isActive: false
}, {
name: 'objects',
text: this.$t('objects'),
icon: faDice,
isActive: false
}, {
name: 'symbols',
text: this.$t('symbols'),
icon: faHeart,
isActive: false
}, {
name: 'flags',
text: this.$t('flags'),
icon: faFlag,
isActive: false
}]
}
},
created() {
let local = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
local = groupByX(local, (x: any) => x.category || '');
this.customEmojis = local;
if (this.$store.state.device.activeEmojiCategoryName) {
this.goCategory(this.$store.state.device.activeEmojiCategoryName);
}
},
methods: {
go(category: any) {
this.goCategory(category.name);
},
goCategory(name: string) {
let matched = false;
for (const c of this.categories) {
c.isActive = c.name === name;
if (c.isActive) {
matched = true;
this.$store.commit('device/set', { key: 'activeEmojiCategoryName', value: c.name });
}
}
if (!matched) {
this.categories[0].isActive = true;
}
},
chosen(emoji: any) {
const getKey = (emoji: any) => emoji.char || `:${emoji.name}:`;
let recents = this.$store.state.device.recentEmojis || [];
recents = recents.filter((e: any) => getKey(e) !== getKey(emoji));
recents.unshift(emoji)
this.$store.commit('device/set', { key: 'recentEmojis', value: recents.splice(0, 16) });
this.$emit('chosen', getKey(emoji));
}
}
});
</script>
<style lang="stylus" scoped>
.prlncendiewqqkrevzeruhndoakghvtx
width 350px
background var(--face)
> header
display flex
> button
flex 1
padding 10px 0
font-size 16px
color var(--text)
transition color 0.2s ease
&:hover
color var(--textHighlighted)
transition color 0s
&.active
color var(--primary)
transition color 0s
> .emojis
height 300px
overflow-y auto
overflow-x hidden
> header.category
position sticky
top 0
left 0
z-index 1
padding 8px
background var(--faceHeader)
color var(--text)
font-size 12px
>>> header.sub
padding 4px 8px
color var(--text)
font-size 12px
>>> div.list
display grid
grid-template-columns 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr
gap 4px
padding 8px
> button
padding 0
width 100%
&:before
content ''
display block
width 1px
height 0
padding-bottom 100%
&:hover
> *
transform scale(1.2)
transition transform 0s
> *
position absolute
top 0
left 0
width 100%
height 100%
object-fit contain
font-size 28px
transition transform 0.2s ease
pointer-events none
</style>

View File

@ -1,28 +0,0 @@
<template>
<div class="wjqjnyhzogztorhrdgcpqlkxhkmuetgj">
<p><fa icon="exclamation-triangle"/> {{ $t('@.error.title') }}</p>
<ui-button @click="() => $emit('retry')">{{ $t('@.error.retry') }}</ui-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n()
});
</script>
<style lang="stylus" scoped>
.wjqjnyhzogztorhrdgcpqlkxhkmuetgj
max-width 350px
margin 0 auto
padding 32px
text-align center
color var(--text)
> p
margin 0 0 8px 0
</style>

View File

@ -1,17 +0,0 @@
<template>
<span class="mk-file-type-icon">
<template v-if="kind == 'image'"><fa icon="file-image"/></template>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['type'],
computed: {
kind(): string {
return this.type.split('/')[0];
}
}
});
</script>

View File

@ -1,209 +0,0 @@
<template>
<button class="wfliddvnhxvyusikowhxozkyxyenqxqr"
:class="{ wait, block, inline, mini, transparent, active: isFollowing || hasPendingFollowRequestFromYou }"
@click="onClick"
:disabled="wait"
:inline="inline"
>
<template v-if="!wait">
<fa :icon="iconAndText[0]"/> <template v-if="!mini">{{ iconAndText[1] }}</template>
</template>
<template v-else><fa icon="spinner" pulse fixed-width/></template>
</button>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n('common/views/components/follow-button.vue'),
props: {
user: {
type: Object,
required: true
},
block: {
type: Boolean,
required: false,
default: false
},
inline: {
type: Boolean,
required: false,
default: false
},
mini: {
type: Boolean,
required: false,
default: false
},
transparent: {
type: Boolean,
required: false,
default: true
},
},
data() {
return {
isFollowing: this.user.isFollowing,
hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou,
wait: false,
connection: null
};
},
computed: {
iconAndText(): any[] {
return (
(this.hasPendingFollowRequestFromYou && this.user.isLocked) ? ['hourglass-half', this.$t('request-pending')] :
(this.hasPendingFollowRequestFromYou && !this.user.isLocked) ? ['spinner', this.$t('follow-processing')] :
(this.isFollowing) ? ['minus', this.$t('following')] :
(!this.isFollowing && this.user.isLocked) ? ['plus', this.$t('follow-request')] :
(!this.isFollowing && !this.user.isLocked) ? ['plus', this.$t('follow')] :
[]
);
}
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('follow', this.onFollowChange);
this.connection.on('unfollow', this.onFollowChange);
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onFollowChange(user) {
if (user.id == this.user.id) {
this.isFollowing = user.isFollowing;
this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
}
},
async onClick() {
this.wait = true;
try {
if (this.isFollowing) {
const { canceled } = await this.$root.dialog({
type: 'warning',
text: this.$t('@.unfollow-confirm', { name: this.user.name || this.user.username }),
showCancelButton: true
});
if (canceled) return;
await this.$root.api('following/delete', {
userId: this.user.id
});
} else {
if (this.hasPendingFollowRequestFromYou) {
await this.$root.api('following/requests/cancel', {
userId: this.user.id
});
} else if (this.user.isLocked) {
await this.$root.api('following/create', {
userId: this.user.id
});
this.hasPendingFollowRequestFromYou = true;
} else {
await this.$root.api('following/create', {
userId: this.user.id
});
this.hasPendingFollowRequestFromYou = true;
}
}
} catch (e) {
console.error(e);
} finally {
this.wait = false;
}
}
}
});
</script>
<style lang="stylus" scoped>
.wfliddvnhxvyusikowhxozkyxyenqxqr
display block
user-select none
cursor pointer
padding 0 16px
margin 0
min-width 100px
line-height 36px
font-size 14px
font-weight bold
color var(--primary)
background transparent
outline none
border solid 1px var(--primary)
border-radius 36px
&:not(.transparent)
background #fff
&.inline
display inline-block
&.mini
padding 0
min-width 0
width 32px
height 32px
font-size 16px
border-radius 4px
line-height 32px
&:focus
&:after
border-radius 8px
&.block
width 100%
&:focus
&:after
content ""
pointer-events none
position absolute
top -5px
right -5px
bottom -5px
left -5px
border 2px solid var(--primaryAlpha03)
border-radius 36px
&:hover
background var(--primaryAlpha01)
&:active
background var(--primaryAlpha02)
&.active
color var(--primaryForeground)
background var(--primary)
&:hover
background var(--primaryLighten10)
border-color var(--primaryLighten10)
&:active
background var(--primaryDarken10)
border-color var(--primaryDarken10)
&.wait
cursor wait !important
opacity 0.7
*
pointer-events none
</style>

View File

@ -1,48 +0,0 @@
<template>
<a class="a" :href="repositoryUrl" rel="noopener" target="_blank" title="View source on GitHub">
<svg width="80" height="80" viewBox="0 0 250 250" aria-hidden="aria-hidden">
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
<path class="octo-arm" d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor"></path>
<path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor"></path>
</svg>
</a>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
data() {
return {
repositoryUrl: 'https://github.com/syuilo/misskey'
};
}
});
</script>
<style lang="stylus" scoped>
.a
display block
> svg
display block
//fill #151513
//color #fff
fill var(--primary)
color var(--primaryForeground)
.octo-arm
transform-origin 130px 106px
&:hover
.octo-arm
animation octocat-wave 560ms ease-in-out
@keyframes octocat-wave
0%, 100%
transform rotate(0)
20%, 60%
transform rotate(-25deg)
40%, 80%
transform rotate(10deg)
</style>

View File

@ -1,49 +0,0 @@
<template>
<span class="mk-frac"><span>{{ pad }}</span><span>{{ value }} / {{ total }}</span></span>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n(),
props: {
value: {
type: Number,
required: true,
},
total: {
type: Number,
required: true,
},
},
computed: {
pad(this: {
value: number;
total: number;
length(value: number): number;
}) {
return '0'.repeat(this.length(this.total) - this.length(this.value));
},
},
methods: {
length(value: number) {
const string = value.toString();
return string.includes('e') ? -~string.substr(string.indexOf('e')) : string.length;
},
},
});
</script>
<style lang="stylus" scoped>
.mk-frac
-webkit-font-feature-settings 'tnum'
-moz-font-feature-settings 'tnum'
font-feature-settings 'tnum'
font-variant-numeric tabular-nums
> :first-child
visibility hidden
</style>

View File

@ -1,473 +0,0 @@
<template>
<div class="xqnhankfuuilcwvhgsopeqncafzsquya">
<button class="go-index" v-if="selfNav" @click="goIndex"><fa icon="arrow-left"/></button>
<header><b><router-link :to="blackUser | userPage"><mk-user-name :user="blackUser"/></router-link></b>({{ $t('@.reversi.black') }}) vs <b><router-link :to="whiteUser | userPage"><mk-user-name :user="whiteUser"/></router-link></b>({{ $t('@.reversi.white') }})</header>
<div style="overflow: hidden; line-height: 28px;">
<p class="turn" v-if="!iAmPlayer && !game.isEnded">
<mfm :key="'turn:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.turn-of', { name: $options.filters.userName(turnUser) })" :plain="true" :custom-emojis="turnUser.emojis"/>
<mk-ellipsis/>
</p>
<p class="turn" v-if="logPos != logs.length">
<mfm :key="'past-turn-of:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.past-turn-of', { name: $options.filters.userName(turnUser) })" :plain="true" :custom-emojis="turnUser.emojis"/>
</p>
<p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn">{{ $t('@.reversi.opponent-turn') }}<mk-ellipsis/></p>
<p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn" v-animate-css="{ classes: 'tada', iteration: 'infinite' }">{{ $t('@.reversi.my-turn') }}</p>
<p class="result" v-if="game.isEnded && logPos == logs.length">
<template v-if="game.winner">
<mfm :key="'won'" :text="$t('@.reversi.won', { name: $options.filters.userName(game.winner) })" :plain="true" :custom-emojis="game.winner.emojis"/>
<span v-if="game.surrendered != null"> ({{ $t('surrendered') }})</span>
</template>
<template v-else>{{ $t('@.reversi.drawn') }}</template>
</p>
</div>
<div class="board">
<div class="labels-x" v-if="$store.state.settings.gamesReversiShowBoardLabels">
<span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span>
</div>
<div class="flex">
<div class="labels-y" v-if="$store.state.settings.gamesReversiShowBoardLabels">
<div v-for="i in game.map.length">{{ i }}</div>
</div>
<div class="cells" :style="cellsStyle">
<div v-for="(stone, i) in o.board"
:class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn, can: turnUser ? o.canPut(turnUser.id == blackUser.id, i) : null, prev: o.prevPos == i }"
@click="set(i)"
:title="`${String.fromCharCode(65 + o.transformPosToXy(i)[0])}${o.transformPosToXy(i)[1] + 1}`">
<template v-if="$store.state.settings.gamesReversiUseAvatarStones">
<img v-if="stone === true" :src="blackUser.avatarUrl" alt="black">
<img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white">
</template>
<template v-else>
<fa v-if="stone === true" :icon="fasCircle"/>
<fa v-if="stone === false" :icon="farCircle"/>
</template>
</div>
</div>
<div class="labels-y" v-if="this.$store.state.settings.gamesReversiShowBoardLabels">
<div v-for="i in game.map.length">{{ i }}</div>
</div>
</div>
<div class="labels-x" v-if="this.$store.state.settings.gamesReversiShowBoardLabels">
<span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span>
</div>
</div>
<p class="status"><b>{{ $t('@.reversi.this-turn', { count: logPos }) }}</b> {{ $t('@.reversi.black') }}:{{ o.blackCount }} {{ $t('@.reversi.white') }}:{{ o.whiteCount }} {{ $t('@.reversi.total') }}:{{ o.blackCount + o.whiteCount }}</p>
<div class="actions" v-if="!game.isEnded && iAmPlayer">
<form-button @click="surrender">{{ $t('surrender') }}</form-button>
</div>
<div class="player" v-if="game.isEnded">
<span>{{ logPos }} / {{ logs.length }}</span>
<ui-horizon-group>
<ui-button @click="logPos = 0" :disabled="logPos == 0"><fa :icon="faAngleDoubleLeft"/></ui-button>
<ui-button @click="logPos--" :disabled="logPos == 0"><fa :icon="faAngleLeft"/></ui-button>
<ui-button @click="logPos++" :disabled="logPos == logs.length"><fa :icon="faAngleRight"/></ui-button>
<ui-button @click="logPos = logs.length" :disabled="logPos == logs.length"><fa :icon="faAngleDoubleRight"/></ui-button>
</ui-horizon-group>
</div>
<div class="info">
<p v-if="game.isLlotheo">{{ $t('is-llotheo') }}</p>
<p v-if="game.loopedBoard">{{ $t('looped-map') }}</p>
<p v-if="game.canPutEverywhere">{{ $t('can-put-everywhere') }}</p>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../../i18n';
import * as CRC32 from 'crc-32';
import Reversi, { Color } from '../../../../../../../games/reversi/core';
import { url } from '../../../../../config';
import { faAngleDoubleLeft, faAngleLeft, faAngleRight, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons';
import { faCircle as fasCircle } from '@fortawesome/free-solid-svg-icons';
import { faCircle as farCircle } from '@fortawesome/free-regular-svg-icons';
export default Vue.extend({
i18n: i18n('common/views/components/games/reversi/reversi.game.vue'),
props: {
initGame: {
type: Object,
require: true
},
connection: {
type: Object,
require: true
},
selfNav: {
type: Boolean,
require: true
}
},
data() {
return {
game: null,
o: null as Reversi,
logs: [],
logPos: 0,
pollingClock: null,
faAngleDoubleLeft, faAngleLeft, faAngleRight, faAngleDoubleRight, fasCircle, farCircle
};
},
computed: {
iAmPlayer(): boolean {
if (!this.$store.getters.isSignedIn) return false;
return this.game.user1Id == this.$store.state.i.id || this.game.user2Id == this.$store.state.i.id;
},
myColor(): Color {
if (!this.iAmPlayer) return null;
if (this.game.user1Id == this.$store.state.i.id && this.game.black == 1) return true;
if (this.game.user2Id == this.$store.state.i.id && this.game.black == 2) return true;
return false;
},
opColor(): Color {
if (!this.iAmPlayer) return null;
return this.myColor === true ? false : true;
},
blackUser(): any {
return this.game.black == 1 ? this.game.user1 : this.game.user2;
},
whiteUser(): any {
return this.game.black == 1 ? this.game.user2 : this.game.user1;
},
turnUser(): any {
if (this.o.turn === true) {
return this.game.black == 1 ? this.game.user1 : this.game.user2;
} else if (this.o.turn === false) {
return this.game.black == 1 ? this.game.user2 : this.game.user1;
} else {
return null;
}
},
isMyTurn(): boolean {
if (!this.iAmPlayer) return false;
if (this.turnUser == null) return false;
return this.turnUser.id == this.$store.state.i.id;
},
cellsStyle(): any {
return {
'grid-template-rows': `repeat(${this.game.map.length}, 1fr)`,
'grid-template-columns': `repeat(${this.game.map[0].length}, 1fr)`
};
}
},
watch: {
logPos(v) {
if (!this.game.isEnded) return;
this.o = new Reversi(this.game.map, {
isLlotheo: this.game.isLlotheo,
canPutEverywhere: this.game.canPutEverywhere,
loopedBoard: this.game.loopedBoard
});
for (const log of this.logs.slice(0, v)) {
this.o.put(log.color, log.pos);
}
this.$forceUpdate();
}
},
created() {
this.game = this.initGame;
this.o = new Reversi(this.game.map, {
isLlotheo: this.game.isLlotheo,
canPutEverywhere: this.game.canPutEverywhere,
loopedBoard: this.game.loopedBoard
});
for (const log of this.game.logs) {
this.o.put(log.color, log.pos);
}
this.logs = this.game.logs;
this.logPos = this.logs.length;
// 通信を取りこぼしてもいいように定期的にポーリングさせる
if (this.game.isStarted && !this.game.isEnded) {
this.pollingClock = setInterval(() => {
if (this.game.isEnded) return;
const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join(''));
this.connection.send('check', {
crc32: crc32
});
}, 3000);
}
},
mounted() {
this.connection.on('set', this.onSet);
this.connection.on('rescue', this.onRescue);
this.connection.on('ended', this.onEnded);
},
beforeDestroy() {
this.connection.off('set', this.onSet);
this.connection.off('rescue', this.onRescue);
this.connection.off('ended', this.onEnded);
clearInterval(this.pollingClock);
},
methods: {
set(pos) {
if (this.game.isEnded) return;
if (!this.iAmPlayer) return;
if (!this.isMyTurn) return;
if (!this.o.canPut(this.myColor, pos)) return;
this.o.put(this.myColor, pos);
// サウンドを再生する
if (this.$store.state.device.enableSounds) {
const sound = new Audio(`${url}/assets/reversi-put-me.mp3`);
sound.volume = this.$store.state.device.soundVolume;
sound.play();
}
this.connection.send('set', {
pos: pos
});
this.checkEnd();
this.$forceUpdate();
},
onSet(x) {
this.logs.push(x);
this.logPos++;
this.o.put(x.color, x.pos);
this.checkEnd();
this.$forceUpdate();
// サウンドを再生する
if (this.$store.state.device.enableSounds && x.color != this.myColor) {
const sound = new Audio(`${url}/assets/reversi-put-you.mp3`);
sound.volume = this.$store.state.device.soundVolume;
sound.play();
}
},
onEnded(x) {
this.game = x.game;
},
checkEnd() {
this.game.isEnded = this.o.isEnded;
if (this.game.isEnded) {
if (this.o.winner === true) {
this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id;
this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2;
} else if (this.o.winner === false) {
this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id;
this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1;
} else {
this.game.winnerId = null;
this.game.winner = null;
}
}
},
// 正しいゲーム情報が送られてきたとき
onRescue(game) {
this.game = game;
this.o = new Reversi(this.game.map, {
isLlotheo: this.game.isLlotheo,
canPutEverywhere: this.game.canPutEverywhere,
loopedBoard: this.game.loopedBoard
});
for (const log of this.game.logs) {
this.o.put(log.color, log.pos, true);
}
this.logs = this.game.logs;
this.logPos = this.logs.length;
this.checkEnd();
this.$forceUpdate();
},
surrender() {
this.$root.api('games/reversi/games/surrender', {
gameId: this.game.id
});
},
goIndex() {
this.$emit('go-index');
}
}
});
</script>
<style lang="stylus" scoped>
.xqnhankfuuilcwvhgsopeqncafzsquya
text-align center
> .go-index
position absolute
top 0
left 0
z-index 1
width 42px
height 42px
> header
padding 8px
border-bottom dashed 1px var(--reversiGameHeaderLine)
a
color inherit
> .board
width calc(100% - 16px)
max-width 500px
margin 0 auto
$label-size = 16px
$gap = 4px
> .labels-x
height $label-size
padding 0 $label-size
display flex
> *
flex 1
display flex
align-items center
justify-content center
font-size 12px
&:first-child
margin-left -($gap / 2)
&:last-child
margin-right -($gap / 2)
> .flex
display flex
> .labels-y
width $label-size
display flex
flex-direction column
> *
flex 1
display flex
align-items center
justify-content center
font-size 12px
&:first-child
margin-top -($gap / 2)
&:last-child
margin-bottom -($gap / 2)
> .cells
flex 1
display grid
grid-gap $gap
> div
background transparent
border-radius 6px
overflow hidden
*
pointer-events none
user-select none
&.empty
border solid 2px var(--reversiGameEmptyCell)
&.empty.can
background var(--reversiGameEmptyCell)
&.empty.myTurn
border-color var(--reversiGameEmptyCellMyTurn)
&.can
background var(--reversiGameEmptyCellCanPut)
cursor pointer
&:hover
border-color var(--primaryDarken10)
background var(--primary)
&:active
background var(--primaryDarken10)
&.prev
box-shadow 0 0 0 4px var(--primaryAlpha07)
&.isEnded
border-color var(--reversiGameEmptyCellMyTurn)
&.none
border-color transparent !important
> svg
display block
width 100%
height 100%
> img
display block
width 100%
height 100%
> .graph
display grid
grid-template-columns repeat(61, 1fr)
width 300px
height 38px
margin 0 auto 16px auto
> div
&:not(:empty)
background #ccc
> div:first-child
background #333
> div:last-child
background #ccc
> .status
margin 0
padding 16px 0
> .actions
padding-bottom 16px
> .player
padding 0 16px 32px 16px
margin 0 auto
max-width 500px
> span
display inline-block
margin 0 8px
min-width 70px
</style>

View File

@ -1,56 +0,0 @@
<template>
<div>
<x-room v-if="!g.isStarted" :game="g" :connection="connection"/>
<x-game v-else :init-game="g" :connection="connection" :self-nav="selfNav" @go-index="goIndex"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../../i18n';
import XGame from './reversi.game.vue';
import XRoom from './reversi.room.vue';
export default Vue.extend({
i18n: i18n('common/views/components/games/reversi/reversi.gameroom.vue'),
components: {
XGame,
XRoom
},
props: {
game: {
type: Object,
required: true
},
selfNav: {
type: Boolean,
require: true
}
},
data() {
return {
connection: null,
g: null
};
},
created() {
this.g = this.game;
this.connection = this.$root.stream.connectToChannel('gamesReversiGame', {
gameId: this.game.id
});
this.connection.on('started', this.onStarted);
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onStarted(game) {
Object.assign(this.g, game);
this.$forceUpdate();
},
goIndex() {
this.$emit('go-index');
}
}
});
</script>

View File

@ -1,245 +0,0 @@
<template>
<div class="phgnkghfpyvkrvwiajkiuoxyrdaqpzcx">
<h1>{{ $t('title') }}</h1>
<p>{{ $t('sub-title') }}</p>
<div class="play">
<form-button primary round @click="match">{{ $t('invite') }}</form-button>
<details>
<summary>{{ $t('rule') }}</summary>
<div>
<p>{{ $t('rule-desc') }}</p>
<dl>
<dt><b>{{ $t('mode-invite') }}</b></dt>
<dd>{{ $t('mode-invite-desc') }}</dd>
</dl>
</div>
</details>
</div>
<section v-if="invitations.length > 0">
<h2>{{ $t('invitations') }}</h2>
<div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)">
<mk-avatar class="avatar" :user="i.parent"/>
<span class="name"><b><mk-user-name :user="i.parent"/></b></span>
<span class="username">@{{ i.parent.username }}</span>
<mk-time :time="i.createdAt"/>
</div>
</section>
<section v-if="myGames.length > 0">
<h2>{{ $t('my-games') }}</h2>
<a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/games/reversi/${g.id}`">
<mk-avatar class="avatar" :user="g.user1"/>
<mk-avatar class="avatar" :user="g.user2"/>
<span><b><mk-user-name :user="g.user1"/></b> vs <b><mk-user-name :user="g.user2"/></b></span>
<span class="state">{{ g.isEnded ? $t('game-state.ended') : $t('game-state.playing') }}</span>
<mk-time :time="g.createdAt" />
</a>
</section>
<section v-if="games.length > 0">
<h2>{{ $t('all-games') }}</h2>
<a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/games/reversi/${g.id}`">
<mk-avatar class="avatar" :user="g.user1"/>
<mk-avatar class="avatar" :user="g.user2"/>
<span><b><mk-user-name :user="g.user1"/></b> vs <b><mk-user-name :user="g.user2"/></b></span>
<span class="state">{{ g.isEnded ? $t('game-state.ended') : $t('game-state.playing') }}</span>
<mk-time :time="g.createdAt" />
</a>
</section>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../../i18n';
export default Vue.extend({
i18n: i18n('common/views/components/games/reversi/reversi.index.vue'),
data() {
return {
games: [],
gamesFetching: true,
gamesMoreFetching: false,
myGames: [],
matching: null,
invitations: [],
connection: null
};
},
mounted() {
if (this.$store.getters.isSignedIn) {
this.connection = this.$root.stream.useSharedConnection('gamesReversi');
this.connection.on('invited', this.onInvited);
this.$root.api('games/reversi/games', {
my: true
}).then(games => {
this.myGames = games;
});
this.$root.api('games/reversi/invitations').then(invitations => {
this.invitations = this.invitations.concat(invitations);
});
}
this.$root.api('games/reversi/games').then(games => {
this.games = games;
this.gamesFetching = false;
});
},
beforeDestroy() {
if (this.connection) {
this.connection.dispose();
}
},
methods: {
go(game) {
this.$emit('go', game);
},
async match() {
const { result: user } = await this.$root.dialog({
title: this.$t('enter-username'),
user: {
local: true
}
});
if (user == null) return;
this.$root.api('games/reversi/match', {
userId: user.id
}).then(res => {
if (res == null) {
this.$emit('matching', user);
} else {
this.$emit('go', res);
}
});
},
accept(invitation) {
this.$root.api('games/reversi/match', {
userId: invitation.parent.id
}).then(game => {
if (game) {
this.$emit('go', game);
}
});
},
onInvited(invite) {
this.invitations.unshift(invite);
}
}
});
</script>
<style lang="stylus" scoped>
.phgnkghfpyvkrvwiajkiuoxyrdaqpzcx
> h1
margin 0
padding 24px
font-size 24px
text-align center
font-weight normal
color #fff
background linear-gradient(to bottom, var(--reversiBannerGradientStart), var(--reversiBannerGradientEnd))
& + p
margin 0
padding 12px
margin-bottom 12px
text-align center
font-size 14px
border-bottom solid 1px var(--faceDivider)
> .play
margin 0 auto
padding 0 16px
max-width 500px
text-align center
> details
margin 8px 0
> div
padding 16px
font-size 14px
text-align left
background var(--reversiDescBg)
border-radius 8px
> section
margin 0 auto
padding 0 16px 16px 16px
max-width 500px
border-top solid 1px var(--faceDivider)
> h2
margin 0
padding 16px 0 8px 0
font-size 16px
font-weight bold
.invitation
margin 8px 0
padding 8px
color var(--text)
background var(--face)
box-shadow 0 2px 16px var(--reversiListItemShadow)
border-radius 6px
cursor pointer
*
pointer-events none
user-select none
&:focus
border-color var(--primary)
&:hover
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
&:active
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
> .avatar
width 32px
height 32px
border-radius 100%
> span
margin 0 8px
line-height 32px
.game
display block
margin 8px 0
padding 8px
color var(--text)
background var(--face)
box-shadow 0 2px 16px var(--reversiListItemShadow)
border-radius 6px
cursor pointer
*
pointer-events none
user-select none
&:hover
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
&:active
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
> .avatar
width 32px
height 32px
border-radius 100%
> span
margin 0 8px
line-height 32px
</style>

View File

@ -1,355 +0,0 @@
<template>
<div class="urbixznjwwuukfsckrwzwsqzsxornqij">
<header><b><mk-user-name :user="game.user1"/></b> vs <b><mk-user-name :user="game.user2"/></b></header>
<div>
<p>{{ $t('settings-of-the-game') }}</p>
<div class="card map">
<header>
<select v-model="mapName" :placeholder="$t('choose-map')" @change="onMapChange">
<option label="-Custom-" :value="mapName" v-if="mapName == '-Custom-'"/>
<option :label="$t('random')" :value="null"/>
<optgroup v-for="c in mapCategories" :key="c" :label="c">
<option v-for="m in maps" v-if="m.category == c" :key="m.name" :label="m.name" :value="m.name">{{ m.name }}</option>
</optgroup>
</select>
</header>
<div>
<div class="random" v-if="game.map == null"><fa icon="dice"/></div>
<div class="board" v-else :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
<div v-for="(x, i) in game.map.join('')"
:data-none="x == ' '"
@click="onPixelClick(i, x)">
<fa v-if="x == 'b'" :icon="fasCircle"/>
<fa v-if="x == 'w'" :icon="farCircle"/>
</div>
</div>
</div>
</div>
<div class="card">
<header>
<span>{{ $t('black-or-white') }}</span>
</header>
<div>
<form-radio v-model="game.bw" value="random" @change="updateSettings('bw')">{{ $t('random') }}</form-radio>
<form-radio v-model="game.bw" :value="1" @change="updateSettings('bw')">{{ this.$t('black-is').split('{}')[0] }}<b><mk-user-name :user="game.user1"/></b>{{ this.$t('black-is').split('{}')[1] }}</form-radio>
<form-radio v-model="game.bw" :value="2" @change="updateSettings('bw')">{{ this.$t('black-is').split('{}')[0] }}<b><mk-user-name :user="game.user2"/></b>{{ this.$t('black-is').split('{}')[1] }}</form-radio>
</div>
</div>
<div class="card">
<header>
<span>{{ $t('rules') }}</span>
</header>
<div>
<ui-switch v-model="game.isLlotheo" @change="updateSettings('isLlotheo')">{{ $t('is-llotheo') }}</ui-switch>
<ui-switch v-model="game.loopedBoard" @change="updateSettings('loopedBoard')">{{ $t('looped-map') }}</ui-switch>
<ui-switch v-model="game.canPutEverywhere" @change="updateSettings('canPutEverywhere')">{{ $t('can-put-everywhere') }}</ui-switch>
</div>
</div>
<div class="card form" v-if="form">
<header>
<span>{{ $t('settings-of-the-bot') }}</span>
</header>
<div>
<template v-for="item in form">
<ui-switch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" @change="onChangeForm(item)">{{ item.label || item.desc || '' }}</ui-switch>
<div class="card" v-if="item.type == 'radio'" :key="item.id">
<header>
<span>{{ item.label }}</span>
</header>
<div>
<form-radio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :value="r.value" @change="onChangeForm(item)">{{ r.label }}</form-radio>
</div>
</div>
<div class="card" v-if="item.type == 'slider'" :key="item.id">
<header>
<span>{{ item.label }}</span>
</header>
<div>
<input type="range" :min="item.min" :max="item.max" :step="item.step || 1" v-model="item.value" @change="onChangeForm(item)"/>
</div>
</div>
<div class="card" v-if="item.type == 'textbox'" :key="item.id">
<header>
<span>{{ item.label }}</span>
</header>
<div>
<input v-model="item.value" @change="onChangeForm(item)"/>
</div>
</div>
</template>
</div>
</div>
</div>
<footer>
<p class="status">
<template v-if="isAccepted && isOpAccepted">{{ $t('this-game-is-started-soon') }}<mk-ellipsis/></template>
<template v-if="isAccepted && !isOpAccepted">{{ $t('waiting-for-other') }}<mk-ellipsis/></template>
<template v-if="!isAccepted && isOpAccepted">{{ $t('waiting-for-me') }}</template>
<template v-if="!isAccepted && !isOpAccepted">{{ $t('waiting-for-both') }}<mk-ellipsis/></template>
</p>
<div class="actions">
<form-button @click="exit">{{ $t('cancel') }}</form-button>
<form-button primary @click="accept" v-if="!isAccepted">{{ $t('ready') }}</form-button>
<form-button primary @click="cancel" v-if="isAccepted">{{ $t('cancel-ready') }}</form-button>
</div>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../../i18n';
import * as maps from '../../../../../../../games/reversi/maps';
import { faCircle as fasCircle } from '@fortawesome/free-solid-svg-icons';
import { faCircle as farCircle } from '@fortawesome/free-regular-svg-icons';
export default Vue.extend({
i18n: i18n('common/views/components/games/reversi/reversi.room.vue'),
props: ['game', 'connection'],
data() {
return {
o: null,
isLlotheo: false,
mapName: maps.eighteight.name,
maps: maps,
form: null,
messages: [],
fasCircle, farCircle
};
},
computed: {
mapCategories(): string[] {
const categories = Object.values(maps).map(x => x.category);
return categories.filter((item, pos) => categories.indexOf(item) == pos);
},
isAccepted(): boolean {
if (this.game.user1Id == this.$store.state.i.id && this.game.user1Accepted) return true;
if (this.game.user2Id == this.$store.state.i.id && this.game.user2Accepted) return true;
return false;
},
isOpAccepted(): boolean {
if (this.game.user1Id != this.$store.state.i.id && this.game.user1Accepted) return true;
if (this.game.user2Id != this.$store.state.i.id && this.game.user2Accepted) return true;
return false;
}
},
created() {
this.connection.on('changeAccepts', this.onChangeAccepts);
this.connection.on('updateSettings', this.onUpdateSettings);
this.connection.on('initForm', this.onInitForm);
this.connection.on('message', this.onMessage);
if (this.game.user1Id != this.$store.state.i.id && this.game.form1) this.form = this.game.form1;
if (this.game.user2Id != this.$store.state.i.id && this.game.form2) this.form = this.game.form2;
},
beforeDestroy() {
this.connection.off('changeAccepts', this.onChangeAccepts);
this.connection.off('updateSettings', this.onUpdateSettings);
this.connection.off('initForm', this.onInitForm);
this.connection.off('message', this.onMessage);
},
methods: {
exit() {
},
accept() {
this.connection.send('accept', {});
},
cancel() {
this.connection.send('cancelAccept', {});
},
onChangeAccepts(accepts) {
this.game.user1Accepted = accepts.user1;
this.game.user2Accepted = accepts.user2;
this.$forceUpdate();
},
updateSettings(key: string) {
this.connection.send('updateSettings', {
key: key,
value: this.game[key]
});
},
onUpdateSettings({ key, value }) {
this.game[key] = value;
if (this.game.map == null) {
this.mapName = null;
} else {
const found = Object.values(maps).find(x => x.data.join('') == this.game.map.join(''));
this.mapName = found ? found.name : '-Custom-';
}
},
onInitForm(x) {
if (x.userId == this.$store.state.i.id) return;
this.form = x.form;
},
onMessage(x) {
if (x.userId == this.$store.state.i.id) return;
this.messages.unshift(x.message);
},
onChangeForm(item) {
this.connection.send('updateForm', {
id: item.id,
value: item.value
});
},
onMapChange() {
if (this.mapName == null) {
this.game.map = null;
} else {
this.game.map = Object.values(maps).find(x => x.name == this.mapName).data;
}
this.$forceUpdate();
this.updateSettings('map');
},
onPixelClick(pos, pixel) {
const x = pos % this.game.map[0].length;
const y = Math.floor(pos / this.game.map[0].length);
const newPixel =
pixel == ' ' ? '-' :
pixel == '-' ? 'b' :
pixel == 'b' ? 'w' :
' ';
const line = this.game.map[y].split('');
line[x] = newPixel;
this.$set(this.game.map, y, line.join(''));
this.$forceUpdate();
this.updateSettings('map');
}
}
});
</script>
<style lang="stylus" scoped>
.urbixznjwwuukfsckrwzwsqzsxornqij
text-align center
background var(--bg)
> header
padding 8px
border-bottom dashed 1px #c4cdd4
> div
padding 0 16px
> .card
margin 0 auto 16px auto
&.map
> header
> select
width 100%
padding 12px 14px
background var(--face)
border 1px solid var(--reversiMapSelectBorder)
border-radius 4px
color var(--text)
cursor pointer
transition border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1)
-webkit-appearance none
-moz-appearance none
appearance none
&:hover
border-color var(--reversiMapSelectHoverBorder)
&:focus
&:active
border-color var(--primary)
> div
> .random
padding 32px 0
font-size 64px
color var(--text)
opacity 0.7
> .board
display grid
grid-gap 4px
width 300px
height 300px
margin 0 auto
color var(--text)
> div
background transparent
border solid 2px var(--faceDivider)
border-radius 6px
overflow hidden
cursor pointer
*
pointer-events none
user-select none
width 100%
height 100%
&[data-none]
border-color transparent
&.form
> div
> .card + .card
margin-top 16px
input[type='range']
width 100%
.card
max-width 400px
border-radius 4px
background var(--face)
color var(--text)
box-shadow 0 2px 12px 0 var(--reversiRoomFormShadow)
> header
padding 18px 20px
border-bottom 1px solid var(--faceDivider)
> div
padding 20px
color var(--text)
> footer
position sticky
bottom 0
padding 16px
background var(--reversiRoomFooterBg)
border-top solid 1px var(--faceDivider)
> .status
margin 0 0 16px 0
</style>

View File

@ -1,175 +0,0 @@
<template>
<div class="vchtoekanapleubgzioubdtmlkribzfd">
<div v-if="game">
<x-gameroom :game="game" :self-nav="selfNav" @go-index="goIndex"/>
</div>
<div class="matching" v-else-if="matching">
<h1>{{ this.$t('matching.waiting-for').split('{}')[0] }}<b><mk-user-name :user="matching"/></b>{{ this.$t('matching.waiting-for').split('{}')[1] }}<mk-ellipsis/></h1>
<div class="cancel">
<form-button round @click="cancel">{{ $t('matching.cancel') }}</form-button>
</div>
</div>
<div v-else-if="gameId">
...
</div>
<div class="index" v-else>
<x-index @go="nav" @matching="onMatching"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../../i18n';
import XGameroom from './reversi.gameroom.vue';
import XIndex from './reversi.index.vue';
import Progress from '../../../../scripts/loading';
export default Vue.extend({
i18n: i18n('common/views/components/games/reversi/reversi.vue'),
components: {
XGameroom,
XIndex
},
props: {
gameId: {
type: String,
required: false
},
selfNav: {
type: Boolean,
require: false,
default: true
}
},
data() {
return {
game: null,
matching: null,
connection: null,
pingClock: null
};
},
watch: {
game() {
this.$emit('gamed', this.game);
},
gameId() {
this.fetch();
}
},
mounted() {
this.fetch();
if (this.$store.getters.isSignedIn) {
this.connection = this.$root.stream.useSharedConnection('gamesReversi');
this.connection.on('matched', this.onMatched);
this.pingClock = setInterval(() => {
if (this.matching) {
this.connection.send('ping', {
id: this.matching.id
});
}
}, 3000);
}
},
beforeDestroy() {
if (this.connection) {
this.connection.dispose();
clearInterval(this.pingClock);
}
},
methods: {
fetch() {
if (this.gameId == null) {
this.game = null;
} else {
Progress.start();
this.$root.api('games/reversi/games/show', {
gameId: this.gameId
}).then(game => {
this.game = game;
Progress.done();
});
}
},
async nav(game, actualNav = true) {
if (this.selfNav) {
// 受け取ったゲーム情報が省略されたものなら完全な情報を取得する
if (game != null && game.map == null) {
game = await this.$root.api('games/reversi/games/show', {
gameId: game.id
});
}
this.game = game;
} else {
this.$emit('nav', game, actualNav);
}
},
onMatching(user) {
this.matching = user;
},
cancel() {
this.matching = null;
this.$root.api('games/reversi/match/cancel');
},
accept(invitation) {
this.$root.api('games/reversi/match', {
userId: invitation.parent.id
}).then(game => {
if (game) {
this.matching = null;
this.nav(game);
}
});
},
onMatched(game) {
this.matching = null;
this.game = game;
this.nav(game, false);
},
goIndex() {
this.nav(null);
}
}
});
</script>
<style lang="stylus" scoped>
.vchtoekanapleubgzioubdtmlkribzfd
color var(--text)
background var(--bg)
> .matching
> h1
margin 0
padding 24px
font-size 20px
text-align center
font-weight normal
> .cancel
margin 0 auto
padding 24px 0 0 0
max-width 200px
text-align center
border-top dashed 1px #c4cdd4
</style>

View File

@ -1,66 +0,0 @@
<template>
<div class="mk-google">
<input type="search" v-model="query" :placeholder="q">
<button @click="search"><fa icon="search"/> {{ $t('@.search') }}</button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n(),
props: ['q'],
data() {
return {
query: null
};
},
mounted() {
this.query = this.q;
},
methods: {
search() {
const engine = this.$store.state.settings.webSearchEngine ||
'https://www.google.com/?#q={{query}}';
const url = engine.replace('{{query}}', this.query)
window.open(url, '_blank');
}
}
});
</script>
<style lang="stylus" scoped>
.mk-google
display flex
margin 8px 0
> input
flex-shrink 1
padding 10px
width 100%
height 40px
font-size 16px
color var(--googleSearchFg)
background var(--googleSearchBg)
border solid 1px var(--googleSearchBorder)
border-radius 4px 0 0 4px
&:hover
border-color var(--googleSearchHoverBorder)
> button
flex-shrink 0
padding 0 16px
border solid 1px var(--googleSearchBorder)
border-left none
border-radius 0 4px 4px 0
&:hover
background-color var(--googleSearchHoverButton)
&:active
box-shadow 0 2px 4px rgba(#000, 0.15) inset
</style>

View File

@ -1,41 +0,0 @@
<template>
<ui-modal ref="modal" v-hotkey.global="keymap">
<img :src="image.url" :alt="image.name" :title="image.name" @click="close" />
</ui-modal>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['image'],
computed: {
keymap(): any {
return {
'esc': this.close,
};
}
},
methods: {
close() {
(this.$refs.modal as any).close();
}
}
});
</script>
<style lang="stylus" scoped>
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
image-orientation from-image
</style>

View File

@ -1,103 +0,0 @@
import Vue from 'vue';
import dummy from './dummy.vue';
import userName from './user-name.vue';
import followButton from './follow-button.vue';
import error from './error.vue';
import noteSkeleton from './note-skeleton.vue';
import instance from './instance.vue';
import cwButton from './cw-button.vue';
import tagCloud from './tag-cloud.vue';
import trends from './trends.vue';
import analogClock from './analog-clock.vue';
import menu from './menu.vue';
import noteHeader from './note-header.vue';
import renote from './renote.vue';
import signin from './signin.vue';
import signup from './signup.vue';
import forkit from './forkit.vue';
import acct from './acct.vue';
import avatar from './avatar.vue';
import nav from './nav.vue';
import misskeyFlavoredMarkdown from './misskey-flavored-markdown.vue';
import poll from './poll.vue';
import reactionIcon from './reaction-icon.vue';
import reactionsViewer from './reactions-viewer.vue';
import time from './time.vue';
import mediaList from './media-list.vue';
import uploader from './uploader.vue';
import streamIndicator from './stream-indicator.vue';
import ellipsis from './ellipsis.vue';
import urlPreview from './url-preview.vue';
import fileTypeIcon from './file-type-icon.vue';
import emoji from './emoji.vue';
import welcomeTimeline from './welcome-timeline.vue';
import userList from './user-list.vue';
import frac from './frac.vue';
import uiInput from './ui/input.vue';
import uiButton from './ui/button.vue';
import uiHorizonGroup from './ui/horizon-group.vue';
import uiCard from './ui/card.vue';
import uiForm from './ui/form.vue';
import uiTextarea from './ui/textarea.vue';
import uiSwitch from './ui/switch.vue';
import uiRadio from './ui/radio.vue';
import uiSelect from './ui/select.vue';
import uiInfo from './ui/info.vue';
import uiMargin from './ui/margin.vue';
import uiHr from './ui/hr.vue';
import uiPagination from './ui/pagination.vue';
import uiModal from './ui/modal.vue';
import formButton from './ui/form/button.vue';
import formRadio from './ui/form/radio.vue';
Vue.component('mfm', misskeyFlavoredMarkdown);
Vue.component('mk-dummy', dummy);
Vue.component('mk-user-name', userName);
Vue.component('mk-follow-button', followButton);
Vue.component('mk-error', error);
Vue.component('mk-note-skeleton', noteSkeleton);
Vue.component('mk-instance', instance);
Vue.component('mk-cw-button', cwButton);
Vue.component('mk-tag-cloud', tagCloud);
Vue.component('mk-trends', trends);
Vue.component('mk-analog-clock', analogClock);
Vue.component('mk-menu', menu);
Vue.component('mk-note-header', noteHeader);
Vue.component('mk-renote', renote);
Vue.component('mk-signin', signin);
Vue.component('mk-signup', signup);
Vue.component('mk-forkit', forkit);
Vue.component('mk-acct', acct);
Vue.component('mk-avatar', avatar);
Vue.component('mk-nav', nav);
Vue.component('mk-poll', poll);
Vue.component('mk-reaction-icon', reactionIcon);
Vue.component('mk-reactions-viewer', reactionsViewer);
Vue.component('mk-time', time);
Vue.component('mk-media-list', mediaList);
Vue.component('mk-uploader', uploader);
Vue.component('mk-stream-indicator', streamIndicator);
Vue.component('mk-ellipsis', ellipsis);
Vue.component('mk-url-preview', urlPreview);
Vue.component('mk-file-type-icon', fileTypeIcon);
Vue.component('mk-emoji', emoji);
Vue.component('mk-welcome-timeline', welcomeTimeline);
Vue.component('mk-user-list', userList);
Vue.component('mk-frac', frac);
Vue.component('ui-input', uiInput);
Vue.component('ui-button', uiButton);
Vue.component('ui-horizon-group', uiHorizonGroup);
Vue.component('ui-card', uiCard);
Vue.component('ui-form', uiForm);
Vue.component('ui-textarea', uiTextarea);
Vue.component('ui-switch', uiSwitch);
Vue.component('ui-radio', uiRadio);
Vue.component('ui-select', uiSelect);
Vue.component('ui-info', uiInfo);
Vue.component('ui-margin', uiMargin);
Vue.component('ui-hr', uiHr);
Vue.component('ui-pagination', uiPagination);
Vue.component('ui-modal', uiModal);
Vue.component('form-button', formButton);
Vue.component('form-radio', formRadio);

View File

@ -1,53 +0,0 @@
<template>
<div class="nhasjydimbopojusarffqjyktglcuxjy" v-if="meta">
<div class="banner" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }"></div>
<h1>{{ meta.name || 'Misskey' }}</h1>
<p v-html="meta.description || this.$t('@.about')"></p>
<router-link to="/">{{ $t('start') }}</router-link>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n('common/views/components/instance.vue'),
data() {
return {
meta: null
}
},
created() {
this.$root.getMeta().then(meta => {
this.meta = meta;
});
}
});
</script>
<style lang="stylus" scoped>
.nhasjydimbopojusarffqjyktglcuxjy
color var(--text)
background var(--face)
text-align center
> .banner
height 100px
background-position center
background-size cover
> h1
margin 16px
font-size 16px
> p
margin 16px
font-size 14px
> a
display block
padding-bottom 16px
</style>

View File

@ -1,48 +0,0 @@
<template>
<a class="zxrjzpcj" :href="url" :class="service" rel="noopener" target="_blank">
<fa :icon="icon" size="lg" fixed-width /><span>{{ text }}</span>
</a>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['url', 'text', 'icon', 'service']
});
</script>
<style lang="stylus" scoped>
.zxrjzpcj
display inline-block
padding 6px 8px 6px 6px
margin-top 4px
margin-bottom 4px
border-radius 32px
white-space nowrap
&:hover
text-decoration none
&.twitter
color #fff
background #1da1f3
&:hover
background #0c87cf
&.github
color #fff
background #171515
&:hover
background #000
&.discord
color #fff
background #7289da
&:hover
background #4968ce
</style>

View File

@ -1,26 +0,0 @@
<template>
<div class="nbogcrmo" :v-if="user.twitter || user.github || user.discord">
<x-integration v-if="user.twitter" service="twitter" :url="`https://twitter.com/${user.twitter.screenName}`" :text="user.twitter.screenName" :icon="['fab', 'twitter']"/>
<x-integration v-if="user.github" service="github" :url="`https://github.com/${user.github.login}`" :text="user.github.login" :icon="['fab', 'github']"/>
<x-integration v-if="user.discord" service="discord" :url="`https://discordapp.com/users/${user.discord.id}`" :text="`${user.discord.username}#${user.discord.discriminator}`" :icon="['fab', 'discord']"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XIntegration from './integrations.integration.vue';
export default Vue.extend({
components: {
XIntegration
},
props: ['user']
});
</script>
<style lang="stylus" scoped>
.nbogcrmo
> *
margin-right 10px
</style>

View File

@ -1,113 +0,0 @@
<template>
<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false">
<div>
<b><fa icon="exclamation-triangle"/> {{ $t('sensitive') }}</b>
<span>{{ $t('click-to-show') }}</span>
</div>
</div>
<a class="gqnyydlzavusgskkfvwvjiattxdzsqlf" v-else
:href="image.url"
:style="style"
:title="image.name"
@click.prevent="onClick"
>
<div v-if="image.type === 'image/gif'">GIF</div>
</a>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import ImageViewer from './image-viewer.vue';
import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url';
export default Vue.extend({
i18n: i18n('common/views/components/media-image.vue'),
props: {
image: {
type: Object,
required: true
},
raw: {
default: false
}
},
data() {
return {
hide: true
};
},
computed: {
style(): any {
let url = `url(${
this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(this.image.thumbnailUrl)
: this.image.thumbnailUrl
})`;
if (this.$store.state.device.loadRemoteMedia || this.$store.state.device.lightmode) {
url = null;
} else if (this.raw || this.$store.state.device.loadRawImages) {
url = `url(${this.image.url})`;
}
return {
'background-color': this.image.properties.avgColor || 'transparent',
'background-image': url
};
}
},
methods: {
onClick() {
const viewer = this.$root.new(ImageViewer, {
image: this.image
});
this.$once('hook:beforeDestroy', () => {
viewer.close();
});
}
}
});
</script>
<style lang="stylus" scoped>
.gqnyydlzavusgskkfvwvjiattxdzsqlf
display block
cursor zoom-in
overflow hidden
width 100%
height 100%
background-position center
background-size contain
background-repeat no-repeat
> div
background-color var(--text)
border-radius 6px
color var(--secondary)
display inline-block
font-size 14px
font-weight bold
left 12px
opacity .5
padding 0 6px
text-align center
top 12px
pointer-events none
.qjewsnkgzzxlxtzncydssfbgjibiehcy
display flex
justify-content center
align-items center
background #111
color #fff
> div
display table-cell
text-align center
font-size 12px
> *
display block
</style>

View File

@ -1,113 +0,0 @@
<template>
<div class="mk-media-list">
<template v-for="media in mediaList.filter(media => !previewable(media))">
<x-banner :media="media" :key="media.id"/>
</template>
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container">
<div :data-count="mediaList.filter(media => previewable(media)).length" ref="grid">
<template v-for="media in mediaList">
<mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')"/>
<x-image :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/>
</template>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XBanner from './media-banner.vue';
import XImage from './media-image.vue';
export default Vue.extend({
components: {
XBanner,
XImage
},
props: {
mediaList: {
required: true
},
raw: {
default: false
}
},
mounted() {
//#region for Safari bug
if (this.$refs.grid) {
this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px`
: this.$store.state.device.inDeckMode ? '128px' : this.$root.isMobile ? '173px' : '287px';
}
//#endregion
},
methods: {
previewable(file) {
return (file.type.startsWith('video') || file.type.startsWith('image')) && file.thumbnailUrl;
}
}
});
</script>
<style lang="stylus" scoped>
.mk-media-list
> .gird-container
width 100%
margin-top 4px
&:before
content ''
display block
padding-top 56.25% // 16:9
> div
position absolute
top 0
right 0
bottom 0
left 0
display grid
grid-gap 4px
> *
overflow hidden
border-radius 4px
&[data-count="1"]
grid-template-rows 1fr
&[data-count="2"]
grid-template-columns 1fr 1fr
grid-template-rows 1fr
&[data-count="3"]
grid-template-columns 1fr 0.5fr
grid-template-rows 1fr 1fr
> *:nth-child(1)
grid-row 1 / 3
> *:nth-child(3)
grid-column 2 / 3
grid-row 2 / 3
&[data-count="4"]
grid-template-columns 1fr 1fr
grid-template-rows 1fr 1fr
> *:nth-child(1)
grid-column 1 / 2
grid-row 1 / 2
> *:nth-child(2)
grid-column 2 / 3
grid-row 1 / 2
> *:nth-child(3)
grid-column 1 / 2
grid-row 2 / 3
> *:nth-child(4)
grid-column 2 / 3
grid-row 2 / 3
</style>

View File

@ -1,196 +0,0 @@
<template>
<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv" :class="{ isMobile: $root.isMobile }">
<div class="backdrop" ref="backdrop" @click="close"></div>
<div class="popover" :class="{ bubble }" ref="popover">
<template v-for="item, i in items">
<div v-if="item === null"></div>
<button v-if="item" @click="clicked(item.action)" :tabindex="i">
<fa v-if="item.icon" :icon="item.icon"/>{{ item.text }}
</button>
</template>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import anime from 'animejs';
export default Vue.extend({
props: {
source: {
required: true
},
items: {
type: Array,
required: true
}
},
data() {
return {
bubble: !this.$root.isMobile
};
},
mounted() {
this.$nextTick(() => {
const popover = this.$refs.popover as any;
const rect = this.source.getBoundingClientRect();
const width = popover.offsetWidth;
const height = popover.offsetHeight;
let left;
let top;
if (this.$root.isMobile) {
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2);
left = (x - (width / 2));
top = (y - (height / 2));
} else {
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
const y = rect.top + window.pageYOffset + this.source.offsetHeight;
left = (x - (width / 2));
top = y;
}
if (left + width - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - width + window.pageXOffset;
this.bubble = false;
}
if (top + height - window.pageYOffset > window.innerHeight) {
top = window.innerHeight - height + window.pageYOffset;
this.bubble = false;
}
if (top < 0) {
top = 0;
}
popover.style.left = left + 'px';
popover.style.top = top + 'px';
anime({
targets: this.$refs.backdrop,
opacity: 1,
duration: 100,
easing: 'linear'
});
anime({
targets: this.$refs.popover,
opacity: 1,
scale: [0.5, 1],
duration: 500
});
});
},
methods: {
clicked(fn) {
fn();
this.close();
},
close() {
(this.$refs.backdrop as any).style.pointerEvents = 'none';
anime({
targets: this.$refs.backdrop,
opacity: 0,
duration: 200,
easing: 'linear'
});
(this.$refs.popover as any).style.pointerEvents = 'none';
anime({
targets: this.$refs.popover,
opacity: 0,
scale: 0.5,
duration: 200,
easing: 'easeInBack',
complete: () => {
this.$emit('closed');
this.destroyDom();
}
});
}
}
});
</script>
<style lang="stylus" scoped>
.onchrpzrvnoruiaenfcqvccjfuupzzwv
$bg-color = var(--popupBg)
position initial
&.isMobile
> .popover
> button
font-size 15px
> .backdrop
position fixed
top 0
left 0
z-index 10000
width 100%
height 100%
background var(--modalBackdrop)
opacity 0
> .popover
position absolute
z-index 10001
padding 8px 0
background $bg-color
border-radius 4px
box-shadow 0 3px 12px rgba(27, 31, 35, 0.15)
transform scale(0.5)
opacity 0
$balloon-size = 16px
&.bubble
margin-top $balloon-size
transform-origin center -($balloon-size)
&:before
&:after
content ""
display block
position absolute
pointer-events none
&:before
top -($balloon-size * 2)
left s('calc(50% - %s)', $balloon-size)
border-top solid $balloon-size transparent
border-left solid $balloon-size transparent
border-right solid $balloon-size transparent
border-bottom solid $balloon-size $bg-color
> button
display block
padding 8px 16px
width 100%
color var(--popupFg)
white-space nowrap
&:hover
color var(--primaryForeground)
background var(--primary)
text-decoration none
&:active
color var(--primaryForeground)
background var(--primaryDarken10)
> [data-icon]
margin-right 4px
> div
margin 8px 0
height var(--lineWidth)
background var(--faceDivider)
</style>

View File

@ -1,279 +0,0 @@
<template>
<div class="message" :data-is-me="isMe">
<mk-avatar class="avatar" :user="message.user" target="_blank"/>
<div class="content">
<div class="balloon" :data-no-text="message.text == null">
<button class="delete-button" v-if="isMe" :title="$t('@.delete')" @click="del">
<img src="/assets/desktop/remove.png" alt="Delete"/>
</button>
<div class="content" v-if="!message.isDeleted">
<mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/>
<div class="file" v-if="message.file">
<a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"
:style="{ backgroundColor: message.file.properties.avgColor || 'transparent' }"/>
<p v-else>{{ message.file.name }}</p>
</a>
</div>
</div>
<div class="content" v-else>
<p class="is-deleted">{{ $t('deleted') }}</p>
</div>
</div>
<div></div>
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
<footer>
<template v-if="isGroup">
<span class="read" v-if="message.reads.length > 0">{{ $t('is-read') }} {{ message.reads.length }}</span>
</template>
<template v-else>
<span class="read" v-if="isMe && message.isRead">{{ $t('is-read') }}</span>
</template>
<mk-time :time="message.createdAt"/>
<template v-if="message.is_edited"><fa icon="pencil-alt"/></template>
</footer>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import { parse } from '../../../../../mfm/parse';
import { unique } from '../../../../../prelude/array';
export default Vue.extend({
i18n: i18n('common/views/components/messaging-room.message.vue'),
props: {
message: {
required: true
},
isGroup: {
required: false
}
},
computed: {
isMe(): boolean {
return this.message.userId == this.$store.state.i.id;
},
urls(): string[] {
if (this.message.text) {
const ast = parse(this.message.text);
return unique(ast
.filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
.map(t => t.node.props.url));
} else {
return null;
}
}
},
methods: {
del() {
this.$root.api('messaging/messages/delete', {
messageId: this.message.id
});
}
}
});
</script>
<style lang="stylus" scoped>
.message
$me-balloon-color = var(--primary)
padding 10px 12px 10px 12px
background-color transparent
> .avatar
display block
position absolute
top 10px
width 54px
height 54px
border-radius 8px
transition all 0.1s ease
> .content
> .balloon
display flex
align-items center
padding 0
max-width calc(100% - 16px)
min-height 38px
border-radius 16px
&:before
content ""
pointer-events none
display block
position absolute
top 12px
& + *
clear both
&:hover
> .delete-button
display block
> .delete-button
display none
position absolute
z-index 1
top -4px
right -4px
margin 0
padding 0
cursor pointer
outline none
border none
border-radius 0
box-shadow none
background transparent
> img
vertical-align bottom
width 16px
height 16px
cursor pointer
> .content
max-width 100%
> .is-deleted
display block
margin 0
padding 0
overflow hidden
overflow-wrap break-word
font-size 1em
color rgba(#000, 0.5)
> .text
display block
margin 0
padding 8px 16px
overflow hidden
overflow-wrap break-word
word-break break-word
font-size 1em
color rgba(#000, 0.8)
& + .file
> a
border-radius 0 0 16px 16px
> .file
> a
display block
max-width 100%
border-radius 16px
overflow hidden
text-decoration none
&:hover
text-decoration none
> p
background #ccc
> *
display block
margin 0
width 100%
max-height 512px
object-fit contain
> p
padding 30px
text-align center
color #555
background #ddd
> .mk-url-preview
margin 8px 0
> footer
display block
margin 2px 0 0 0
font-size 10px
color var(--messagingRoomMessageInfo)
> .read
margin 0 8px
> [data-icon]
margin-left 4px
&:not([data-is-me])
> .avatar
left 12px
> .content
padding-left 66px
> .balloon
$color = var(--messagingRoomMessageBg)
float left
background $color
&[data-no-text]
background transparent
&:not([data-no-text]):before
left -14px
border-top solid 8px transparent
border-right solid 8px $color
border-bottom solid 8px transparent
border-left solid 8px transparent
> .content
> .text
color var(--messagingRoomMessageFg)
> footer
text-align left
&[data-is-me]
> .avatar
right 12px
> .content
padding-right 66px
> .balloon
float right
background $me-balloon-color
&[data-no-text]
background transparent
&:not([data-no-text]):before
right -14px
left auto
border-top solid 8px transparent
border-right solid 8px transparent
border-bottom solid 8px transparent
border-left solid 8px $me-balloon-color
> .content
> p.is-deleted
color rgba(#fff, 0.5)
> .text >>>
&, *
color #fff !important
> footer
text-align right
> .read
user-select none
&[data-is-deleted]
> .balloon
opacity 0.5
</style>

View File

@ -1,500 +0,0 @@
<template>
<div class="mk-messaging" :data-compact="compact">
<div class="search" v-if="!compact" :style="{ top: headerTop + 'px' }">
<div class="form">
<label for="search-input"><i><fa icon="search"/></i></label>
<input v-model="q" type="search" @input="search" @keydown="onSearchKeydown" :placeholder="$t('search-user')"/>
</div>
<div class="result">
<ol class="users" v-if="result.length > 0" ref="searchResult">
<li v-for="(user, i) in result"
@keydown.enter="navigate(user)"
@keydown="onSearchResultKeydown(i)"
@click="navigate(user)"
tabindex="-1"
>
<mk-avatar class="avatar" :user="user" :key="user.id"/>
<span class="name"><mk-user-name :user="user" :key="user.id"/></span>
<span class="username">@{{ user | acct }}</span>
</li>
</ol>
</div>
</div>
<div class="history" v-if="messages.length > 0">
<a v-for="message in messages"
class="user"
:href="message.groupId ? `/i/messaging/group/${message.groupId}` : `/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
:data-is-me="isMe(message)"
:data-is-read="message.groupId ? message.reads.includes($store.state.i.id) : message.isRead"
@click.prevent="message.groupId ? navigateGroup(message.group) : navigate(isMe(message) ? message.recipient : message.user)"
:key="message.id"
>
<div>
<mk-avatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user"/>
<header v-if="message.groupId">
<span class="name">{{ message.group.name }}</span>
<mk-time :time="message.createdAt"/>
</header>
<header v-else>
<span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span>
<span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span>
<mk-time :time="message.createdAt"/>
</header>
<div class="body">
<p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p>
</div>
</div>
</a>
</div>
<p class="no-history" v-if="!fetching && messages.length == 0">{{ $t('no-history') }}</p>
<p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p>
<ui-margin>
<ui-button @click="startUser()"><fa :icon="faUser"/> {{ $t('start-with-user') }}</ui-button>
<ui-button @click="startGroup()"><fa :icon="faUsers"/> {{ $t('start-with-group') }}</ui-button>
</ui-margin>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faUser, faUsers } from '@fortawesome/free-solid-svg-icons';
import i18n from '../../../i18n';
import getAcct from '../../../../../misc/acct/render';
export default Vue.extend({
i18n: i18n('common/views/components/messaging.vue'),
props: {
compact: {
type: Boolean,
default: false
},
headerTop: {
type: Number,
default: 0
}
},
data() {
return {
fetching: true,
moreFetching: false,
messages: [],
q: null,
result: [],
connection: null,
faUser, faUsers
};
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('messagingIndex');
this.connection.on('message', this.onMessage);
this.connection.on('read', this.onRead);
this.$root.api('messaging/history', { group: false }).then(userMessages => {
this.$root.api('messaging/history', { group: true }).then(groupMessages => {
const messages = userMessages.concat(groupMessages);
messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
this.messages = messages;
this.fetching = false;
});
});
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
getAcct,
isMe(message) {
return message.userId == this.$store.state.i.id;
},
onMessage(message) {
if (message.recipientId) {
this.messages = this.messages.filter(m => !(
(m.recipientId == message.recipientId && m.userId == message.userId) ||
(m.recipientId == message.userId && m.userId == message.recipientId)));
this.messages.unshift(message);
} else if (message.groupId) {
this.messages = this.messages.filter(m => m.groupId !== message.groupId);
this.messages.unshift(message);
}
},
onRead(ids) {
for (const id of ids) {
const found = this.messages.find(m => m.id == id);
if (found) {
if (found.recipientId) {
found.isRead = true;
} else if (found.groupId) {
found.reads.push(this.$store.state.i.id);
}
}
}
},
search() {
if (this.q == '') {
this.result = [];
return;
}
this.$root.api('users/search', {
query: this.q,
localOnly: false,
limit: 10,
detail: false
}).then(users => {
this.result = users.filter(user => user.id != this.$store.state.i.id);
});
},
navigate(user) {
this.$emit('navigate', user);
},
navigateGroup(group) {
this.$emit('navigateGroup', group);
},
onSearchKeydown(e) {
switch (e.which) {
case 9: // [TAB]
case 40: // [↓]
e.preventDefault();
e.stopPropagation();
(this.$refs.searchResult as any).childNodes[0].focus();
break;
}
},
onSearchResultKeydown(i, e) {
const list = this.$refs.searchResult as any;
const cancel = () => {
e.preventDefault();
e.stopPropagation();
};
switch (true) {
case e.which == 27: // [ESC]
cancel();
(this.$refs.search as any).focus();
break;
case e.which == 9 && e.shiftKey: // [TAB] + [Shift]
case e.which == 38: // [↑]
cancel();
(list.childNodes[i].previousElementSibling || list.childNodes[this.result.length - 1]).focus();
break;
case e.which == 9: // [TAB]
case e.which == 40: // [↓]
cancel();
(list.childNodes[i].nextElementSibling || list.childNodes[0]).focus();
break;
}
},
async startUser() {
const { result: user } = await this.$root.dialog({
user: {
local: true
}
});
if (user == null) return;
this.navigate(user);
},
async startGroup() {
const groups1 = await this.$root.api('users/groups/owned');
const groups2 = await this.$root.api('users/groups/joined');
const { canceled, result: group } = await this.$root.dialog({
type: null,
title: this.$t('select-group'),
select: {
items: groups1.concat(groups2).map(group => ({
value: group, text: group.name
}))
},
showCancelButton: true
});
if (canceled) return;
this.navigateGroup(group);
}
}
});
</script>
<style lang="stylus" scoped>
.mk-messaging
&[data-compact]
font-size 0.8em
> .history
> a
&:last-child
border-bottom none
&:not([data-is-me]):not([data-is-read])
> div
background-image none
border-left solid 4px #3aa2dc
> div
padding 16px
> header
> .mk-time
font-size 1em
> .avatar
width 42px
height 42px
margin 0 12px 0 0
> .search
display block
position -webkit-sticky
position sticky
top 0
left 0
z-index 1
width 100%
box-shadow 0 0 2px rgba(#000, 0.2)
> .form
background rgba(0, 0, 0, 0.02)
> label
display block
position absolute
top 0
left 8px
z-index 1
height 100%
width 38px
pointer-events none
> i
display block
position absolute
top 0
right 0
bottom 0
left 0
width 1em
line-height 48px
margin auto
color #555
> input
margin 0
padding 0 0 0 42px
width 100%
font-size 1em
line-height 48px
color var(--faceText)
outline none
background transparent
border none
border-radius 5px
box-shadow none
> .result
display block
top 0
left 0
z-index 2
width 100%
margin 0
padding 0
background #fff
> .users
margin 0
padding 0
list-style none
> li
display inline-block
z-index 1
width 100%
padding 8px 32px
vertical-align top
white-space nowrap
overflow hidden
color rgba(#000, 0.8)
text-decoration none
transition none
cursor pointer
&:hover
&:focus
color #fff
background var(--primary)
.name
color #fff
.username
color #fff
&:active
color #fff
background var(--primaryDarken10)
.name
color #fff
.username
color #fff
.avatar
vertical-align middle
min-width 32px
min-height 32px
max-width 32px
max-height 32px
margin 0 8px 0 0
border-radius 6px
.name
margin 0 8px 0 0
/*font-weight bold*/
font-weight normal
color rgba(#000, 0.8)
.username
font-weight normal
color rgba(#000, 0.3)
> .history
> a
display block
text-decoration none
background var(--face)
border-bottom solid 1px var(--faceDivider)
*
pointer-events none
user-select none
&:hover
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
.avatar
filter saturate(200%)
&:active
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
&[data-is-read]
&[data-is-me]
opacity 0.8
&:not([data-is-me]):not([data-is-read])
> div
background-image url("/assets/unread.svg")
background-repeat no-repeat
background-position 0 center
&:after
content ""
display block
clear both
> div
max-width 500px
margin 0 auto
padding 20px 30px
&:after
content ""
display block
clear both
> header
display flex
align-items center
margin-bottom 2px
white-space nowrap
overflow hidden
> .name
margin 0
padding 0
overflow hidden
text-overflow ellipsis
font-size 1em
color var(--noteHeaderName)
font-weight bold
transition all 0.1s ease
> .username
margin 0 8px
color var(--noteHeaderAcct)
> .mk-time
margin 0 0 0 auto
color var(--noteHeaderInfo)
font-size 80%
> .avatar
float left
width 54px
height 54px
margin 0 16px 0 0
border-radius 8px
transition all 0.1s ease
> .body
> .text
display block
margin 0 0 0 0
padding 0
overflow hidden
overflow-wrap break-word
font-size 1.1em
color var(--faceText)
.me
opacity 0.7
> .image
display block
max-width 100%
max-height 512px
> .no-history
margin 0
padding 2em 1em
text-align center
color #999
font-weight 500
> .fetching
margin 0
padding 16px
text-align center
color var(--text)
> [data-icon]
margin-right 4px
// TODO: element base media query
@media (max-width 400px)
> .search
> .result
> .users
> li
padding 8px 16px
> .history
> a
&:not([data-is-me]):not([data-is-read])
> div
background-image none
border-left solid 4px #3aa2dc
> div
padding 16px
font-size 14px
> .avatar
margin 0 12px 0 0
</style>

View File

@ -1,43 +0,0 @@
<template>
<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }" v-once/>
</template>
<script lang="ts">
import Vue from 'vue';
import MfmCore from './mfm';
export default Vue.extend({
components: {
MfmCore
}
});
</script>
<style lang="stylus" scoped>
.havbbuyv
white-space pre-wrap
&.nowrap
white-space pre
word-wrap normal // https://codeday.me/jp/qa/20190424/690106.html
>>> .title
display block
margin-bottom 4px
padding 4px
font-size 90%
text-align center
background var(--mfmTitleBg)
border-radius 4px
>>> .quote
display block
margin 8px
padding 6px 0 6px 12px
color var(--mfmQuote)
border-left solid 3px var(--mfmQuoteLine)
>>> pre code
font-size 80%
</style>

View File

@ -1,47 +0,0 @@
<template>
<span class="mk-nav">
<a :href="aboutUrl">{{ $t('about') }}</a>
<template v-if="ToSUrl !== null">
<i></i>
<a :href="ToSUrl" target="_blank">{{ $t('tos') }}</a>
</template>
<i></i>
<a :href="repositoryUrl" rel="noopener" target="_blank">{{ $t('repository') }}</a>
<i></i>
<a :href="feedbackUrl" rel="noopener" target="_blank">{{ $t('feedback') }}</a>
<i></i>
<a href="/dev" target="_blank">{{ $t('develop') }}</a>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import { lang } from '../../../config';
export default Vue.extend({
i18n: i18n('common/views/components/nav.vue'),
data() {
return {
aboutUrl: `/docs/${lang}/about`,
repositoryUrl: 'https://github.com/syuilo/misskey',
feedbackUrl: 'https://github.com/syuilo/misskey/issues/new',
ToSUrl: null
}
},
mounted() {
this.$root.getMeta(true).then(meta => {
this.repositoryUrl = meta.repositoryUrl;
this.feedbackUrl = meta.feedbackUrl;
this.ToSUrl = meta.ToSUrl;
})
}
});
</script>
<style lang="stylus" scoped>
.mk-nav
a
color inherit
</style>

View File

@ -1,118 +0,0 @@
<template>
<header class="bvonvjxbwzaiskogyhbwgyxvcgserpmu">
<mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle == 'smart'"/>
<router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">
<mk-user-name :user="note.user"/>
</router-link>
<span class="is-admin" v-if="note.user.isAdmin">admin</span>
<span class="is-bot" v-if="note.user.isBot">bot</span>
<span class="is-cat" v-if="note.user.isCat">cat</span>
<span class="username"><mk-acct :user="note.user"/></span>
<div class="info">
<span class="app" v-if="note.app && !mini && $store.state.settings.showVia">via <b>{{ note.app.name }}</b></span>
<span class="mobile" v-if="note.viaMobile"><fa icon="mobile-alt"/></span>
<router-link class="created-at" :to="note | notePage">
<mk-time :time="note.createdAt"/>
</router-link>
<span class="visibility" v-if="note.visibility != 'public'">
<fa v-if="note.visibility == 'home'" icon="home"/>
<fa v-if="note.visibility == 'followers'" icon="unlock"/>
<fa v-if="note.visibility == 'specified'" icon="envelope"/>
</span>
<span class="localOnly" v-if="note.localOnly == true"><fa icon="heart"/></span>
</div>
</header>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n(),
props: {
note: {
type: Object,
required: true
},
mini: {
type: Boolean,
required: false,
default: false
}
}
});
</script>
<style lang="stylus" scoped>
.bvonvjxbwzaiskogyhbwgyxvcgserpmu
display flex
align-items baseline
white-space nowrap
> .avatar
flex-shrink 0
margin-right 8px
width 20px
height 20px
border-radius 100%
> .name
display block
margin 0 .5em 0 0
padding 0
overflow hidden
color var(--noteHeaderName)
font-size 1em
font-weight bold
text-decoration none
text-overflow ellipsis
&:hover
text-decoration underline
> .is-admin
> .is-bot
> .is-cat
flex-shrink 0
align-self center
margin 0 .5em 0 0
padding 1px 6px
font-size 80%
color var(--noteHeaderBadgeFg)
background var(--noteHeaderBadgeBg)
border-radius 3px
&.is-admin
background var(--noteHeaderAdminBg)
color var(--noteHeaderAdminFg)
> .username
margin 0 .5em 0 0
overflow hidden
text-overflow ellipsis
color var(--noteHeaderAcct)
flex-shrink 2147483647
> .info
margin-left auto
font-size 0.9em
> *
color var(--noteHeaderInfo)
> .mobile
margin-right 8px
> .app
margin-right 8px
padding-right 8px
border-right solid 1px var(--faceDivider)
> .visibility
margin-left 8px
> .localOnly
margin-left 4px
</style>

View File

@ -1,52 +0,0 @@
<template>
<div>
<vue-content-loading v-if="width" :width="width" :height="100" :primary="primary" :secondary="secondary">
<circle cx="30" cy="30" r="30" />
<rect x="75" y="13" rx="4" ry="4" :width="150 + r1" height="15" />
<rect x="75" y="39" rx="4" ry="4" :width="260 + r2" height="10" />
<rect x="75" y="59" rx="4" ry="4" :width="230 + r3" height="10" />
</vue-content-loading>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import VueContentLoading from 'vue-content-loading';
import * as tinycolor from 'tinycolor2';
export default Vue.extend({
components: {
VueContentLoading,
},
data() {
return {
width: 0,
r1: (Math.random() * 100) - 50,
r2: (Math.random() * 100) - 50,
r3: (Math.random() * 100) - 50
};
},
computed: {
text(): tinycolor.Instance {
const text = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text'));
return text;
},
primary(): string {
return '#' + this.text.clone().toHex();
},
secondary(): string {
return '#' + this.text.clone().darken(20).toHex();
}
},
mounted() {
let width = this.$el.clientWidth;
if (width < 400) width = 400;
this.width = width;
}
});
</script>

View File

@ -1,138 +0,0 @@
<template>
<router-link :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1">
<div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div>
<article>
<header>
<h1 :title="page.title">{{ page.title }}</h1>
</header>
<p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p>
<footer>
<img class="icon" :src="page.user.avatarUrl"/>
<p>{{ page.user | userName }}</p>
</footer>
</article>
</router-link>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
page: {
type: Object,
required: true
},
},
});
</script>
<style lang="stylus" scoped>
.vhpxefrj
display block
overflow hidden
width 100%
border solid var(--lineWidth) var(--urlPreviewBorder)
border-radius 4px
overflow hidden
&:hover
text-decoration none
border-color var(--urlPreviewBorderHover)
> .thumbnail
position absolute
width 100px
height 100%
background-position center
background-size cover
display flex
justify-content center
align-items center
> button
font-size 3.5em
opacity: 0.7
&:hover
font-size 4em
opacity 0.9
& + article
left 100px
width calc(100% - 100px)
> article
padding 16px
> header
margin-bottom 8px
> h1
margin 0
font-size 1em
color var(--urlPreviewTitle)
> p
margin 0
color var(--urlPreviewText)
font-size 0.8em
> footer
margin-top 8px
height 16px
> img
display inline-block
width 16px
height 16px
margin-right 4px
vertical-align top
> p
display inline-block
margin 0
color var(--urlPreviewInfo)
font-size 0.8em
line-height 16px
vertical-align top
@media (max-width 700px)
> .thumbnail
position relative
width 100%
height 100px
& + article
left 0
width 100%
@media (max-width 550px)
font-size 12px
> .thumbnail
height 80px
> article
padding 12px
@media (max-width 500px)
font-size 10px
> .thumbnail
height 70px
> article
padding 8px
> header
margin-bottom 4px
> footer
margin-top 4px
> img
width 12px
height 12px
</style>

View File

@ -1,68 +0,0 @@
<template>
<div class="ngbfujlo">
<ui-textarea class="textarea" :value="text" readonly></ui-textarea>
<ui-button primary @click="post()" :disabled="posting || posted">{{ posted ? $t('posted-from-post-form') : $t('post-from-post-form') }}</ui-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('pages'),
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
text: this.script.interpolate(this.value.text),
posted: false,
posting: false,
};
},
created() {
this.$watch('script.vars', () => {
this.text = this.script.interpolate(this.value.text);
}, { deep: true });
},
methods: {
post() {
this.posting = true;
this.$root.api('notes/create', {
text: this.text,
}).then(() => {
this.posted = true;
this.$root.dialog({
type: 'success',
splash: true
});
});
}
}
});
</script>
<style lang="stylus" scoped>
.ngbfujlo
padding 0 32px 32px 32px
border solid 2px var(--pageBlockBorder)
border-radius 6px
@media (max-width 600px)
padding 0 16px 16px 16px
> .textarea
margin-top 16px
margin-bottom 16px
</style>

File diff suppressed because one or more lines are too long

View File

@ -1,235 +0,0 @@
<template>
<div class="zmdxowus">
<p class="caution" v-if="choices.length < 2">
<fa icon="exclamation-triangle"/>{{ $t('no-only-one-choice') }}
</p>
<ul ref="choices">
<li v-for="(choice, i) in choices">
<input :value="choice" @input="onInput(i, $event)" :placeholder="$t('choice-n').replace('{}', i + 1)">
<button @click="remove(i)" :title="$t('remove')">
<fa icon="times"/>
</button>
</li>
</ul>
<button class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</button>
<button class="add" v-else disabled>{{ $t('no-more') }}</button>
<button class="destroy" @click="destroy" :title="$t('destroy')">
<fa icon="times"/>
</button>
<section>
<ui-switch v-model="multiple">{{ $t('multiple') }}</ui-switch>
<div>
<ui-select v-model="expiration">
<template #label>{{ $t('expiration') }}</template>
<option value="infinite">{{ $t('infinite') }}</option>
<option value="at">{{ $t('at') }}</option>
<option value="after">{{ $t('after') }}</option>
</ui-select>
<section v-if="expiration === 'at'">
<ui-input v-model="atDate" type="date">
<template #title>{{ $t('deadline-date') }}</template>
</ui-input>
<ui-input v-model="atTime" type="time">
<template #title>{{ $t('deadline-time') }}</template>
</ui-input>
</section>
<section v-if="expiration === 'after'">
<ui-input v-model="after" type="number">
<template #title>{{ $t('interval') }}</template>
</ui-input>
<ui-select v-model="unit">
<template #title>{{ $t('unit') }}</template>
<option value="second">{{ $t('second') }}</option>
<option value="minute">{{ $t('minute') }}</option>
<option value="hour">{{ $t('hour') }}</option>
<option value="day">{{ $t('day') }}</option>
</ui-select>
</section>
</div>
</section>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import { erase } from '../../../../../prelude/array';
import { addTimespan } from '../../../../../prelude/time';
import { formatDateTimeString } from '../../../../../misc/format-time-string';
export default Vue.extend({
i18n: i18n('common/views/components/poll-editor.vue'),
data() {
return {
choices: ['', ''],
multiple: false,
expiration: 'infinite',
atDate: formatDateTimeString(addTimespan(new Date(), 1, 'days'), 'yyyy-MM-dd'),
atTime: '00:00',
after: 0,
unit: 'second'
};
},
watch: {
choices() {
this.$emit('updated');
}
},
methods: {
onInput(i, e) {
Vue.set(this.choices, i, e.target.value);
},
add() {
this.choices.push('');
this.$nextTick(() => {
(this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus();
});
},
remove(i) {
this.choices = this.choices.filter((_, _i) => _i != i);
},
destroy() {
this.$emit('destroyed');
},
get() {
const at = () => {
return new Date(`${this.atDate} ${this.atTime}`).getTime();
};
const after = () => {
let base = parseInt(this.after);
switch (this.unit) {
case 'day': base *= 24;
case 'hour': base *= 60;
case 'minute': base *= 60;
case 'second': return base *= 1000;
default: return null;
}
};
return {
choices: erase('', this.choices),
multiple: this.multiple,
...(
this.expiration === 'at' ? { expiresAt: at() } :
this.expiration === 'after' ? { expiredAfter: after() } : {})
};
},
set(data) {
if (data.choices.length == 0) return;
this.choices = data.choices;
if (data.choices.length == 1) this.choices = this.choices.concat('');
this.multiple = data.multiple;
if (data.expiresAt) {
this.expiration = 'at';
this.atDate = this.atTime = data.expiresAt;
} else if (typeof data.expiredAfter === 'number') {
this.expiration = 'after';
this.after = data.expiredAfter;
} else {
this.expiration = 'infinite';
}
}
}
});
</script>
<style lang="stylus" scoped>
.zmdxowus
padding 8px
> .caution
margin 0 0 8px 0
font-size 0.8em
color #f00
> [data-icon]
margin-right 4px
> ul
display block
margin 0
padding 0
list-style none
> li
display block
margin 8px 0
padding 0
width 100%
&:first-child
margin-top 0
&:last-child
margin-bottom 0
> input
padding 6px 8px
width 300px
font-size 14px
color var(--inputText)
background var(--pollEditorInputBg)
border solid 1px var(--primaryAlpha01)
border-radius 4px
&:hover
border-color var(--primaryAlpha02)
&:focus
border-color var(--primaryAlpha05)
> button
padding 4px 8px
color var(--primaryAlpha04)
&:hover
color var(--primaryAlpha06)
&:active
color var(--primaryDarken30)
> .add
margin 8px 0 0 0
vertical-align top
color var(--primary)
z-index 1
> .destroy
position absolute
top 0
right 0
padding 4px 8px
color var(--primaryAlpha04)
&:hover
color var(--primaryAlpha06)
&:active
color var(--primaryDarken30)
> section
margin 16px 0 -16px 0
> div
margin 0 8px
&:last-child
flex 1 0 auto
> section
align-items center
display flex
margin -32px 0 0
> :first-child
margin-right 16px
> .ui-input
flex 1 0 auto
</style>

View File

@ -1,148 +0,0 @@
<template>
<div class="mk-poll" :data-done="closed || isVoted">
<ul>
<li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }" :title="!closed && !isVoted ? $t('vote-to').replace('{}', choice.text) : ''">
<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span>
<template v-if="choice.isVoted"><fa icon="check"/></template>
<mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/>
<span class="votes" v-if="showResult">({{ $t('vote-count').replace('{}', choice.votes) }})</span>
</span>
</li>
</ul>
<p>
<span>{{ $t('total-votes').replace('{}', total) }}</span>
<span> · </span>
<a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $t('vote') : $t('show-result') }}</a>
<span v-if="isVoted">{{ $t('voted') }}</span>
<span v-else-if="closed">{{ $t('closed') }}</span>
<span v-if="remaining > 0"> · {{ timer }}</span>
</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import { sum } from '../../../../../prelude/array';
export default Vue.extend({
i18n: i18n('common/views/components/poll.vue'),
props: ['note'],
data() {
return {
remaining: -1,
showResult: false
};
},
computed: {
poll(): any {
return this.note.poll;
},
total(): number {
return sum(this.poll.choices.map(x => x.votes));
},
closed(): boolean {
return !this.remaining;
},
timer(): string {
return this.$t(
this.remaining > 86400 ? 'remaining-days' :
this.remaining > 3600 ? 'remaining-hours' :
this.remaining > 60 ? 'remaining-minutes' : 'remaining-seconds')
.replace('{s}', Math.floor(this.remaining % 60))
.replace('{m}', Math.floor(this.remaining / 60) % 60)
.replace('{h}', Math.floor(this.remaining / 3600) % 24)
.replace('{d}', Math.floor(this.remaining / 86400));
},
isVoted(): boolean {
return !this.poll.multiple && this.poll.choices.some(c => c.isVoted);
}
},
created() {
this.showResult = this.isVoted;
if (this.note.poll.expiresAt) {
const update = () => {
if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000))
requestAnimationFrame(update);
else
this.showResult = true;
};
update();
}
},
methods: {
toggleShowResult() {
this.showResult = !this.showResult;
},
vote(id) {
if (this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return;
this.$root.api('notes/polls/vote', {
noteId: this.note.id,
choice: id
}).then(() => {
if (!this.showResult) this.showResult = !this.poll.multiple;
});
}
}
});
</script>
<style lang="stylus" scoped>
.mk-poll
> ul
display block
margin 0
padding 0
list-style none
> li
display block
margin 4px 0
padding 4px 8px
width 100%
color var(--pollChoiceText)
border solid 1px var(--pollChoiceBorder)
border-radius 4px
overflow hidden
cursor pointer
&:hover
background rgba(#000, 0.05)
&:active
background rgba(#000, 0.1)
> .backdrop
position absolute
top 0
left 0
height 100%
background var(--primary)
transition width 1s ease
> span
> [data-icon]
margin-right 4px
> .votes
margin-left 4px
> p
color var(--text)
a
color inherit
&[data-done]
> ul > li
cursor default
&:hover
background transparent
&:active
background transparent
</style>

View File

@ -1,139 +0,0 @@
<template>
<div class="skeikyzd" v-show="files.length != 0">
<x-draggable class="files" :list="files" animation="150">
<div v-for="file in files" :key="file.id" @click="showFileMenu(file, $event)" @contextmenu.prevent="showFileMenu(file, $event)">
<x-file-thumbnail :data-id="file.id" class="thumbnail" :file="file" fit="cover"/>
<img class="remove" @click.stop="detachMedia(file.id)" src="/assets/desktop/remove.png" :title="$t('attach-cancel')" alt=""/>
<div class="sensitive" v-if="file.isSensitive">
<fa class="icon" :icon="faExclamationTriangle"/>
</div>
</div>
</x-draggable>
<p class="remain">{{ 4 - files.length }}/4</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import * as XDraggable from 'vuedraggable';
import XMenu from '../../../common/views/components/menu.vue';
import { faTimesCircle, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import XFileThumbnail from './drive-file-thumbnail.vue'
export default Vue.extend({
i18n: i18n('common/views/components/post-form-attaches.vue'),
components: {
XDraggable,
XFileThumbnail
},
props: {
files: {
type: Array,
required: true
},
detachMediaFn: {
type: Function,
required: false
}
},
data() {
return {
faExclamationTriangle
};
},
methods: {
detachMedia(id) {
if (this.detachMediaFn) this.detachMediaFn(id)
else if (this.$parent.detachMedia) this.$parent.detachMedia(id)
},
toggleSensitive(file) {
this.$root.api('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive
}).then(() => {
file.isSensitive = !file.isSensitive;
});
},
showFileMenu(file, ev: MouseEvent) {
this.$root.new(XMenu, {
items: [{
type: 'item',
text: file.isSensitive ? this.$t('unmark-as-sensitive') : this.$t('mark-as-sensitive'),
icon: file.isSensitive ? faEyeSlash : faEye,
action: () => { this.toggleSensitive(file) }
}, {
type: 'item',
text: this.$t('attach-cancel'),
icon: faTimesCircle,
action: () => { this.detachMedia(file.id) }
}],
source: ev.currentTarget || ev.target
});
}
}
});
</script>
<style lang="stylus" scoped>
.skeikyzd
padding 4px
> .files
display flex
flex-wrap wrap
> div
width 64px
height 64px
margin 4px
cursor move
&:hover > .remove
display block
> .thumbnail
width 100%
height 100%
z-index 1
color var(--text)
> .remove
display none
position absolute
top -6px
right -6px
width 16px
height 16px
cursor pointer
z-index 1000
> .sensitive
display flex
position absolute
width 64px
height 64px
top 0
left 0
z-index 2
background rgba(17, 17, 17, .7)
color #fff
> .icon
margin auto
> .remain
display block
position absolute
top 8px
right 8px
margin 0
padding 0
color var(--primaryAlpha04)
</style>

View File

@ -1,53 +0,0 @@
<template>
<mk-emoji :emoji="str.startsWith(':') ? null : str" :name="str.startsWith(':') ? str.substr(1, str.length - 2) : null" :is-reaction="true" :custom-emojis="customEmojis" :normal="true"/>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n(),
props: {
reaction: {
type: String,
required: true
},
},
data() {
return {
customEmojis: []
};
},
created() {
this.$root.getMeta().then(meta => {
if (meta && meta.emojis) this.customEmojis = meta.emojis;
});
},
computed: {
str(): any {
switch (this.reaction) {
case 'like': return '👍';
case 'love': return '❤';
case 'laugh': return '😆';
case 'hmm': return '🤔';
case 'surprise': return '😮';
case 'congrats': return '🎉';
case 'angry': return '💢';
case 'confused': return '😥';
case 'rip': return '😇';
case 'pudding': return (this.$store.getters.isSignedIn && this.$store.state.settings.iLikeSushi) ? '🍣' : '🍮';
case 'star': return '⭐';
default: return this.reaction;
}
},
},
});
</script>
<style lang="stylus" scoped>
.mk-reaction-icon
img
vertical-align middle
width 1em
height 1em
</style>

View File

@ -1,323 +0,0 @@
<template>
<div class="rdfaahpb" v-hotkey.global="keymap">
<div class="backdrop" ref="backdrop" @click="close"></div>
<div class="popover" :class="{ isMobile: $root.isMobile }" ref="popover">
<p v-if="!$root.isMobile">{{ title }}</p>
<div class="buttons" ref="buttons" :class="{ showFocus }">
<button v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" @mouseover="onMouseover" @mouseout="onMouseout" :tabindex="i + 1" :title="/^[a-z]+$/.test(reaction) ? $t('@.reactions.' + reaction) : reaction" v-particle><mk-reaction-icon :reaction="reaction"/></button>
</div>
<div v-if="enableEmojiReaction" class="text">
<input v-model="text" :placeholder="$t('input-reaction-placeholder')" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }">
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import anime from 'animejs';
import { emojiRegex } from '../../../../../misc/emoji-regex';
export default Vue.extend({
i18n: i18n('common/views/components/reaction-picker.vue'),
props: {
source: {
required: true
},
reactions: {
required: false
},
showFocus: {
type: Boolean,
required: false,
default: false
},
animation: {
type: Boolean,
required: false,
default: true
}
},
data() {
return {
rs: this.reactions || this.$store.state.settings.reactions,
title: this.$t('choose-reaction'),
text: null,
enableEmojiReaction: false,
focus: null
};
},
computed: {
keymap(): any {
return {
'esc': this.close,
'enter|space|plus': this.choose,
'up|k': this.focusUp,
'left|h|shift+tab': this.focusLeft,
'right|l|tab': this.focusRight,
'down|j': this.focusDown,
'1': () => this.react('like'),
'2': () => this.react('love'),
'3': () => this.react('laugh'),
'4': () => this.react('hmm'),
'5': () => this.react('surprise'),
'6': () => this.react('congrats'),
'7': () => this.react('angry'),
'8': () => this.react('confused'),
'9': () => this.react('rip'),
'0': () => this.react('pudding'),
};
}
},
watch: {
focus(i) {
this.$refs.buttons.children[i].focus();
if (this.showFocus) {
this.title = this.$refs.buttons.children[i].title;
}
}
},
mounted() {
this.$root.getMeta().then(meta => {
this.enableEmojiReaction = meta.enableEmojiReaction;
});
this.$nextTick(() => {
this.focus = 0;
const popover = this.$refs.popover as any;
const rect = this.source.getBoundingClientRect();
const width = popover.offsetWidth;
const height = popover.offsetHeight;
if (this.$root.isMobile) {
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2);
popover.style.left = (x - (width / 2)) + 'px';
popover.style.top = (y - (height / 2)) + 'px';
} else {
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
const y = rect.top + window.pageYOffset + this.source.offsetHeight;
popover.style.left = (x - (width / 2)) + 'px';
popover.style.top = y + 'px';
}
anime({
targets: this.$refs.backdrop,
opacity: 1,
duration: this.animation ? 100 : 0,
easing: 'linear'
});
anime({
targets: this.$refs.popover,
opacity: 1,
scale: [0.5, 1],
duration: this.animation ? 500 : 0
});
});
},
methods: {
react(reaction) {
this.$emit('chosen', reaction);
},
reactText() {
if (!this.text) return;
this.react(this.text);
},
tryReactText() {
if (!this.text) return;
if (!this.text.match(emojiRegex)) return;
this.reactText();
},
onMouseover(e) {
this.title = e.target.title;
},
onMouseout(e) {
this.title = this.$t('choose-reaction');
},
close() {
(this.$refs.backdrop as any).style.pointerEvents = 'none';
anime({
targets: this.$refs.backdrop,
opacity: 0,
duration: this.animation ? 200 : 0,
easing: 'linear'
});
(this.$refs.popover as any).style.pointerEvents = 'none';
anime({
targets: this.$refs.popover,
opacity: 0,
scale: 0.5,
duration: this.animation ? 200 : 0,
easing: 'easeInBack',
complete: () => {
this.$emit('closed');
this.destroyDom();
}
});
},
focusUp() {
this.focus = this.focus == 0 ? 9 : this.focus < 5 ? (this.focus + 4) : (this.focus - 5);
},
focusDown() {
this.focus = this.focus == 9 ? 0 : this.focus >= 5 ? (this.focus - 4) : (this.focus + 5);
},
focusRight() {
this.focus = this.focus == 9 ? 0 : (this.focus + 1);
},
focusLeft() {
this.focus = this.focus == 0 ? 9 : (this.focus - 1);
},
choose() {
this.$refs.buttons.childNodes[this.focus].click();
}
}
});
</script>
<style lang="stylus" scoped>
.rdfaahpb
position initial
> .backdrop
position fixed
top 0
left 0
z-index 10000
width 100%
height 100%
background var(--modalBackdrop)
opacity 0
> .popover
$bgcolor = var(--popupBg)
position absolute
z-index 10001
background $bgcolor
border-radius 4px
box-shadow 0 3px 12px rgba(27, 31, 35, 0.15)
transform scale(0.5)
opacity 0
&.isMobile
> div
width 280px
> button
width 50px
height 50px
font-size 28px
border-radius 4px
&:not(.isMobile)
$arrow-size = 16px
margin-top $arrow-size
transform-origin center -($arrow-size)
&:before
content ""
display block
position absolute
top -($arrow-size * 2)
left s('calc(50% - %s)', $arrow-size)
border-top solid $arrow-size transparent
border-left solid $arrow-size transparent
border-right solid $arrow-size transparent
border-bottom solid $arrow-size $bgcolor
> p
display block
margin 0
padding 8px 10px
font-size 14px
color var(--popupFg)
border-bottom solid var(--lineWidth) var(--faceDivider)
line-height 20px
> .buttons
padding 4px 4px 8px 4px
width 216px
text-align center
&.showFocus
> button:focus
z-index 1
&:after
content ""
pointer-events none
position absolute
top 0
right 0
bottom 0
left 0
border 2px solid var(--primaryAlpha03)
border-radius 4px
> button
padding 0
width 40px
height 40px
font-size 24px
border-radius 2px
> *
height 1em
&:hover
background var(--reactionPickerButtonHoverBg)
&:active
background var(--primary)
box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15)
> .text
width 216px
padding 0 8px 8px 8px
> input
width 100%
padding 10px
margin 0
text-align center
font-size 16px
color var(--desktopPostFormTextareaFg)
background var(--desktopPostFormTextareaBg)
outline none
border solid 1px var(--primaryAlpha01)
border-radius 4px
transition border-color .2s ease
&:hover
border-color var(--primaryAlpha02)
transition border-color .1s ease
&:focus
border-color var(--primaryAlpha05)
transition border-color 0s ease
</style>

View File

@ -1,122 +0,0 @@
<template>
<transition name="zoom-in-top">
<div class="buebdbiu" ref="popover" v-if="show">
<i18n path="few-users" v-if="users.length <= 10">
<span slot="users">
<b v-for="u in users" :key="u.id" style="margin-right: 12px;">
<mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
<mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/>
</b>
</span>
<mk-reaction-icon slot="reaction" :reaction="reaction" ref="icon" />
</i18n>
<i18n path="many-users" v-if="10 < users.length">
<span slot="users">
<b v-for="u in users" :key="u.id" style="margin-right: 12px;">
<mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
<mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/>
</b>
</span>
<span slot="omitted">{{ count - 10 }}</span>
<mk-reaction-icon slot="reaction" :reaction="reaction" ref="icon" />
</i18n>
</div>
</transition>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n('common/views/components/reactions-viewer.details.vue'),
props: {
reaction: {
type: String,
required: true,
},
users: {
type: Array,
required: true,
},
count: {
type: Number,
required: true,
},
source: {
required: true,
}
},
data() {
return {
show: false
};
},
mounted() {
this.show = true;
this.$nextTick(() => {
const popover = this.$refs.popover as any;
if (this.source == null) {
this.destroyDom();
return;
}
const rect = this.source.getBoundingClientRect();
const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
const y = rect.top + window.pageYOffset + this.source.offsetHeight;
popover.style.left = (x - 28) + 'px';
popover.style.top = (y + 16) + 'px';
});
}
methods: {
close() {
this.show = false;
setTimeout(this.destroyDom, 300);
}
}
})
</script>
<style lang="stylus" scoped>
.buebdbiu
$bgcolor = var(--popupBg)
z-index 10000
display block
position absolute
max-width 240px
font-size 0.8em
padding 6px 8px
background $bgcolor
text-align center
color var(--text)
border-radius 4px
box-shadow 0 var(--lineWidth) 4px rgba(#000, 0.25)
pointer-events none
transform-origin center -16px
&:before
content ""
pointer-events none
display block
position absolute
top -28px
left 12px
border-top solid 14px transparent
border-right solid 14px transparent
border-bottom solid 14px rgba(#000, 0.1)
border-left solid 14px transparent
&:after
content ""
pointer-events none
display block
position absolute
top -27px
left 12px
border-top solid 14px transparent
border-right solid 14px transparent
border-bottom solid 14px $bgcolor
border-left solid 14px transparent
</style>

View File

@ -1,104 +0,0 @@
<template>
<div class="puqkfets" :class="{ mini: narrow }">
<mk-avatar class="avatar" :user="note.user"/>
<fa icon="retweet"/>
<i18n path="@.renoted-by" tag="span">
<router-link class="name" :to="note.user | userPage" v-user-preview="note.userId" place="user">
<mk-user-name :user="note.user"/>
</router-link>
</i18n>
<div class="info">
<span class="mobile" v-if="note.viaMobile"><fa icon="mobile-alt"/></span>
<mk-time :time="note.createdAt"/>
<span class="visibility" v-if="note.visibility != 'public'">
<fa v-if="note.visibility == 'home'" icon="home"/>
<fa v-if="note.visibility == 'followers'" icon="unlock"/>
<fa v-if="note.visibility == 'specified'" icon="envelope"/>
</span>
<span class="localOnly" v-if="note.localOnly == true"><fa icon="heart"/></span>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
export default Vue.extend({
i18n: i18n(),
props: {
note: {
type: Object,
required: true
}
},
inject: {
narrow: {
default: false
}
},
});
</script>
<style lang="stylus" scoped>
.puqkfets
display flex
align-items center
padding 8px 16px
line-height 28px
white-space pre
color var(--renoteText)
background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%)
&:not(.mini)
padding 8px 16px
@media (min-width 500px)
padding 8px 16px
@media (min-width 600px)
padding 16px 32px 8px 32px
> .avatar
flex-shrink 0
display inline-block
width 28px
height 28px
margin 0 8px 0 0
border-radius 6px
> [data-icon]
margin-right 4px
> span
overflow hidden
flex-shrink 1
text-overflow ellipsis
white-space nowrap
> .name
font-weight bold
> .info
margin-left auto
font-size 0.9em
> .mobile
margin-right 8px
> .mk-time
flex-shrink 0
> .visibility
margin-left 8px
[data-icon]
margin-right 0
> .localOnly
margin-left 4px
[data-icon]
margin-right 0
</style>

View File

@ -1,259 +0,0 @@
<template>
<div class="2fa totp-section">
<p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('url')" target="_blank">{{ $t('detail') }}</a></p>
<ui-info warn>{{ $t('caution') }}</ui-info>
<p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p>
<template v-if="$store.state.i.twoFactorEnabled">
<h2 class="heading">{{ $t('totp-header') }}</h2>
<p>{{ $t('already-registered') }}</p>
<ui-button @click="unregister">{{ $t('unregister') }}</ui-button>
<template v-if="supportsCredentials">
<hr class="totp-method-sep">
<h2 class="heading">{{ $t('security-key-header') }}</h2>
<p>{{ $t('security-key') }}</p>
<div class="key-list">
<div class="key" v-for="key in $store.state.i.securityKeysList">
<h3>
{{ key.name }}
</h3>
<div class="last-used">
{{ $t('last-used') }}
<mk-time :time="key.lastUsed"/>
</div>
<ui-button @click="unregisterKey(key)">
{{ $t('unregister') }}
</ui-button>
</div>
</div>
<ui-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">
{{ $t('use-password-less-login') }}
</ui-switch>
<ui-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</ui-info>
<ui-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</ui-button>
<ol v-if="registration && !registration.error">
<li v-if="registration.stage >= 0">
{{ $t('activate-key') }}
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
</li>
<li v-if="registration.stage >= 1">
<ui-form :disabled="registration.stage != 1 || registration.saving">
<ui-input v-model="keyName" :max="30">
<span>{{ $t('security-key-name') }}</span>
</ui-input>
<ui-button @click="registerKey" :disabled="this.keyName.length == 0">
{{ $t('register-security-key') }}
</ui-button>
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
</ui-form>
</li>
</ol>
</template>
</template>
<div v-if="data && !$store.state.i.twoFactorEnabled">
<ol>
<li>{{ $t('authenticator') }}<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank">{{ $t('howtoinstall') }}</a></li>
<li>{{ $t('scan') }}<br><img :src="data.qr"></li>
<li>{{ $t('done') }}<br>
<ui-input v-model="token">{{ $t('token') }}</ui-input>
<ui-button primary @click="submit">{{ $t('submit') }}</ui-button>
</li>
</ol>
<ui-info>{{ $t('info') }}</ui-info>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { hostname } from '../../../../config';
import { hexifyAB } from '../../../scripts/2fa';
function stringifyAB(buffer) {
return String.fromCharCode.apply(null, new Uint8Array(buffer));
}
export default Vue.extend({
i18n: i18n('desktop/views/components/settings.2fa.vue'),
data() {
return {
data: null,
supportsCredentials: !!navigator.credentials,
usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin,
registration: null,
keyName: '',
token: null
};
},
methods: {
register() {
this.$root.dialog({
title: this.$t('enter-password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
this.$root.api('i/2fa/register', {
password: password
}).then(data => {
this.data = data;
});
});
},
unregister() {
this.$root.dialog({
title: this.$t('enter-password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
this.$root.api('i/2fa/unregister', {
password: password
}).then(() => {
this.usePasswordLessLogin = false;
this.updatePasswordLessLogin();
}).then(() => {
this.$notify(this.$t('unregistered'));
this.$store.state.i.twoFactorEnabled = false;
});
});
},
submit() {
this.$root.api('i/2fa/done', {
token: this.token
}).then(() => {
this.$notify(this.$t('success'));
this.$store.state.i.twoFactorEnabled = true;
}).catch(() => {
this.$notify(this.$t('failed'));
});
},
registerKey() {
this.registration.saving = true;
this.$root.api('i/2fa/key-done', {
password: this.registration.password,
name: this.keyName,
challengeId: this.registration.challengeId,
// we convert each 16 bits to a string to serialise
clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON),
attestationObject: hexifyAB(this.registration.credential.response.attestationObject)
}).then(key => {
this.registration = null;
key.lastUsed = new Date();
this.$notify(this.$t('success'));
})
},
unregisterKey(key) {
this.$root.dialog({
title: this.$t('enter-password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
return this.$root.api('i/2fa/remove-key', {
password,
credentialId: key.id
}).then(() => {
this.usePasswordLessLogin = false;
this.updatePasswordLessLogin();
}).then(() => {
this.$notify(this.$t('key-unregistered'));
});
});
},
addSecurityKey() {
this.$root.dialog({
title: this.$t('enter-password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
this.$root.api('i/2fa/register-key', {
password
}).then(registration => {
this.registration = {
password,
challengeId: registration.challengeId,
stage: 0,
publicKeyOptions: {
challenge: Buffer.from(
registration.challenge
.replace(/\-/g, "+")
.replace(/_/g, "/"),
'base64'
),
rp: {
id: hostname,
name: 'Misskey'
},
user: {
id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)),
name: this.$store.state.i.username,
displayName: this.$store.state.i.name,
},
pubKeyCredParams: [{alg: -7, type: 'public-key'}],
timeout: 60000,
attestation: 'direct'
},
saving: true
};
return navigator.credentials.create({
publicKey: this.registration.publicKeyOptions
});
}).then(credential => {
this.registration.credential = credential;
this.registration.saving = false;
this.registration.stage = 1;
}).catch(err => {
console.warn('Error while registering?', err);
this.registration.error = err.message;
this.registration.stage = -1;
});
});
},
updatePasswordLessLogin() {
this.$root.api('i/2fa/password-less', {
value: !!this.usePasswordLessLogin
});
}
}
});
</script>
<style lang="stylus" scoped>
.totp-section
.totp-method-sep
margin 1.5em 0 1em
border none
border-top solid var(--lineWidth) var(--faceDivider)
h2.heading
margin 0
.key
padding 1em
margin 0.5em 0
background #161616
border-radius 6px
h3
margin-top 0
margin-bottom .3em
.last-used
margin-bottom .5em
</style>

View File

@ -1,102 +0,0 @@
<template>
<ui-card>
<template #title><fa icon="key"/> API</template>
<section class="fit-top">
<ui-input :value="$store.state.i.token" readonly>
<span>{{ $t('token') }}</span>
</ui-input>
<p>{{ $t('intro') }}</p>
<ui-info warn>{{ $t('caution') }}</ui-info>
<p>{{ $t('regeneration-of-token') }}</p>
<ui-button @click="regenerateToken"><fa icon="sync-alt"/> {{ $t('regenerate-token') }}</ui-button>
</section>
<section>
<header><fa icon="terminal"/> {{ $t('console.title') }}</header>
<ui-input v-model="endpoint" :datalist="endpoints" @change="onEndpointChange()">
<span>{{ $t('console.endpoint') }}</span>
</ui-input>
<ui-textarea v-model="body">
<span>{{ $t('console.parameter') }} (JSON or JSON5)</span>
<template #desc>{{ $t('console.credential-info') }}</template>
</ui-textarea>
<ui-button @click="send" :disabled="sending">
<template v-if="sending">{{ $t('console.sending') }}</template>
<template v-else><fa icon="paper-plane"/> {{ $t('console.send') }}</template>
</ui-button>
<ui-textarea v-if="res" v-model="res" readonly tall>
<span>{{ $t('console.response') }}</span>
</ui-textarea>
</section>
</ui-card>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import * as JSON5 from 'json5';
export default Vue.extend({
i18n: i18n('common/views/components/api-settings.vue'),
data() {
return {
endpoint: '',
body: '{}',
res: null,
sending: false,
endpoints: []
};
},
created() {
this.$root.api('endpoints').then(endpoints => {
this.endpoints = endpoints;
});
},
methods: {
regenerateToken() {
this.$root.dialog({
title: this.$t('enter-password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
this.$root.api('i/regenerate_token', {
password: password
});
});
},
send() {
this.sending = true;
this.$root.api(this.endpoint, JSON5.parse(this.body)).then(res => {
this.sending = false;
this.res = JSON5.stringify(res, null, 2);
}, err => {
this.sending = false;
this.res = JSON5.stringify(err, null, 2);
});
},
onEndpointChange() {
this.$root.api('endpoint', { endpoint: this.endpoint }).then(endpoint => {
const body = {};
for (const p of endpoint.params) {
body[p.name] =
p.type === 'String' ? '' :
p.type === 'Number' ? 0 :
p.type === 'Boolean' ? false :
p.type === 'Array' ? [] :
p.type === 'Object' ? {} :
null;
}
this.body = JSON5.stringify(body, null, 2);
});
}
}
});
</script>

View File

@ -1,53 +0,0 @@
<template>
<ui-card>
<template #title><fa :icon="faMobileAlt"/> {{ $t('title') }}</template>
<section class="fit-top">
<p>{{ $t('intro') }}</p>
<ui-select v-model="appTypeForce" :placeholder="$t('intro')">
<option v-for="x in ['auto', 'desktop', 'mobile']" :value="x" :key="x">{{ $t(`choices.${x}`) }}</option>
</ui-select>
<ui-info warn>{{ $t('info') }}</ui-info>
</section>
</ui-card>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { faMobileAlt } from '@fortawesome/free-solid-svg-icons'
export default Vue.extend({
i18n: i18n('common/views/components/settings/app-type.vue'),
data() {
return {
faMobileAlt
};
},
computed: {
appTypeForce: {
get() { return this.$store.state.device.appTypeForce; },
set(value) {
this.$store.commit('device/set', { key: 'appTypeForce', value });
this.reload();
}
},
},
methods: {
reload() {
this.$root.dialog({
type: 'warning',
text: this.$t('@.reload-to-apply-the-setting'),
showCancelButton: true
}).then(({ canceled }) => {
if (!canceled) {
location.reload();
}
});
},
}
});
</script>

View File

@ -1,39 +0,0 @@
<template>
<div class="root">
<ui-info v-if="!fetching && apps.length == 0">{{ $t('no-apps') }}</ui-info>
<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';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('desktop/views/components/settings.apps.vue'),
data() {
return {
fetching: true,
apps: []
};
},
mounted() {
this.$root.api('i/authorized_apps').then(apps => {
this.apps = apps;
this.fetching = false;
});
}
});
</script>
<style lang="stylus" scoped>
.root
> .apps
> div
padding 16px 0 0 0
border-bottom solid 1px #eee
</style>

View File

@ -1,209 +0,0 @@
<template>
<ui-card>
<template #title><fa icon="cloud"/> {{ $t('@.drive') }}</template>
<section v-if="!fetching" class="juakhbxthdewydyreaphkepoxgxvfogn">
<div class="meter"><div :style="meterStyle"></div></div>
<p>{{ $t('max') }}: <b>{{ capacity | bytes }}</b> {{ $t('in-use') }}: <b>{{ usage | bytes }}</b></p>
</section>
<section>
<header>{{ $t('stats') }}</header>
<div ref="chart" style="margin-bottom: -16px; margin-left: -8px; color: #000;"></div>
</section>
<section>
<header>{{ $t('default-upload-folder') }}</header>
<ui-input v-model="uploadFolderName" readonly>{{ $t('default-upload-folder-name') }}</ui-input>
<ui-button @click="chooseUploadFolder()">{{ $t('change-default-upload-folder') }}</ui-button>
</section>
</ui-card>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import * as tinycolor from 'tinycolor2';
import ApexCharts from 'apexcharts';
export default Vue.extend({
i18n: i18n('common/views/components/drive-settings.vue'),
data() {
return {
fetching: true,
usage: null,
capacity: null,
uploadFolderName: null
};
},
computed: {
meterStyle(): any {
return {
width: `${this.usage / this.capacity * 100}%`,
background: tinycolor({
h: 180 - (this.usage / this.capacity * 180),
s: 0.7,
l: 0.5
})
};
},
uploadFolder: {
get() { return this.$store.state.settings.uploadFolder; },
set(value) { this.$store.dispatch('settings/set', { key: 'uploadFolder', value }); }
},
},
mounted() {
if (this.uploadFolder == null) {
this.uploadFolderName = this.$t('@._settings.root');
} else {
this.$root.api('drive/folders/show', {
folderId: this.uploadFolder
}).then(folder => {
this.uploadFolderName = folder.name;
});
}
this.$root.api('drive').then(info => {
this.capacity = info.capacity;
this.usage = info.usage;
this.fetching = false;
this.$nextTick(() => {
this.renderChart();
});
});
},
methods: {
renderChart() {
this.$root.api('charts/user/drive', {
userId: this.$store.state.i.id,
span: 'day',
limit: 21
}).then(stats => {
const addition = [];
const deletion = [];
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
for (let i = 0; i < 21; i++) {
const x = new Date(y, m, d - i);
addition.push([
x,
stats.incSize[i]
]);
deletion.push([
x,
-stats.decSize[i]
]);
}
const chart = new ApexCharts(this.$refs.chart, {
chart: {
type: 'bar',
stacked: true,
height: 150,
zoom: {
enabled: false
},
toolbar: {
show: false
}
},
plotOptions: {
bar: {
columnWidth: '80%'
}
},
grid: {
clipMarkers: false,
borderColor: 'rgba(0, 0, 0, 0.1)',
xaxis: {
lines: {
show: true,
}
},
},
tooltip: {
shared: true,
intersect: false
},
dataLabels: {
enabled: false
},
legend: {
show: false
},
series: [{
name: 'Additions',
data: addition
}, {
name: 'Deletions',
data: deletion
}],
xaxis: {
type: 'datetime',
labels: {
style: {
colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString()
}
},
axisBorder: {
color: 'rgba(0, 0, 0, 0.1)'
},
axisTicks: {
color: 'rgba(0, 0, 0, 0.1)'
},
crosshairs: {
width: 1,
opacity: 1
}
},
yaxis: {
labels: {
formatter: v => Vue.filter('bytes')(v, 0),
style: {
color: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString()
}
}
}
});
chart.render();
});
},
chooseUploadFolder() {
this.$chooseDriveFolder().then(folder => {
this.uploadFolder = folder ? folder.id : null;
this.uploadFolderName = folder ? folder.name : this.$t('@._settings.root');
})
}
}
});
</script>
<style lang="stylus" scoped>
.juakhbxthdewydyreaphkepoxgxvfogn
> .meter
$size = 12px
margin-bottom 16px
background rgba(0, 0, 0, 0.1)
border-radius ($size / 2)
overflow hidden
> div
height $size
border-radius ($size / 2)
> p
margin 0
</style>

View File

@ -1,118 +0,0 @@
<template>
<ui-card v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration">
<template #title><fa icon="share-alt"/> {{ $t('title') }}</template>
<section v-if="enableTwitterIntegration">
<header><fa :icon="['fab', 'twitter']"/> Twitter</header>
<p v-if="$store.state.i.twitter">{{ $t('connected-to') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p>
<ui-button v-if="$store.state.i.twitter" @click="disconnectTwitter">{{ $t('disconnect') }}</ui-button>
<ui-button v-else @click="connectTwitter">{{ $t('connect') }}</ui-button>
</section>
<section v-if="enableDiscordIntegration">
<header><fa :icon="['fab', 'discord']"/> Discord</header>
<p v-if="$store.state.i.discord">{{ $t('connected-to') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p>
<ui-button v-if="$store.state.i.discord" @click="disconnectDiscord">{{ $t('disconnect') }}</ui-button>
<ui-button v-else @click="connectDiscord">{{ $t('connect') }}</ui-button>
</section>
<section v-if="enableGithubIntegration">
<header><fa :icon="['fab', 'github']"/> GitHub</header>
<p v-if="$store.state.i.github">{{ $t('connected-to') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.github.login }}</a></p>
<ui-button v-if="$store.state.i.github" @click="disconnectGithub">{{ $t('disconnect') }}</ui-button>
<ui-button v-else @click="connectGithub">{{ $t('connect') }}</ui-button>
</section>
</ui-card>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { apiUrl } from '../../../../config';
export default Vue.extend({
i18n: i18n('common/views/components/integration-settings.vue'),
data() {
return {
apiUrl,
twitterForm: null,
discordForm: null,
githubForm: null,
enableTwitterIntegration: false,
enableDiscordIntegration: false,
enableGithubIntegration: false,
};
},
created() {
this.$root.getMeta().then(meta => {
this.enableTwitterIntegration = meta.enableTwitterIntegration;
this.enableDiscordIntegration = meta.enableDiscordIntegration;
this.enableGithubIntegration = meta.enableGithubIntegration;
});
},
mounted() {
if (!document.cookie.match(/i=(\w+)/)) {
document.cookie = `i=${this.$store.state.i.token}; path=/;` +
` domain=${document.location.hostname}; max-age=31536000;` +
(document.location.protocol.startsWith('https') ? ' secure' : '');
}
this.$watch('$store.state.i', () => {
if (this.$store.state.i.twitter) {
if (this.twitterForm) this.twitterForm.close();
}
if (this.$store.state.i.discord) {
if (this.discordForm) this.discordForm.close();
}
if (this.$store.state.i.github) {
if (this.githubForm) this.githubForm.close();
}
}, {
deep: true
});
},
methods: {
connectTwitter() {
this.twitterForm = window.open(apiUrl + '/connect/twitter',
'twitter_connect_window',
'height=570, width=520');
},
disconnectTwitter() {
window.open(apiUrl + '/disconnect/twitter',
'twitter_disconnect_window',
'height=570, width=520');
},
connectDiscord() {
this.discordForm = window.open(apiUrl + '/connect/discord',
'discord_connect_window',
'height=570, width=520');
},
disconnectDiscord() {
window.open(apiUrl + '/disconnect/discord',
'discord_disconnect_window',
'height=570, width=520');
},
connectGithub() {
this.githubForm = window.open(apiUrl + '/connect/github',
'github_connect_window',
'height=570, width=520');
},
disconnectGithub() {
window.open(apiUrl + '/disconnect/github',
'github_disconnect_window',
'height=570, width=520');
},
}
});
</script>
<style lang="stylus" scoped>
</style>

View File

@ -1,54 +0,0 @@
<template>
<ui-card>
<template #title><fa icon="language"/> {{ $t('title') }}</template>
<section class="fit-top">
<ui-select v-model="lang" :placeholder="$t('pick-language')">
<optgroup :label="$t('recommended')">
<option value="">{{ $t('auto') }}</option>
</optgroup>
<optgroup :label="$t('specify-language')">
<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
</optgroup>
</ui-select>
<ui-info>Current: <i>{{ currentLanguage }}</i></ui-info>
<ui-info warn>{{ $t('info') }}</ui-info>
</section>
</ui-card>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { langs } from '../../../../config';
export default Vue.extend({
i18n: i18n('common/views/components/language-settings.vue'),
data() {
return {
langs,
currentLanguage: 'Unknown',
};
},
computed: {
lang: {
get() { return this.$store.state.device.lang; },
set(value) { this.$store.commit('device/set', { key: 'lang', value }); }
},
},
created() {
try {
const locale = JSON.parse(localStorage.getItem('locale') || "{}");
const localeKey = localStorage.getItem('localeKey');
this.currentLanguage = `${locale.meta.lang} (${localeKey})`;
} catch { }
},
methods: {
}
});
</script>

View File

@ -1,39 +0,0 @@
<template>
<div class="muteblockuser">
<div class="avatar-link">
<a :href="user | userPage(null, true)">
<mk-avatar class="avatar" :user="user" :disable-link="true"/>
</a>
</div>
<div class="text">
<div><mk-user-name :user="user"/></div>
<div class="username">@{{ user | acct }}</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('common/views/components/mute-and-block.user.vue'),
props: ['user'],
});
</script>
<style lang="stylus" scoped>
.muteblockuser
display flex
padding 16px
> .avatar-link
> a
> .avatar
width 40px
height 40px
> .text
color var(--text)
margin-left 16px
</style>

View File

@ -1,181 +0,0 @@
<template>
<ui-card>
<template #title><fa icon="ban"/> {{ $t('mute-and-block') }}</template>
<section>
<header>{{ $t('mute') }}</header>
<ui-info v-if="!muteFetching && mute.length == 0">{{ $t('no-muted-users') }}</ui-info>
<div class="users" v-if="mute.length != 0">
<div class="user" v-for="user in mute" :key="user.id">
<x-user :user="user"/>
<span @click="unmute(user)">
<fa icon="times"/>
</span>
</div>
<ui-button v-if="this.muteCursor != null" @click="updateMute()">{{ $t('@.load-more') }}</ui-button>
</div>
</section>
<section>
<header>{{ $t('block') }}</header>
<ui-info v-if="!blockFetching && block.length == 0">{{ $t('no-blocked-users') }}</ui-info>
<div class="users" v-if="block.length != 0">
<div class="user" v-for="user in block" :key="user.id">
<x-user :user="user"/>
<span @click="unblock(user)">
<fa icon="times"/>
</span>
</div>
<ui-button v-if="this.blockCursor != null" @click="updateBlock()">{{ $t('@.load-more') }}</ui-button>
</div>
</section>
<section>
<header>{{ $t('word-mute') }}</header>
<ui-textarea v-model="mutedWords">
{{ $t('muted-words') }}<template #desc>{{ $t('muted-words-description') }}</template>
</ui-textarea>
<ui-button @click="save">{{ $t('save') }}</ui-button>
</section>
</ui-card>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import XUser from './mute-and-block.user.vue';
const fetchLimit = 30;
export default Vue.extend({
i18n: i18n('common/views/components/mute-and-block.vue'),
components: {
XUser
},
data() {
return {
muteFetching: true,
blockFetching: true,
mute: [],
block: [],
muteCursor: undefined,
blockCursor: undefined,
mutedWords: ''
};
},
computed: {
_mutedWords: {
get() { return this.$store.state.settings.mutedWords; },
set(value) { this.$store.dispatch('settings/set', { key: 'mutedWords', value }); }
},
},
mounted() {
this.mutedWords = this._mutedWords.map(words => words.join(' ')).join('\n');
this.updateMute();
this.updateBlock();
},
methods: {
save() {
this._mutedWords = this.mutedWords.split('\n').map(line => line.split(' ').filter(x => x != ''));
},
unmute(user) {
this.$root.dialog({
type: 'warning',
text: this.$t('unmute-confirm'),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.$root.api('mute/delete', {
userId: user.id
}).then(() => {
this.muteCursor = undefined;
this.updateMute();
});
});
},
unblock(user) {
this.$root.dialog({
type: 'warning',
text: this.$t('unblock-confirm'),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.$root.api('blocking/delete', {
userId: user.id
}).then(() => {
this.updateBlock();
});
});
},
updateMute() {
this.muteFetching = true;
this.$root.api('mute/list', {
limit: fetchLimit + 1,
untilId: this.muteCursor,
}).then((items: Object[]) => {
const past = this.muteCursor ? this.mute : [];
if (items.length === fetchLimit + 1) {
items.pop()
this.muteCursor = items[items.length - 1].id;
} else {
this.muteCursor = undefined;
}
this.mute = past.concat(items.map(x => x.mutee));
this.muteFetching = false;
});
},
updateBlock() {
this.blockFetching = true;
this.$root.api('blocking/list', {
limit: fetchLimit + 1,
untilId: this.blockCursor,
}).then((items: Object[]) => {
const past = this.blockCursor ? this.block : [];
if (items.length === fetchLimit + 1) {
items.pop()
this.blockCursor = items[items.length - 1].id;
} else {
this.blockCursor = undefined;
}
this.block = past.concat(items.map(x => x.blockee));
this.blockFetching = false;
});
},
}
});
</script>
<style lang="stylus" scoped>
.users
> .user
display flex
align-items center
justify-content flex-end
border-radius 6px
&:hover
background-color var(--primary)
> span
margin-left auto
cursor pointer
padding 16px
> button
margin-top 16px
</style>

View File

@ -1,44 +0,0 @@
<template>
<ui-card>
<template #title><fa :icon="['far', 'bell']"/> {{ $t('title') }}</template>
<section>
<ui-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
{{ $t('auto-watch') }}<template #desc>{{ $t('auto-watch-desc') }}</template>
</ui-switch>
<section>
<ui-button @click="readAllNotifications">{{ $t('mark-as-read-all-notifications') }}</ui-button>
<ui-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</ui-button>
<ui-button @click="readAllMessagingMessages">{{ $t('mark-as-read-all-talk-messages') }}</ui-button>
</section>
</section>
</ui-card>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('common/views/components/notification-settings.vue'),
methods: {
onChangeAutoWatch(v) {
this.$root.api('i/update', {
autoWatch: v
});
},
readAllUnreadNotes() {
this.$root.api('i/read_all_unread_notes');
},
readAllMessagingMessages() {
this.$root.api('i/read_all_messaging_messages');
},
readAllNotifications() {
this.$root.api('notifications/mark_all_as_read');
}
}
});
</script>

View File

@ -1,63 +0,0 @@
<template>
<div>
<ui-button @click="reset">{{ $t('reset') }}</ui-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('common/views/components/password-settings.vue'),
methods: {
async reset() {
const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({
title: this.$t('enter-current-password'),
input: {
type: 'password'
}
});
if (canceled1) return;
const { canceled: canceled2, result: newPassword } = await this.$root.dialog({
title: this.$t('enter-new-password'),
input: {
type: 'password'
}
});
if (canceled2) return;
const { canceled: canceled3, result: newPassword2 } = await this.$root.dialog({
title: this.$t('enter-new-password-again'),
input: {
type: 'password'
}
});
if (canceled3) return;
if (newPassword !== newPassword2) {
this.$root.dialog({
title: null,
text: this.$t('not-match')
});
return;
}
this.$root.api('i/change_password', {
currentPassword,
newPassword
}).then(() => {
this.$root.dialog({
type: 'success',
text: this.$t('changed')
});
}).catch(() => {
this.$root.dialog({
type: 'error',
text: this.$t('failed')
});
});
}
}
});
</script>

View File

@ -1,442 +0,0 @@
<template>
<ui-card>
<template #title><fa icon="user"/> {{ $t('title') }}</template>
<section class="esokaraujimuwfttfzgocmutcihewscl">
<div class="header" :style="bannerStyle">
<mk-avatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true"/>
</div>
<ui-form :disabled="saving">
<ui-input v-model="name" :max="30">
<span>{{ $t('name') }}</span>
</ui-input>
<ui-input v-model="username" readonly>
<span>{{ $t('account') }}</span>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</ui-input>
<ui-input v-model="location">
<span>{{ $t('location') }}</span>
<template #prefix><fa icon="map-marker-alt"/></template>
</ui-input>
<ui-input v-model="birthday" type="date">
<template #title>{{ $t('birthday') }}</template>
<template #prefix><fa icon="birthday-cake"/></template>
</ui-input>
<ui-textarea v-model="description" :max="500">
<span>{{ $t('description') }}</span>
<template #desc>{{ $t('you-can-include-hashtags') }}</template>
</ui-textarea>
<ui-select v-model="lang">
<template #label>{{ $t('language') }}</template>
<template #icon><fa icon="language"/></template>
<option v-for="lang in unique(Object.values(langmap).map(x => x.nativeName)).map(name => Object.keys(langmap).find(k => langmap[k].nativeName == name))" :value="lang" :key="lang">{{ langmap[lang].nativeName }}</option>
</ui-select>
<ui-input type="file" @change="onAvatarChange">
<span>{{ $t('avatar') }}</span>
<template #icon><fa icon="image"/></template>
<template #desc v-if="avatarUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
</ui-input>
<ui-input type="file" @change="onBannerChange">
<span>{{ $t('banner') }}</span>
<template #icon><fa icon="image"/></template>
<template #desc v-if="bannerUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
</ui-input>
<div class="fields">
<header>{{ $t('profile-metadata') }}</header>
<ui-horizon-group>
<ui-input v-model="fieldName0">{{ $t('metadata-label') }}</ui-input>
<ui-input v-model="fieldValue0">{{ $t('metadata-content') }}</ui-input>
</ui-horizon-group>
<ui-horizon-group>
<ui-input v-model="fieldName1">{{ $t('metadata-label') }}</ui-input>
<ui-input v-model="fieldValue1">{{ $t('metadata-content') }}</ui-input>
</ui-horizon-group>
<ui-horizon-group>
<ui-input v-model="fieldName2">{{ $t('metadata-label') }}</ui-input>
<ui-input v-model="fieldValue2">{{ $t('metadata-content') }}</ui-input>
</ui-horizon-group>
<ui-horizon-group>
<ui-input v-model="fieldName3">{{ $t('metadata-label') }}</ui-input>
<ui-input v-model="fieldValue3">{{ $t('metadata-content') }}</ui-input>
</ui-horizon-group>
</div>
<ui-button @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</ui-form>
</section>
<section>
<header><fa :icon="faCogs"/> {{ $t('advanced') }}</header>
<div>
<ui-switch v-model="isCat" @change="save(false)">{{ $t('is-cat') }}</ui-switch>
<ui-switch v-model="isBot" @change="save(false)">{{ $t('is-bot') }}</ui-switch>
<ui-switch v-model="alwaysMarkNsfw">{{ $t('@._settings.always-mark-nsfw') }}</ui-switch>
</div>
</section>
<section>
<header><fa :icon="faUnlockAlt"/> {{ $t('privacy') }}</header>
<div>
<ui-switch v-model="isLocked" @change="save(false)">{{ $t('is-locked') }}</ui-switch>
<ui-switch v-model="carefulBot" :disabled="isLocked" @change="save(false)">{{ $t('careful-bot') }}</ui-switch>
<ui-switch v-model="autoAcceptFollowed" :disabled="!isLocked && !carefulBot" @change="save(false)">{{ $t('auto-accept-followed') }}</ui-switch>
</div>
</section>
<section v-if="enableEmail">
<header><fa :icon="faEnvelope"/> {{ $t('email') }}</header>
<div>
<template v-if="$store.state.i.email != null">
<ui-info v-if="$store.state.i.emailVerified">{{ $t('email-verified') }}</ui-info>
<ui-info v-else warn>{{ $t('email-not-verified') }}</ui-info>
</template>
<ui-input v-model="email" type="email"><span>{{ $t('email-address') }}</span></ui-input>
<ui-button @click="updateEmail()" :disabled="email === $store.state.i.email"><fa :icon="faSave"/> {{ $t('save') }}</ui-button>
</div>
</section>
<section>
<header><fa :icon="faBoxes"/> {{ $t('export-and-import') }}</header>
<div>
<ui-select v-model="exportTarget">
<option value="notes">{{ $t('export-targets.all-notes') }}</option>
<option value="following">{{ $t('export-targets.following-list') }}</option>
<option value="mute">{{ $t('export-targets.mute-list') }}</option>
<option value="blocking">{{ $t('export-targets.blocking-list') }}</option>
<option value="user-lists">{{ $t('export-targets.user-lists') }}</option>
</ui-select>
<ui-horizon-group class="fit-bottom">
<ui-button @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</ui-button>
<ui-button @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><fa :icon="faUpload"/> {{ $t('import') }}</ui-button>
</ui-horizon-group>
</div>
</section>
<section>
<details>
<summary>{{ $t('danger-zone') }}</summary>
<ui-button @click="deleteAccount()">{{ $t('delete-account') }}</ui-button>
</details>
</section>
</ui-card>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { apiUrl, host } from '../../../../config';
import { toUnicode } from 'punycode';
import langmap from 'langmap';
import { unique } from '../../../../../../prelude/array';
import { faDownload, faUpload, faUnlockAlt, faBoxes, faCogs } from '@fortawesome/free-solid-svg-icons';
import { faSave, faEnvelope } from '@fortawesome/free-regular-svg-icons';
export default Vue.extend({
i18n: i18n('common/views/components/profile-editor.vue'),
data() {
return {
unique,
langmap,
host: toUnicode(host),
enableEmail: false,
email: null,
name: null,
username: null,
location: null,
description: null,
fieldName0: null,
fieldValue0: null,
fieldName1: null,
fieldValue1: null,
fieldName2: null,
fieldValue2: null,
fieldName3: null,
fieldValue3: null,
lang: null,
birthday: null,
avatarId: null,
bannerId: null,
isCat: false,
isBot: false,
isLocked: false,
carefulBot: false,
autoAcceptFollowed: false,
saving: false,
avatarUploading: false,
bannerUploading: false,
exportTarget: 'notes',
faDownload, faUpload, faSave, faEnvelope, faUnlockAlt, faBoxes, faCogs
};
},
computed: {
alwaysMarkNsfw: {
get() { return this.$store.state.i.alwaysMarkNsfw; },
set(value) { this.$root.api('i/update', { alwaysMarkNsfw: value }); }
},
bannerStyle(): any {
if (this.$store.state.i.bannerUrl == null) return {};
return {
backgroundColor: this.$store.state.i.bannerColor,
backgroundImage: `url(${ this.$store.state.i.bannerUrl })`
};
},
},
created() {
this.$root.getMeta().then(meta => {
this.enableEmail = meta.enableEmail;
});
this.email = this.$store.state.i.email;
this.name = this.$store.state.i.name;
this.username = this.$store.state.i.username;
this.location = this.$store.state.i.location;
this.description = this.$store.state.i.description;
this.lang = this.$store.state.i.lang;
this.birthday = this.$store.state.i.birthday;
this.avatarId = this.$store.state.i.avatarId;
this.bannerId = this.$store.state.i.bannerId;
this.isCat = this.$store.state.i.isCat;
this.isBot = this.$store.state.i.isBot;
this.isLocked = this.$store.state.i.isLocked;
this.carefulBot = this.$store.state.i.carefulBot;
this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed;
this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null;
this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null;
this.fieldName1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].name : null;
this.fieldValue1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].value : null;
this.fieldName2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].name : null;
this.fieldValue2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].value : null;
this.fieldName3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].name : null;
this.fieldValue3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].value : null;
},
methods: {
onAvatarChange([file]) {
this.avatarUploading = true;
const data = new FormData();
data.append('file', file);
data.append('i', this.$store.state.i.token);
fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: data
})
.then(response => response.json())
.then(f => {
this.avatarId = f.id;
this.avatarUploading = false;
})
.catch(e => {
this.avatarUploading = false;
alert('%18n:@upload-failed%');
});
},
onBannerChange([file]) {
this.bannerUploading = true;
const data = new FormData();
data.append('file', file);
data.append('i', this.$store.state.i.token);
fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: data
})
.then(response => response.json())
.then(f => {
this.bannerId = f.id;
this.bannerUploading = false;
})
.catch(e => {
this.bannerUploading = false;
alert('%18n:@upload-failed%');
});
},
save(notify) {
const fields = [
{ name: this.fieldName0, value: this.fieldValue0 },
{ name: this.fieldName1, value: this.fieldValue1 },
{ name: this.fieldName2, value: this.fieldValue2 },
{ name: this.fieldName3, value: this.fieldValue3 },
];
this.saving = true;
this.$root.api('i/update', {
name: this.name || null,
location: this.location || null,
description: this.description || null,
lang: this.lang,
birthday: this.birthday || null,
avatarId: this.avatarId || undefined,
bannerId: this.bannerId || undefined,
fields,
isCat: !!this.isCat,
isBot: !!this.isBot,
isLocked: !!this.isLocked,
carefulBot: !!this.carefulBot,
autoAcceptFollowed: !!this.autoAcceptFollowed
}).then(i => {
this.saving = false;
this.$store.state.i.avatarId = i.avatarId;
this.$store.state.i.avatarUrl = i.avatarUrl;
this.$store.state.i.bannerId = i.bannerId;
this.$store.state.i.bannerUrl = i.bannerUrl;
if (notify) {
this.$root.dialog({
type: 'success',
text: this.$t('saved')
});
}
}).catch(err => {
this.saving = false;
switch(err.id) {
case 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191':
this.$root.dialog({
type: 'error',
title: this.$t('unable-to-process'),
text: this.$t('avatar-not-an-image')
});
break;
case '75aedb19-2afd-4e6d-87fc-67941256fa60':
this.$root.dialog({
type: 'error',
title: this.$t('unable-to-process'),
text: this.$t('banner-not-an-image')
});
break;
default:
this.$root.dialog({
type: 'error',
text: this.$t('unable-to-process')
});
}
});
},
updateEmail() {
this.$root.dialog({
title: this.$t('@.enter-password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
this.$root.api('i/update_email', {
password: password,
email: this.email == '' ? null : this.email
});
});
},
doExport() {
this.$root.api(
this.exportTarget == 'notes' ? 'i/export-notes' :
this.exportTarget == 'following' ? 'i/export-following' :
this.exportTarget == 'mute' ? 'i/export-mute' :
this.exportTarget == 'blocking' ? 'i/export-blocking' :
this.exportTarget == 'user-lists' ? 'i/export-user-lists' :
null, {}).then(() => {
this.$root.dialog({
type: 'info',
text: this.$t('export-requested')
});
}).catch((e: any) => {
this.$root.dialog({
type: 'error',
text: e.message
});
});
},
doImport() {
this.$chooseDriveFile().then(file => {
this.$root.api(
this.exportTarget == 'following' ? 'i/import-following' :
this.exportTarget == 'user-lists' ? 'i/import-user-lists' :
null, {
fileId: file.id
}).then(() => {
this.$root.dialog({
type: 'info',
text: this.$t('import-requested')
});
}).catch((e: any) => {
this.$root.dialog({
type: 'error',
text: e.message
});
});
});
},
async deleteAccount() {
const { canceled: canceled, result: password } = await this.$root.dialog({
title: this.$t('enter-password'),
input: {
type: 'password'
}
});
if (canceled) return;
this.$root.api('i/delete-account', {
password
}).then(() => {
this.$root.dialog({
type: 'success',
text: this.$t('account-deleted')
});
});
}
}
});
</script>
<style lang="stylus" scoped>
.esokaraujimuwfttfzgocmutcihewscl
> .header
height 150px
overflow hidden
background-size cover
background-position center
border-radius 4px
> .avatar
position absolute
top 0
bottom 0
left 0
right 0
display block
width 72px
height 72px
margin auto
.fields
> header
padding 8px 0px
font-weight bold
</style>

Some files were not shown because too many files have changed in this diff Show More