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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
871 changed files with 26140 additions and 71950 deletions

View file

@ -0,0 +1,29 @@
<template>
<span class="mk-acct" v-once>
<span class="name">@{{ user.username }}</span>
<span class="host" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import { toUnicode } from 'punycode';
import { host } from '../config';
export default Vue.extend({
props: ['user', 'detail'],
data() {
return {
host: toUnicode(host),
};
}
});
</script>
<style lang="scss" scoped>
.mk-acct {
> .host {
opacity: 0.5;
}
}
</style>

View file

@ -0,0 +1,443 @@
<template>
<div class="mk-autocomplete" @contextmenu.prevent="() => {}">
<ol class="users" ref="suggests" v-if="users.length > 0">
<li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1">
<img class="avatar" :src="user.avatarUrl" alt=""/>
<span class="name">
<mk-user-name :user="user" :key="user.id"/>
</span>
<span class="username">@{{ user | acct }}</span>
</li>
</ol>
<ol class="hashtags" ref="suggests" v-if="hashtags.length > 0">
<li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1">
<span class="name">{{ hashtag }}</span>
</li>
</ol>
<ol class="emojis" ref="suggests" v-if="emojis.length > 0">
<li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1">
<span class="emoji" v-if="emoji.isCustomEmoji"><img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
<span class="emoji" v-else-if="!useOsDefaultEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span>
<span class="emoji" v-else>{{ emoji.emoji }}</span>
<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
<span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span>
</li>
</ol>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { emojilist } from '../../misc/emojilist';
import contains from '../scripts/contains';
import { twemojiSvgBase } from '../../misc/twemoji-base';
import { getStaticImageUrl } from '../scripts/get-static-image-url';
type EmojiDef = {
emoji: string;
name: string;
aliasOf?: string;
url?: string;
isCustomEmoji?: boolean;
};
const lib = emojilist.filter(x => x.category !== 'flags');
const char2file = (char: string) => {
let codes = Array.from(char).map(x => x.codePointAt(0).toString(16));
if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f');
codes = codes.filter(x => x && x.length);
return codes.join('-');
};
const emjdb: EmojiDef[] = lib.map(x => ({
emoji: x.char,
name: x.name,
aliasOf: null,
url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
}));
for (const x of lib) {
if (x.keywords) {
for (const k of x.keywords) {
emjdb.push({
emoji: x.char,
name: k,
aliasOf: x.name,
url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
});
}
}
}
emjdb.sort((a, b) => a.name.length - b.name.length);
export default Vue.extend({
props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'],
data() {
return {
getStaticImageUrl,
fetching: true,
users: [],
hashtags: [],
emojis: [],
select: -1,
emojilist,
emojiDb: [] as EmojiDef[]
}
},
computed: {
items(): HTMLCollection {
return (this.$refs.suggests as Element).children;
},
useOsDefaultEmojis(): boolean {
return this.$store.state.device.useOsDefaultEmojis;
}
},
updated() {
//#region 調
if (this.x + this.$el.offsetWidth > window.innerWidth) {
this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px';
} else {
this.$el.style.left = this.x + 'px';
}
if (this.y + this.$el.offsetHeight > window.innerHeight) {
this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px';
this.$el.style.marginTop = '0';
} else {
this.$el.style.top = this.y + 'px';
this.$el.style.marginTop = 'calc(1em + 8px)';
}
//#endregion
},
mounted() {
//#region Construct Emoji DB
const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
const emojiDefinitions: EmojiDef[] = [];
for (const x of customEmojis) {
emojiDefinitions.push({
name: x.name,
emoji: `:${x.name}:`,
url: x.url,
isCustomEmoji: true
});
if (x.aliases) {
for (const alias of x.aliases) {
emojiDefinitions.push({
name: alias,
aliasOf: x.name,
emoji: `:${x.name}:`,
url: x.url,
isCustomEmoji: true
});
}
}
}
emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
this.emojiDb = emojiDefinitions.concat(emjdb);
//#endregion
this.textarea.addEventListener('keydown', this.onKeydown);
for (const el of Array.from(document.querySelectorAll('*'))) {
el.addEventListener('mousedown', this.onMousedown);
}
this.$nextTick(() => {
this.exec();
this.$watch('q', () => {
this.$nextTick(() => {
this.exec();
});
});
});
},
beforeDestroy() {
this.textarea.removeEventListener('keydown', this.onKeydown);
for (const el of Array.from(document.querySelectorAll('*'))) {
el.removeEventListener('mousedown', this.onMousedown);
}
},
methods: {
exec() {
this.select = -1;
if (this.$refs.suggests) {
for (const el of Array.from(this.items)) {
el.removeAttribute('data-selected');
}
}
if (this.type == 'user') {
const cacheKey = `autocomplete:user:${this.q}`;
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const users = JSON.parse(cache);
this.users = users;
this.fetching = false;
} else {
this.$root.api('users/search', {
query: this.q,
limit: 10,
detail: false
}).then(users => {
this.users = users;
this.fetching = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(users));
});
}
} else if (this.type == 'hashtag') {
if (this.q == null || this.q == '') {
this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
this.fetching = false;
} else {
const cacheKey = `autocomplete:hashtag:${this.q}`;
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const hashtags = JSON.parse(cache);
this.hashtags = hashtags;
this.fetching = false;
} else {
this.$root.api('hashtags/search', {
query: this.q,
limit: 30
}).then(hashtags => {
this.hashtags = hashtags;
this.fetching = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
});
}
}
} else if (this.type == 'emoji') {
if (this.q == null || this.q == '') {
this.emojis = this.emojiDb.filter(x => x.isCustomEmoji && !x.aliasOf).sort((a, b) => {
var textA = a.name.toUpperCase();
var textB = b.name.toUpperCase();
return (textA < textB) ? -1 : (textA > textB) ? 1 : 0;
});
return;
}
const matched = [];
const max = 30;
this.emojiDb.some(x => {
if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
return matched.length == max;
});
if (matched.length < max) {
this.emojiDb.some(x => {
if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
return matched.length == max;
});
}
if (matched.length < max) {
this.emojiDb.some(x => {
if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
return matched.length == max;
});
}
this.emojis = matched;
}
},
onMousedown(e) {
if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
},
onKeydown(e) {
const cancel = () => {
e.preventDefault();
e.stopPropagation();
};
switch (e.which) {
case 10: // [ENTER]
case 13: // [ENTER]
if (this.select !== -1) {
cancel();
(this.items[this.select] as any).click();
} else {
this.close();
}
break;
case 27: // [ESC]
cancel();
this.close();
break;
case 38: // []
if (this.select !== -1) {
cancel();
this.selectPrev();
} else {
this.close();
}
break;
case 9: // [TAB]
case 40: // []
cancel();
this.selectNext();
break;
default:
e.stopPropagation();
this.textarea.focus();
}
},
selectNext() {
if (++this.select >= this.items.length) this.select = 0;
this.applySelect();
},
selectPrev() {
if (--this.select < 0) this.select = this.items.length - 1;
this.applySelect();
},
applySelect() {
for (const el of Array.from(this.items)) {
el.removeAttribute('data-selected');
}
this.items[this.select].setAttribute('data-selected', 'true');
(this.items[this.select] as any).focus();
}
}
});
</script>
<style lang="scss" scoped>
.mk-autocomplete {
position: fixed;
z-index: 65535;
max-width: 100%;
margin-top: calc(1em + 8px);
overflow: hidden;
background: var(--panel);
border: solid 1px rgba(#000, 0.1);
border-radius: 4px;
transition: top 0.1s ease, left 0.1s ease;
> ol {
display: block;
margin: 0;
padding: 4px 0;
max-height: 190px;
max-width: 500px;
overflow: auto;
list-style: none;
> li {
display: flex;
align-items: center;
padding: 4px 12px;
white-space: nowrap;
overflow: hidden;
font-size: 0.9em;
cursor: default;
&, * {
user-select: none;
}
* {
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
background: var(--yrnqrguo);
}
&[data-selected='true'] {
background: var(--accent);
&, * {
color: #fff !important;
}
}
&:active {
background: var(--accentDarken);
&, * {
color: #fff !important;
}
}
}
}
> .users > li {
.avatar {
min-width: 28px;
min-height: 28px;
max-width: 28px;
max-height: 28px;
margin: 0 8px 0 0;
border-radius: 100%;
}
.name {
margin: 0 8px 0 0;
color: var(--autocompleteItemText);
}
.username {
color: var(--autocompleteItemTextSub);
}
}
> .hashtags > li {
.name {
color: var(--autocompleteItemText);
}
}
> .emojis > li {
.emoji {
display: inline-block;
margin: 0 4px 0 0;
width: 24px;
> img {
width: 24px;
vertical-align: bottom;
}
}
.name {
color: var(--autocompleteItemText);
}
.alias {
margin: 0 0 0 8px;
color: var(--autocompleteItemTextSub);
}
}
}
</style>

View file

@ -0,0 +1,116 @@
<template>
<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick">
<span class="inner" :style="icon"></span>
</span>
<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick">
<span class="inner" :style="icon"></span>
</span>
<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id">
<span class="inner" :style="icon"></span>
</router-link>
<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview">
<span class="inner" :style="icon"></span>
</router-link>
</template>
<script lang="ts">
import Vue from 'vue';
import { getStaticImageUrl } from '../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: {
cat(): boolean {
return this.user.isCat;
},
url(): string {
return this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(this.user.avatarUrl)
: this.user.avatarUrl;
},
icon(): any {
return {
backgroundColor: this.user.avatarColor,
backgroundImage: `url(${this.url})`,
};
}
},
watch: {
'user.avatarColor'() {
this.$el.style.color = this.user.avatarColor;
}
},
mounted() {
if (this.user.avatarColor) {
this.$el.style.color = this.user.avatarColor;
}
},
methods: {
onClick(e) {
this.$emit('click', e);
}
}
});
</script>
<style lang="scss" scoped>
.mk-avatar {
position: relative;
display: inline-block;
vertical-align: bottom;
flex-shrink: 0;
border-radius: 100%;
line-height: 16px;
&.cat {
&:before, &:after {
background: #df548f;
border: solid 4px currentColor;
box-sizing: border-box;
content: '';
display: inline-block;
height: 50%;
width: 50%;
}
&:before {
border-radius: 0 75% 75%;
transform: rotate(37.5deg) skew(30deg);
}
&: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;
border-radius: 100%;
z-index: 1;
}
}
</style>

View file

@ -0,0 +1,27 @@
<template>
<div>
<mk-avatar v-for="user in us" :user="user" :key="user.id" style="width:32px;height:32px;"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
userIds: {
required: true
},
},
data() {
return {
us: []
};
},
async created() {
this.us = await this.$root.api('users/show', {
userIds: this.userIds
});
}
});
</script>

View file

@ -0,0 +1,34 @@
<template>
<x-prism :inline="inline" :language="prismLang">{{ code }}</x-prism>
</template>
<script lang="ts">
import Vue from 'vue';
import 'prismjs';
import 'prismjs/themes/prism-okaidia.css';
import XPrism from 'vue-prism-component';
export default Vue.extend({
components: {
XPrism
},
props: {
code: {
type: String,
required: true
},
lang: {
type: String,
required: false
},
inline: {
type: Boolean,
required: false
}
},
computed: {
prismLang() {
return Prism.languages[this.lang] ? this.lang : 'js';
}
}
});
</script>

View file

@ -0,0 +1,26 @@
<template>
<x-code :code="code" :lang="lang" :inline="inline"/>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
components: {
XCode: () => import('./code-core.vue').then(m => m.default)
},
props: {
code: {
type: String,
required: true
},
lang: {
type: String,
required: false
},
inline: {
type: Boolean,
required: false
}
}
});
</script>

View file

@ -0,0 +1,73 @@
<template>
<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh _button" @click="toggle">
<b>{{ value ? this.$t('_cw.hide') : this.$t('_cw.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,
props: {
value: {
type: Boolean,
required: true
},
note: {
type: Object,
required: true
}
},
computed: {
label(): string {
return concat([
this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [],
this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [],
this.note.poll != null ? [this.$t('_cw.poll')] : []
] as string[][]).join(' / ');
}
},
methods: {
length,
toggle() {
this.$emit('input', !this.value);
}
}
});
</script>
<style lang="scss" scoped>
.nrvgflfuaxwgkxoynpnumyookecqrrvh {
display: inline-block;
padding: 4px 8px;
font-size: 0.7em;
color: var(--cwFg);
background: var(--cwBg);
border-radius: 2px;
&:hover {
background: var(--cwHoverBg);
}
> span {
margin-left: 4px;
&:before {
content: '(';
}
&:after {
content: ')';
}
}
}
</style>

View file

@ -0,0 +1,94 @@
<template>
<sequential-entrance class="sqadhkmv" ref="list" :direction="direction">
<template v-for="(item, i) in items">
<slot :item="item" :i="i"></slot>
<div class="separator" :key="item.id + '_date'" :data-index="i" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate()">
<p class="date">
<span><fa class="icon" :icon="faAngleUp"/>{{ getDateText(item.createdAt) }}</span>
<span>{{ getDateText(items[i + 1].createdAt) }}<fa class="icon" :icon="faAngleDown"/></span>
</p>
</div>
</template>
</sequential-entrance>
</template>
<script lang="ts">
import Vue from 'vue';
import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
export default Vue.extend({
i18n,
props: {
items: {
type: Array,
required: true,
},
direction: {
type: String,
required: false
}
},
data() {
return {
faAngleUp, faAngleDown
};
},
methods: {
getDateText(time: string) {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
return this.$t('monthAndDay', {
month: month.toString(),
day: date.toString()
});
},
focus() {
this.$refs.list.focus();
}
}
});
</script>
<style lang="scss" scoped>
.sqadhkmv {
> .separator {
text-align: center;
> .date {
display: inline-block;
position: relative;
margin: 0;
padding: 0 16px;
line-height: 32px;
text-align: center;
font-size: 12px;
border-radius: 64px;
background: var(--dateLabelBg);
color: var(--dateLabelFg);
> span {
&:first-child {
margin-right: 8px;
> .icon {
margin-right: 8px;
}
}
&:last-child {
margin-left: 8px;
> .icon {
margin-left: 8px;
}
}
}
}
}
}
</style>

View file

@ -0,0 +1,320 @@
<template>
<div class="mk-dialog" :class="{ iconOnly }">
<transition name="bg-fade" appear>
<div class="bg" ref="bg" @click="onBgClick" v-if="show"></div>
</transition>
<transition name="dialog" appear @after-leave="() => { destroyDom(); }">
<div class="main" ref="main" v-if="show">
<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="faCheck" v-if="type === 'success'"/>
<fa :icon="faTimesCircle" v-if="type === 'error'"/>
<fa :icon="faExclamationTriangle" v-if="type === 'warning'"/>
<fa :icon="faInfoCircle" v-if="type === 'info'"/>
<fa :icon="faQuestionCircle" v-if="type === 'question'"/>
<fa :icon="faSpinner" pulse v-if="type === 'waiting'"/>
</div>
<header v-if="title" v-html="title"></header>
<header v-if="title == null && user">{{ $t('enterUsername') }}</header>
<div class="body" v-if="text" v-html="text"></div>
<mk-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></mk-input>
<mk-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></mk-input>
<mk-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>
</mk-select>
<div class="buttons" v-if="!iconOnly && (showOkButton || showCancelButton) && !actions">
<mk-button inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user" :disabled="!canOk">{{ (showCancelButton || input || select || user) ? $t('ok') : $t('gotIt') }}</mk-button>
<mk-button inline @click="cancel" v-if="showCancelButton || input || select || user">{{ $t('cancel') }}</mk-button>
</div>
<div class="buttons" v-if="actions">
<mk-button v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</mk-button>
</div>
</template>
</div>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSpinner, faInfoCircle, faExclamationTriangle, faCheck } from '@fortawesome/free-solid-svg-icons';
import { faTimesCircle, faQuestionCircle } from '@fortawesome/free-regular-svg-icons';
import MkButton from './ui/button.vue';
import MkInput from './ui/input.vue';
import MkSelect from './ui/select.vue';
import parseAcct from '../../misc/acct/parse';
import i18n from '../i18n';
export default Vue.extend({
i18n,
components: {
MkButton,
MkInput,
MkSelect,
},
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
},
actions: {
required: false
},
showOkButton: {
type: Boolean,
default: true
},
showCancelButton: {
type: Boolean,
default: false
},
cancelableByBgClick: {
type: Boolean,
default: true
},
iconOnly: {
type: Boolean,
default: false
},
autoClose: {
type: Boolean,
default: false
}
},
data() {
return {
show: true,
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, faSpinner, faInfoCircle, faExclamationTriangle, faCheck
};
},
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;
if (this.autoClose) {
setTimeout(() => {
this.close();
}, 1000);
}
document.addEventListener('keydown', this.onKeydown);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onKeydown);
},
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();
},
close() {
if (!this.show) return;
this.show = false;
this.$el.style.pointerEvents = 'none';
(this.$refs.bg as any).style.pointerEvents = 'none';
(this.$refs.main as any).style.pointerEvents = 'none';
},
onBgClick() {
if (this.cancelableByBgClick) {
this.cancel();
}
},
onKeydown(e) {
if (e.which === 27) { // ESC
this.cancel();
}
},
onInputKeydown(e) {
if (e.which === 13) { // Enter
e.preventDefault();
e.stopPropagation();
this.ok();
}
}
}
});
</script>
<style lang="scss" scoped>
.dialog-enter-active, .dialog-leave-active {
transition: opacity 0.3s, transform 0.3s !important;
}
.dialog-enter, .dialog-leave-to {
opacity: 0;
transform: scale(0.9);
}
.bg-fade-enter-active, .bg-fade-leave-active {
transition: opacity 0.3s !important;
}
.bg-fade-enter, .bg-fade-leave-to {
opacity: 0;
}
.mk-dialog {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
z-index: 30000;
top: 0;
left: 0;
width: 100%;
height: 100%;
&.iconOnly > .main {
min-width: 0;
width: initial;
}
> .bg {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
}
> .main {
display: block;
position: fixed;
margin: auto;
padding: 32px;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
width: calc(100% - 32px);
text-align: center;
background: var(--panel);
border-radius: var(--radius);
> .icon {
font-size: 32px;
&.success {
color: var(--accent);
}
&.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;
> * {
margin: 0 8px;
}
}
}
}
</style>

View file

@ -0,0 +1,188 @@
<template>
<div class="zdjebgpv" :class="{ detail }" ref="thumbnail" :style="`background-color: ${ background }`">
<img
:src="file.url"
:alt="file.name"
:title="file.name"
@load="onThumbnailLoaded"
v-if="detail && is === 'image'"/>
<video
:src="file.url"
ref="volumectrl"
preload="metadata"
controls
v-else-if="detail && is === 'video'"/>
<img :src="file.thumbnailUrl" alt="" @load="onThumbnailLoaded" :style="`object-fit: ${ fit }`" v-else-if="isThumbnailAvailable"/>
<fa :icon="faFileImage" class="icon" v-else-if="is === 'image'"/>
<fa :icon="faFileVideo" class="icon" v-else-if="is === 'video'"/>
<audio
:src="file.url"
ref="volumectrl"
preload="metadata"
controls
v-else-if="detail && is === 'audio'"/>
<fa :icon="faMusic" class="icon" v-else-if="is === 'audio' || is === 'midi'"/>
<fa :icon="faFileCsv" class="icon" v-else-if="is === 'csv'"/>
<fa :icon="faFilePdf" class="icon" v-else-if="is === 'pdf'"/>
<fa :icon="faFileAlt" class="icon" v-else-if="is === 'textfile'"/>
<fa :icon="faFileArchive" class="icon" v-else-if="is === 'archive'"/>
<fa :icon="faFile" class="icon" v-else/>
<fa :icon="faFilm" class="icon-sub" v-if="!detail && isThumbnailAvailable && is === 'video'"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
faFile,
faFileAlt,
faFileImage,
faMusic,
faFileVideo,
faFileCsv,
faFilePdf,
faFileArchive,
faFilm
} from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
props: {
file: {
type: Object,
required: true
},
fit: {
type: String,
required: false,
default: 'cover'
},
detail: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
isContextmenuShowing: false,
isDragging: false,
faFile,
faFileAlt,
faFileImage,
faMusic,
faFileVideo,
faFileCsv,
faFilePdf,
faFileArchive,
faFilm
};
},
computed: {
is(): 'image' | 'video' | 'midi' | 'audio' | 'csv' | 'pdf' | 'textfile' | 'archive' | 'unknown' {
if (this.file.type.startsWith('image/')) return 'image';
if (this.file.type.startsWith('video/')) return 'video';
if (this.file.type === 'audio/midi') return 'midi';
if (this.file.type.startsWith('audio/')) return 'audio';
if (this.file.type.endsWith('/csv')) return 'csv';
if (this.file.type.endsWith('/pdf')) return 'pdf';
if (this.file.type.startsWith('text/')) return 'textfile';
if ([
"application/zip",
"application/x-cpio",
"application/x-bzip",
"application/x-bzip2",
"application/java-archive",
"application/x-rar-compressed",
"application/x-tar",
"application/gzip",
"application/x-7z-compressed"
].some(e => e === this.file.type)) return 'archive';
return 'unknown';
},
isThumbnailAvailable(): boolean {
return this.file.thumbnailUrl
? (this.is === 'image' || this.is === 'video')
: false;
},
background(): string {
return this.file.properties.avgColor || 'transparent';
}
},
mounted() {
const audioTag = this.$refs.volumectrl as HTMLAudioElement;
if (audioTag) audioTag.volume = this.$store.state.device.mediaVolume;
},
methods: {
onThumbnailLoaded() {
if (this.file.properties.avgColor) {
this.$refs.thumbnail.style.backgroundColor = 'transparent';
}
},
volumechange() {
const audioTag = this.$refs.volumectrl as HTMLAudioElement;
this.$store.commit('device/set', { key: 'mediaVolume', value: audioTag.volume });
}
}
});
</script>
<style lang="scss" scoped>
.zdjebgpv {
display: flex;
> img,
> .icon {
pointer-events: none;
}
> .icon-sub {
position: absolute;
width: 30%;
height: auto;
margin: 0;
right: 4%;
bottom: 4%;
}
> * {
margin: auto;
}
&:not(.detail) {
> img {
height: 100%;
width: 100%;
object-fit: cover;
}
> .icon {
height: 65%;
width: 65%;
}
> video,
> audio {
width: 100%;
}
}
&.detail {
> .icon {
height: 100px;
width: 100px;
margin: 16px;
}
> *:not(.icon) {
max-height: 300px;
max-width: 100%;
height: 100%;
object-fit: contain;
}
}
}
</style>

View file

@ -0,0 +1,53 @@
<template>
<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="selected.length === 0" @ok="ok()">
<template #header>{{ multiple ? $t('selectFiles') : $t('selectFile') }}<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length | number }})</span></template>
<div>
<x-drive :multiple="multiple" @change-selection="onChangeSelection" :select-mode="true"/>
</div>
</x-window>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import XDrive from './drive.vue';
import XWindow from './window.vue';
export default Vue.extend({
i18n,
components: {
XDrive,
XWindow,
},
props: {
type: {
type: String,
required: false,
default: undefined
},
multiple: {
type: Boolean,
default: false
}
},
data() {
return {
selected: []
};
},
methods: {
ok() {
this.$emit('selected', this.selected);
this.$refs.window.close();
},
onChangeSelection(files) {
this.selected = files;
}
}
});
</script>

View file

@ -0,0 +1,368 @@
<template>
<div class="ncvczrfv"
:data-is-selected="isSelected"
@click="onClick"
draggable="true"
@dragstart="onDragstart"
@dragend="onDragend"
:title="title"
>
<div class="label" v-if="$store.state.i.avatarId == file.id">
<img src="/assets/label.svg"/>
<p>{{ $t('avatar') }}</p>
</div>
<div class="label" v-if="$store.state.i.bannerId == file.id">
<img src="/assets/label.svg"/>
<p>{{ $t('banner') }}</p>
</div>
<div class="label red" v-if="file.isSensitive">
<img src="/assets/label-red.svg"/>
<p>{{ $t('nsfw') }}</p>
</div>
<x-file-thumbnail class="thumbnail" :file="file" fit="contain"/>
<p class="name">
<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
<span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import i18n from '../i18n';
import copyToClipboard from '../scripts/copy-to-clipboard';
//import updateAvatar from '../api/update-avatar';
//import updateBanner from '../api/update-banner';
import XFileThumbnail from './drive-file-thumbnail.vue';
import { faDownload, faLink, faICursor, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
i18n,
props: {
file: {
type: Object,
required: true,
},
selectMode: {
type: Boolean,
required: false,
default: false,
}
},
components: {
XFileThumbnail
},
data() {
return {
isDragging: false
};
},
computed: {
browser(): any {
return this.$parent;
},
isSelected(): boolean {
return this.browser.selectedFiles.some(f => f.id == this.file.id);
},
title(): string {
return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.size)}`;
}
},
methods: {
onClick(ev) {
if (this.selectMode) {
this.browser.chooseFile(this.file);
} else {
this.$root.menu({
items: [{
type: 'item',
text: this.$t('rename'),
icon: faICursor,
action: this.rename
}, {
type: 'item',
text: this.file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'),
icon: this.file.isSensitive ? faEye : faEyeSlash,
action: this.toggleSensitive
}, null, {
type: 'item',
text: this.$t('copyUrl'),
icon: faLink,
action: this.copyUrl
}, {
type: 'a',
href: this.file.url,
target: '_blank',
text: this.$t('download'),
icon: faDownload,
download: this.file.name
}, null, {
type: 'item',
text: this.$t('delete'),
icon: faTrashAlt,
action: this.deleteFile
}, null, {
type: 'nest',
text: this.$t('contextmenu.else-files'),
menu: [{
type: 'item',
text: this.$t('contextmenu.set-as-avatar'),
action: this.setAsAvatar
}, {
type: 'item',
text: this.$t('contextmenu.set-as-banner'),
action: this.setAsBanner
}]
}],
source: ev.currentTarget || ev.target,
});
}
},
onDragstart(e) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file));
this.isDragging = true;
//
// (=)
this.browser.isDragSource = true;
},
onDragend(e) {
this.isDragging = false;
this.browser.isDragSource = false;
},
onThumbnailLoaded() {
if (this.file.properties.avgColor) {
anime({
targets: this.$refs.thumbnail,
backgroundColor: 'transparent', // TODO fade
duration: 100,
easing: 'linear'
});
}
},
rename() {
this.$root.dialog({
title: this.$t('contextmenu.rename-file'),
input: {
placeholder: this.$t('contextmenu.input-new-file-name'),
default: this.file.name,
allowEmpty: false
}
}).then(({ canceled, result: name }) => {
if (canceled) return;
this.$root.api('drive/files/update', {
fileId: this.file.id,
name: name
});
});
},
toggleSensitive() {
this.$root.api('drive/files/update', {
fileId: this.file.id,
isSensitive: !this.file.isSensitive
});
},
copyUrl() {
copyToClipboard(this.file.url);
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
},
setAsAvatar() {
updateAvatar(this.$root)(this.file);
},
setAsBanner() {
updateBanner(this.$root)(this.file);
},
addApp() {
alert('not implemented yet');
},
async deleteFile() {
const { canceled } = await this.$root.dialog({
type: 'warning',
text: this.$t('driveFileDeleteConfirm', { name: this.file.name }),
showCancelButton: true
});
if (canceled) return;
this.$root.api('drive/files/delete', {
fileId: this.file.id
});
}
}
});
</script>
<style lang="scss" scoped>
.ncvczrfv {
position: relative;
padding: 8px 0 0 0;
min-height: 180px;
border-radius: 4px;
&, * {
cursor: pointer;
}
&:hover {
background: rgba(#000, 0.05);
> .label {
&:before,
&:after {
background: #0b65a5;
}
&.red {
&:before,
&:after {
background: #c12113;
}
}
}
}
&:active {
background: rgba(#000, 0.1);
> .label {
&:before,
&:after {
background: #0b588c;
}
&.red {
&:before,
&:after {
background: #ce2212;
}
}
}
}
&[data-is-selected] {
background: var(--accent);
&:hover {
background: var(--accentLighten);
}
&:active {
background: var(--accentDarken);
}
> .label {
&:before,
&:after {
display: none;
}
}
> .name {
color: #fff;
}
> .thumbnail {
color: #fff;
}
}
> .label {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
&:before,
&:after {
content: "";
display: block;
position: absolute;
z-index: 1;
background: #0c7ac9;
}
&:before {
top: 0;
left: 57px;
width: 28px;
height: 8px;
}
&:after {
top: 57px;
left: 0;
width: 8px;
height: 28px;
}
&.red {
&:before,
&:after {
background: #c12113;
}
}
> img {
position: absolute;
z-index: 2;
top: 0;
left: 0;
}
> p {
position: absolute;
z-index: 3;
top: 19px;
left: -28px;
width: 120px;
margin: 0;
text-align: center;
line-height: 28px;
color: #fff;
transform: rotate(-45deg);
}
}
> .thumbnail {
width: 128px;
height: 128px;
margin: auto;
color: var(--driveFileIcon);
}
> .name {
display: block;
margin: 4px 0 0 0;
font-size: 0.8em;
text-align: center;
word-break: break-all;
color: var(--fg);
overflow: hidden;
> .ext {
opacity: 0.5;
}
}
}
</style>

View file

@ -0,0 +1,281 @@
<template>
<div class="rghtznwe"
:data-draghover="draghover"
@click="onClick"
@mouseover="onMouseover"
@mouseout="onMouseout"
@dragover.prevent.stop="onDragover"
@dragenter.prevent="onDragenter"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
draggable="true"
@dragstart="onDragstart"
@dragend="onDragend"
:title="title"
>
<p class="name">
<template v-if="hover"><fa :icon="faFolderOpen" fixed-width/></template>
<template v-if="!hover"><fa :icon="faFolder" fixed-width/></template>
{{ folder.name }}
</p>
<p class="upload" v-if="$store.state.settings.uploadFolder == folder.id">
{{ $t('upload-folder') }}
</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faFolder, faFolderOpen } from '@fortawesome/free-regular-svg-icons';
import i18n from '../i18n';
export default Vue.extend({
i18n,
props: {
folder: {
type: Object,
required: true,
}
},
data() {
return {
hover: false,
draghover: false,
isDragging: false,
faFolder, faFolderOpen
};
},
computed: {
browser(): any {
return this.$parent;
},
title(): string {
return this.folder.name;
}
},
methods: {
onClick() {
this.browser.move(this.folder);
},
onMouseover() {
this.hover = true;
},
onMouseout() {
this.hover = false
},
onDragover(e) {
//
if (this.isDragging) {
//
e.dataTransfer.dropEffect = 'none';
return;
}
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder';
if (isFile || isDriveFile || isDriveFolder) {
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
},
onDragenter() {
if (!this.isDragging) this.draghover = true;
},
onDragleave() {
this.draghover = false;
},
onDrop(e) {
this.draghover = false;
//
if (e.dataTransfer.files.length > 0) {
for (const file of Array.from(e.dataTransfer.files)) {
this.browser.upload(file, this.folder);
}
return;
}
//#region
const driveFile = e.dataTransfer.getData('mk_drive_file');
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.browser.removeFile(file.id);
this.$root.api('drive/files/update', {
fileId: file.id,
folderId: this.folder.id
});
}
//#endregion
//#region
const driveFolder = e.dataTransfer.getData('mk_drive_folder');
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// reject
if (folder.id == this.folder.id) return;
this.browser.removeFolder(folder.id);
this.$root.api('drive/folders/update', {
folderId: folder.id,
parentId: this.folder.id
}).then(() => {
// noop
}).catch(err => {
switch (err) {
case 'detected-circular-definition':
this.$root.dialog({
title: this.$t('unable-to-process'),
text: this.$t('circular-reference-detected')
});
break;
default:
this.$root.dialog({
type: 'error',
text: this.$t('unhandled-error')
});
}
});
}
//#endregion
},
onDragstart(e) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('mk_drive_folder', JSON.stringify(this.folder));
this.isDragging = true;
//
// (=)
this.browser.isDragSource = true;
},
onDragend() {
this.isDragging = false;
this.browser.isDragSource = false;
},
go() {
this.browser.move(this.folder.id);
},
newWindow() {
this.browser.newWindow(this.folder);
},
rename() {
this.$root.dialog({
title: this.$t('contextmenu.rename-folder'),
input: {
placeholder: this.$t('contextmenu.input-new-folder-name'),
default: this.folder.name
}
}).then(({ canceled, result: name }) => {
if (canceled) return;
this.$root.api('drive/folders/update', {
folderId: this.folder.id,
name: name
});
});
},
deleteFolder() {
this.$root.api('drive/folders/delete', {
folderId: this.folder.id
}).then(() => {
if (this.$store.state.settings.uploadFolder === this.folder.id) {
this.$store.dispatch('settings/set', {
key: 'uploadFolder',
value: null
});
}
}).catch(err => {
switch(err.id) {
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
this.$root.dialog({
type: 'error',
title: this.$t('unable-to-delete'),
text: this.$t('has-child-files-or-folders')
});
break;
default:
this.$root.dialog({
type: 'error',
text: this.$t('unable-to-delete')
});
}
});
},
setAsUploadFolder() {
this.$store.dispatch('settings/set', {
key: 'uploadFolder',
value: this.folder.id
});
},
}
});
</script>
<style lang="scss" scoped>
.rghtznwe {
position: relative;
padding: 8px;
height: 64px;
background: var(--driveFolderBg);
border-radius: 4px;
&, * {
cursor: pointer;
}
* {
pointer-events: none;
}
&[data-draghover] {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -4px;
right: -4px;
bottom: -4px;
left: -4px;
border: 2px dashed var(--focus);
border-radius: 4px;
}
}
> .name {
margin: 0;
font-size: 0.9em;
color: var(--desktopDriveFolderFg);
> [data-icon] {
margin-right: 4px;
margin-left: 2px;
text-align: left;
}
}
> .upload {
margin: 4px 4px;
font-size: 0.8em;
text-align: right;
color: var(--desktopDriveFolderFg);
}
}
</style>

View file

@ -0,0 +1,139 @@
<template>
<div class="drylbebk"
:data-draghover="draghover"
@click="onClick"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.stop="onDrop"
>
<i v-if="folder == null"><fa :icon="faCloud"/></i>
<span>{{ folder == null ? $t('drive') : folder.name }}</span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faCloud } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
export default Vue.extend({
i18n,
props: {
folder: {
type: Object,
required: false,
}
},
data() {
return {
hover: false,
draghover: false,
faCloud
};
},
computed: {
browser(): any {
return this.$parent;
}
},
methods: {
onClick() {
this.browser.move(this.folder);
},
onMouseover() {
this.hover = true;
},
onMouseout() {
this.hover = false;
},
onDragover(e) {
//
if (this.folder == null && this.browser.folder == null) {
e.dataTransfer.dropEffect = 'none';
}
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder';
if (isFile || isDriveFile || isDriveFolder) {
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
return false;
},
onDragenter() {
if (this.folder || this.browser.folder) this.draghover = true;
},
onDragleave() {
if (this.folder || this.browser.folder) this.draghover = false;
},
onDrop(e) {
this.draghover = false;
//
if (e.dataTransfer.files.length > 0) {
for (const file of Array.from(e.dataTransfer.files)) {
this.browser.upload(file, this.folder);
}
return;
}
//#region
const driveFile = e.dataTransfer.getData('mk_drive_file');
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.browser.removeFile(file.id);
this.$root.api('drive/files/update', {
fileId: file.id,
folderId: this.folder ? this.folder.id : null
});
}
//#endregion
//#region
const driveFolder = e.dataTransfer.getData('mk_drive_folder');
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// reject
if (this.folder && folder.id == this.folder.id) return;
this.browser.removeFolder(folder.id);
this.$root.api('drive/folders/update', {
folderId: folder.id,
parentId: this.folder ? this.folder.id : null
});
}
//#endregion
}
}
});
</script>
<style lang="scss" scoped>
.drylbebk {
> * {
pointer-events: none;
}
&[data-draghover] {
background: #eee;
}
> i {
margin-right: 4px;
}
}
</style>

View file

@ -0,0 +1,664 @@
<template>
<div class="yfudmmck">
<nav>
<div class="path" @contextmenu.prevent.stop="() => {}">
<x-nav-folder :class="{ current: folder == null }"/>
<template v-for="folder in hierarchyFolders">
<span class="separator"><fa :icon="faAngleRight"/></span>
<x-nav-folder :folder="folder" :key="folder.id"/>
</template>
<span class="separator" v-if="folder != null"><fa :icon="faAngleRight"/></span>
<span class="folder current" v-if="folder != null">{{ folder.name }}</span>
</div>
</nav>
<div class="main" :class="{ uploading: uploadings.length > 0, fetching }"
ref="main"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
>
<div class="contents" ref="contents">
<div class="folders" ref="foldersContainer" v-if="folders.length > 0">
<x-folder v-for="folder in folders" :key="folder.id" class="folder" :folder="folder"/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
<div class="padding" v-for="n in 16"></div>
<mk-button v-if="moreFolders">{{ $t('@.load-more') }}</mk-button>
</div>
<div class="files" ref="filesContainer" v-if="files.length > 0">
<x-file v-for="file in files" :key="file.id" class="file" :file="file" :select-mode="selectMode"/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
<div class="padding" v-for="n in 16"></div>
<mk-button v-if="moreFiles" @click="fetchMoreFiles">{{ $t('@.load-more') }}</mk-button>
</div>
<div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching">
<p v-if="draghover">{{ $t('empty-draghover') }}</p>
<p v-if="!draghover && folder == null"><strong>{{ $t('emptyDrive') }}</strong><br/>{{ $t('empty-drive-description') }}</p>
<p v-if="!draghover && folder != null">{{ $t('emptyFolder') }}</p>
</div>
</div>
<mk-loading v-if="fetching"/>
</div>
<div class="dropzone" v-if="draghover"></div>
<x-uploader ref="uploader" @change="onChangeUploaderUploads" @uploaded="onUploaderUploaded"/>
<input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faAngleRight } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import XNavFolder from './drive.nav-folder.vue';
import XFolder from './drive.folder.vue';
import XFile from './drive.file.vue';
import XUploader from './uploader.vue';
import MkButton from './ui/button.vue';
export default Vue.extend({
i18n,
components: {
XNavFolder,
XFolder,
XFile,
XUploader,
MkButton,
},
props: {
initFolder: {
type: Object,
required: false
},
type: {
type: String,
required: false,
default: undefined
},
multiple: {
type: Boolean,
required: false,
default: false
},
selectMode: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
/**
* 現在の階層(フォルダ)
* * null でルートを表す
*/
folder: null,
files: [],
folders: [],
moreFiles: false,
moreFolders: false,
hierarchyFolders: [],
selectedFiles: [],
uploadings: [],
connection: null,
/**
* ドロップされようとしているか
*/
draghover: false,
/**
* 自信の所有するアイテムがドラッグをスタートさせたか
* (自分自身の階層にドロップできないようにするためのフラグ)
*/
isDragSource: false,
fetching: true,
faAngleRight
};
},
watch: {
folder() {
this.$emit('cd', this.folder);
}
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('drive');
this.connection.on('fileCreated', this.onStreamDriveFileCreated);
this.connection.on('fileUpdated', this.onStreamDriveFileUpdated);
this.connection.on('fileDeleted', this.onStreamDriveFileDeleted);
this.connection.on('folderCreated', this.onStreamDriveFolderCreated);
this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated);
this.connection.on('folderDeleted', this.onStreamDriveFolderDeleted);
if (this.initFolder) {
this.move(this.initFolder);
} else {
this.fetch();
}
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onStreamDriveFileCreated(file) {
this.addFile(file, true);
},
onStreamDriveFileUpdated(file) {
const current = this.folder ? this.folder.id : null;
if (current != file.folderId) {
this.removeFile(file);
} else {
this.addFile(file, true);
}
},
onStreamDriveFileDeleted(fileId) {
this.removeFile(fileId);
},
onStreamDriveFolderCreated(folder) {
this.addFolder(folder, true);
},
onStreamDriveFolderUpdated(folder) {
const current = this.folder ? this.folder.id : null;
if (current != folder.parentId) {
this.removeFolder(folder);
} else {
this.addFolder(folder, true);
}
},
onStreamDriveFolderDeleted(folderId) {
this.removeFolder(folderId);
},
onChangeUploaderUploads(uploads) {
this.uploadings = uploads;
},
onUploaderUploaded(file) {
this.addFile(file, true);
},
onDragover(e): any {
//
if (this.isDragSource) {
//
e.dataTransfer.dropEffect = 'none';
return;
}
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder';
if (isFile || isDriveFile || isDriveFolder) {
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
return false;
},
onDragenter(e) {
if (!this.isDragSource) this.draghover = true;
},
onDragleave(e) {
this.draghover = false;
},
onDrop(e): any {
this.draghover = false;
//
if (e.dataTransfer.files.length > 0) {
for (const file of Array.from(e.dataTransfer.files)) {
this.upload(file, this.folder);
}
return;
}
//#region
const driveFile = e.dataTransfer.getData('mk_drive_file');
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
if (this.files.some(f => f.id == file.id)) return;
this.removeFile(file.id);
this.$root.api('drive/files/update', {
fileId: file.id,
folderId: this.folder ? this.folder.id : null
});
}
//#endregion
//#region
const driveFolder = e.dataTransfer.getData('mk_drive_folder');
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// reject
if (this.folder && folder.id == this.folder.id) return false;
if (this.folders.some(f => f.id == folder.id)) return false;
this.removeFolder(folder.id);
this.$root.api('drive/folders/update', {
folderId: folder.id,
parentId: this.folder ? this.folder.id : null
}).then(() => {
// noop
}).catch(err => {
switch (err) {
case 'detected-circular-definition':
this.$root.dialog({
title: this.$t('unable-to-process'),
text: this.$t('circular-reference-detected')
});
break;
default:
this.$root.dialog({
type: 'error',
text: this.$t('unhandled-error')
});
}
});
}
//#endregion
},
selectLocalFile() {
(this.$refs.fileInput as any).click();
},
urlUpload() {
this.$root.dialog({
title: this.$t('url-upload'),
input: {
placeholder: this.$t('url-of-file')
}
}).then(({ canceled, result: url }) => {
if (canceled) return;
this.$root.api('drive/files/upload_from_url', {
url: url,
folderId: this.folder ? this.folder.id : undefined
});
this.$root.dialog({
title: this.$t('url-upload-requested'),
text: this.$t('may-take-time')
});
});
},
createFolder() {
this.$root.dialog({
title: this.$t('create-folder'),
input: {
placeholder: this.$t('folder-name')
}
}).then(({ canceled, result: name }) => {
if (canceled) return;
this.$root.api('drive/folders/create', {
name: name,
parentId: this.folder ? this.folder.id : undefined
}).then(folder => {
this.addFolder(folder, true);
});
});
},
onChangeFileInput() {
for (const file of Array.from((this.$refs.fileInput as any).files)) {
this.upload(file, this.folder);
}
},
upload(file, folder) {
if (folder && typeof folder == 'object') folder = folder.id;
(this.$refs.uploader as any).upload(file, folder);
},
chooseFile(file) {
const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id);
if (this.multiple) {
if (isAlreadySelected) {
this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id);
} else {
this.selectedFiles.push(file);
}
this.$emit('change-selection', this.selectedFiles);
} else {
if (isAlreadySelected) {
this.$emit('selected', file);
} else {
this.selectedFiles = [file];
this.$emit('change-selection', [file]);
}
}
},
move(target) {
if (target == null) {
this.goRoot();
return;
} else if (typeof target == 'object') {
target = target.id;
}
this.fetching = true;
this.$root.api('drive/folders/show', {
folderId: target
}).then(folder => {
this.folder = folder;
this.hierarchyFolders = [];
const dive = folder => {
this.hierarchyFolders.unshift(folder);
if (folder.parent) dive(folder.parent);
};
if (folder.parent) dive(folder.parent);
this.$emit('open-folder', folder);
this.fetch();
});
},
addFolder(folder, unshift = false) {
const current = this.folder ? this.folder.id : null;
if (current != folder.parentId) return;
if (this.folders.some(f => f.id == folder.id)) {
const exist = this.folders.map(f => f.id).indexOf(folder.id);
Vue.set(this.folders, exist, folder);
return;
}
if (unshift) {
this.folders.unshift(folder);
} else {
this.folders.push(folder);
}
},
addFile(file, unshift = false) {
const current = this.folder ? this.folder.id : null;
if (current != file.folderId) return;
if (this.files.some(f => f.id == file.id)) {
const exist = this.files.map(f => f.id).indexOf(file.id);
Vue.set(this.files, exist, file);
return;
}
if (unshift) {
this.files.unshift(file);
} else {
this.files.push(file);
}
},
removeFolder(folder) {
if (typeof folder == 'object') folder = folder.id;
this.folders = this.folders.filter(f => f.id != folder);
},
removeFile(file) {
if (typeof file == 'object') file = file.id;
this.files = this.files.filter(f => f.id != file);
},
appendFile(file) {
this.addFile(file);
},
appendFolder(folder) {
this.addFolder(folder);
},
prependFile(file) {
this.addFile(file, true);
},
prependFolder(folder) {
this.addFolder(folder, true);
},
goRoot() {
// root
if (this.folder == null) return;
this.folder = null;
this.hierarchyFolders = [];
this.$emit('move-root');
this.fetch();
},
fetch() {
this.folders = [];
this.files = [];
this.moreFolders = false;
this.moreFiles = false;
this.fetching = true;
let fetchedFolders = null;
let fetchedFiles = null;
const foldersMax = 30;
const filesMax = 30;
//
this.$root.api('drive/folders', {
folderId: this.folder ? this.folder.id : null,
limit: foldersMax + 1
}).then(folders => {
if (folders.length == foldersMax + 1) {
this.moreFolders = true;
folders.pop();
}
fetchedFolders = folders;
complete();
});
//
this.$root.api('drive/files', {
folderId: this.folder ? this.folder.id : null,
type: this.type,
limit: filesMax + 1
}).then(files => {
if (files.length == filesMax + 1) {
this.moreFiles = true;
files.pop();
}
fetchedFiles = files;
complete();
});
let flag = false;
const complete = () => {
if (flag) {
for (const x of fetchedFolders) this.appendFolder(x);
for (const x of fetchedFiles) this.appendFile(x);
this.fetching = false;
} else {
flag = true;
}
};
},
fetchMoreFiles() {
this.fetching = true;
const max = 30;
//
this.$root.api('drive/files', {
folderId: this.folder ? this.folder.id : null,
type: this.type,
untilId: this.files[this.files.length - 1].id,
limit: max + 1
}).then(files => {
if (files.length == max + 1) {
this.moreFiles = true;
files.pop();
} else {
this.moreFiles = false;
}
for (const x of files) this.appendFile(x);
this.fetching = false;
});
}
}
});
</script>
<style lang="scss" scoped>
.yfudmmck {
> nav {
display: block;
z-index: 2;
width: 100%;
overflow: auto;
font-size: 0.9em;
box-shadow: 0 1px 0 var(--divider);
&, * {
user-select: none;
}
> .path {
display: inline-block;
vertical-align: bottom;
line-height: 38px;
white-space: nowrap;
> * {
display: inline-block;
margin: 0;
padding: 0 8px;
line-height: 38px;
cursor: pointer;
* {
pointer-events: none;
}
&:hover {
text-decoration: underline;
}
&.current {
font-weight: bold;
cursor: default;
&:hover {
text-decoration: none;
}
}
&.separator {
margin: 0;
padding: 0;
opacity: 0.5;
cursor: default;
> [data-icon] {
margin: 0;
}
}
}
}
}
> .main {
padding: 8px 0;
overflow: auto;
&, * {
user-select: none;
}
&.fetching {
cursor: wait !important;
* {
pointer-events: none;
}
> .contents {
opacity: 0.5;
}
}
&.uploading {
height: calc(100% - 38px - 100px);
}
> .contents {
> .folders,
> .files {
display: flex;
flex-wrap: wrap;
> .folder,
> .file {
flex-grow: 1;
width: 144px;
margin: 4px;
box-sizing: border-box;
}
> .padding {
flex-grow: 1;
pointer-events: none;
width: 144px + 8px;
}
}
> .empty {
padding: 16px;
text-align: center;
pointer-events: none;
opacity: 0.5;
> p {
margin: 0;
}
}
}
}
> .dropzone {
position: absolute;
left: 0;
top: 38px;
width: 100%;
height: calc(100% - 38px);
border: dashed 2px var(--focus);
pointer-events: none;
}
> .mk-uploader {
height: 100px;
padding: 16px;
}
> input {
display: none;
}
}
</style>

View file

@ -0,0 +1,34 @@
<template>
<span class="mk-ellipsis">
<span>.</span><span>.</span><span>.</span>
</span>
</template>
<style lang="scss" 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

@ -0,0 +1,268 @@
<template>
<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }">
<div class="omfetrab">
<header>
<button v-for="category in categories"
class="_button"
: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('recentUsedEmojis') }}</header>
<div class="list">
<button v-for="(emoji, i) in ($store.state.device.recentEmojis || [])"
class="_button"
: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)"
class="_button"
: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" v-if="key">{{ key }}</header>
<div class="list">
<button v-for="emoji in customEmojis[key]"
class="_button"
: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>
</x-popup>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import { emojilist } from '../../misc/emojilist';
import { getStaticImageUrl } from '../scripts/get-static-image-url';
import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory } from '@fortawesome/free-solid-svg-icons';
import { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons';
import { groupByX } from '../../prelude/array';
import XPopup from './popup.vue';
export default Vue.extend({
i18n,
components: {
XPopup,
},
props: {
source: {
required: true
},
},
data() {
return {
emojilist,
getStaticImageUrl,
customEmojis: {},
faGlobe, faHistory,
categories: [{
text: this.$t('customEmoji'),
icon: faAsterisk,
isActive: true
}, {
name: 'people',
text: this.$t('people'),
icon: faLaugh,
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;
},
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;
}
}
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));
},
close() {
this.$refs.popup.close();
}
}
});
</script>
<style lang="scss" scoped>
.omfetrab {
width: 350px;
> header {
display: flex;
> button {
flex: 1;
padding: 10px 0;
font-size: 16px;
transition: color 0.2s ease;
&:hover {
color: var(--textHighlighted);
transition: color 0s;
}
&.active {
color: var(--accent);
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(--panel);
font-size: 12px;
}
header.sub {
padding: 4px 8px;
font-size: 12px;
}
div.list {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
gap: 4px;
padding: 8px;
> button {
position: relative;
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

@ -0,0 +1,132 @@
<template>
<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt"/>
<img v-else-if="char && !useOsDefaultEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt"/>
<span v-else-if="char && useOsDefaultEmojis">{{ char }}</span>
<span v-else>:{{ name }}:</span>
</template>
<script lang="ts">
import Vue from 'vue';
import { getStaticImageUrl } from '../scripts/get-static-image-url';
import { twemojiSvgBase } from '../../misc/twemoji-base';
export default Vue.extend({
props: {
name: {
type: String,
required: false
},
emoji: {
type: String,
required: false
},
normal: {
type: Boolean,
required: false,
default: false
},
noStyle: {
type: Boolean,
required: false,
default: false
},
customEmojis: {
required: false,
default: () => []
},
isReaction: {
type: Boolean,
default: false
},
},
data() {
return {
url: null,
char: null,
customEmoji: null
}
},
computed: {
alt(): string {
return this.customEmoji ? `:${this.customEmoji.name}:` : this.char;
},
useOsDefaultEmojis(): boolean {
return this.$store.state.device.useOsDefaultEmojis && !this.isReaction;
}
},
watch: {
customEmojis() {
if (this.name) {
const customEmoji = this.customEmojis.find(x => x.name == this.name);
if (customEmoji) {
this.customEmoji = customEmoji;
this.url = this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(customEmoji.url)
: customEmoji.url;
}
}
},
},
created() {
if (this.name) {
const customEmoji = this.customEmojis.find(x => x.name == this.name);
if (customEmoji) {
this.customEmoji = customEmoji;
this.url = this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(customEmoji.url)
: customEmoji.url;
} else {
//const emoji = lib[this.name];
//if (emoji) {
// this.char = emoji.char;
//}
}
} else {
this.char = this.emoji;
}
if (this.char) {
let codes = Array.from(this.char).map(x => x.codePointAt(0).toString(16));
if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f');
codes = codes.filter(x => x && x.length);
this.url = `${twemojiSvgBase}/${codes.join('-')}.svg`;
}
},
});
</script>
<style lang="scss" scoped>
.mk-emoji {
height: 1.25em;
vertical-align: -0.25em;
&.custom {
height: 2.5em;
vertical-align: middle;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.2);
}
&.normal {
height: 1.25em;
vertical-align: -0.25em;
&:hover {
transform: none;
}
}
}
&.noStyle {
height: auto !important;
}
}
</style>

View file

@ -0,0 +1,42 @@
<template>
<div class="wjqjnyhzogztorhrdgcpqlkxhkmuetgj _panel">
<p><fa :icon="faExclamationTriangle"/> {{ $t('error') }}</p>
<mk-button @click="() => $emit('retry')" class="button">{{ $t('retry') }}</mk-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import MkButton from './ui/button.vue';
export default Vue.extend({
i18n,
components: {
MkButton,
},
data() {
return {
faExclamationTriangle
};
},
});
</script>
<style lang="scss" scoped>
.wjqjnyhzogztorhrdgcpqlkxhkmuetgj {
max-width: 350px;
margin: 0 auto;
padding: 32px;
text-align: center;
> p {
margin: 0 0 8px 0;
}
> .button {
margin: 0 auto;
}
}
</style>

View file

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

View file

@ -0,0 +1,162 @@
<template>
<button class="wfliddvnhxvyusikowhxozkyxyenqxqr _button"
:class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou }"
@click="onClick"
:disabled="wait"
>
<template v-if="!wait">
<fa v-if="hasPendingFollowRequestFromYou && user.isLocked" :icon="faHourglassHalf"/>
<fa v-else-if="hasPendingFollowRequestFromYou && !user.isLocked" :icon="faSpinner" pulse/>
<fa v-else-if="isFollowing" :icon="faMinus"/>
<fa v-else-if="!isFollowing && user.isLocked" :icon="faPlus"/>
<fa v-else-if="!isFollowing && !user.isLocked" :icon="faPlus"/>
</template>
<template v-else><fa :icon="faSpinner" pulse fixed-width/></template>
</button>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import { faSpinner, faPlus, faMinus, faHourglassHalf } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
i18n,
props: {
user: {
type: Object,
required: true
},
},
data() {
return {
isFollowing: this.user.isFollowing,
hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou,
wait: false,
connection: null,
faSpinner, faPlus, faMinus, faHourglassHalf
};
},
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('unfollowConfirm', { 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="scss" scoped>
.wfliddvnhxvyusikowhxozkyxyenqxqr {
position: relative;
display: inline-block;
font-weight: bold;
color: var(--accent);
background: transparent;
border: solid 1px var(--accent);
padding: 0;
width: 31px;
height: 31px;
font-size: 16px;
border-radius: 100%;
background: #fff;
&:focus {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 100%;
}
}
&:hover {
//background: mix($primary, #fff, 20);
}
&:active {
//background: mix($primary, #fff, 40);
}
&.active {
color: #fff;
background: var(--accent);
&:hover {
background: var(--accentLighten);
border-color: var(--accentLighten);
}
&:active {
background: var(--accentDarken);
border-color: var(--accentDarken);
}
}
&.wait {
cursor: wait !important;
opacity: 0.7;
}
}
</style>

View file

@ -0,0 +1,33 @@
<template>
<div v-if="block" v-html="compiledFormula"></div>
<span v-else v-html="compiledFormula"></span>
</template>
<script lang="ts">
import Vue from 'vue';
import * as katex from 'katex';
export default Vue.extend({
props: {
formula: {
type: String,
required: true
},
block: {
type: Boolean,
required: true
}
},
computed: {
compiledFormula(): any {
return katex.renderToString(this.formula, {
throwOnError: false
} as any);
}
}
});
</script>
<style>
@import "../../../node_modules/katex/dist/katex.min.css";
</style>

View file

@ -0,0 +1,22 @@
<template>
<x-formula :formula="formula" :block="block" />
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
components: {
XFormula: () => import('./formula-core.vue').then(m => m.default)
},
props: {
formula: {
type: String,
required: true
},
block: {
type: Boolean,
required: true
}
}
});
</script>

View file

@ -0,0 +1,71 @@
<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,
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="scss" 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

@ -0,0 +1,25 @@
import Vue from 'vue';
import mfm from './misskey-flavored-markdown.vue';
import acct from './acct.vue';
import avatar from './avatar.vue';
import emoji from './emoji.vue';
import userName from './user-name.vue';
import ellipsis from './ellipsis.vue';
import time from './time.vue';
import url from './url.vue';
import loading from './loading.vue';
import SequentialEntrance from './sequential-entrance.vue';
import error from './error.vue';
Vue.component('mfm', mfm);
Vue.component('mk-acct', acct);
Vue.component('mk-avatar', avatar);
Vue.component('mk-emoji', emoji);
Vue.component('mk-user-name', userName);
Vue.component('mk-ellipsis', ellipsis);
Vue.component('mk-time', time);
Vue.component('mk-url', url);
Vue.component('mk-loading', loading);
Vue.component('mk-error', error);
Vue.component('sequential-entrance', SequentialEntrance);

View file

@ -0,0 +1,30 @@
<template>
<div class="yxspomdl">
<fa :icon="faSpinner" pulse fixed-width class="icon"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
data() {
return {
faSpinner
};
},
});
</script>
<style lang="scss" scoped>
.yxspomdl {
padding: 32px;
text-align: center;
> .icon {
font-size: 32px;
opacity: 0.5;
}
}
</style>

View file

@ -0,0 +1,109 @@
<template>
<div class="mk-media-banner">
<div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false">
<span class="icon"><fa :icon="faExclamationTriangle"/></span>
<b>{{ $t('sensitive') }}</b>
<span>{{ $t('clickToShow') }}</span>
</div>
<div class="audio" v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'">
<audio class="audio"
:src="media.url"
:title="media.name"
controls
ref="audio"
@volumechange="volumechange"
preload="metadata" />
</div>
<a class="download" v-else
:href="media.url"
:title="media.name"
:download="media.name"
>
<span class="icon"><fa icon="download"/></span>
<b>{{ media.name }}</b>
</a>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
export default Vue.extend({
i18n,
props: {
media: {
type: Object,
required: true
}
},
data() {
return {
hide: true,
faExclamationTriangle
};
},
mounted() {
const audioTag = this.$refs.audio as HTMLAudioElement;
if (audioTag) audioTag.volume = this.$store.state.device.mediaVolume;
},
methods: {
volumechange() {
const audioTag = this.$refs.audio as HTMLAudioElement;
this.$store.commit('device/set', { key: 'mediaVolume', value: audioTag.volume });
},
},
})
</script>
<style lang="scss" scoped>
.mk-media-banner {
width: 100%;
border-radius: 4px;
margin-top: 4px;
overflow: hidden;
> .download,
> .sensitive {
display: flex;
align-items: center;
font-size: 12px;
padding: 8px 12px;
white-space: nowrap;
> * {
display: block;
}
> b {
overflow: hidden;
text-overflow: ellipsis;
}
> *:not(:last-child) {
margin-right: .2em;
}
> .icon {
font-size: 1.6em;
}
}
> .download {
background: var(--noteAttachedFile);
}
> .sensitive {
background: #111;
color: #fff;
}
> .audio {
.audio {
display: block;
width: 100%;
}
}
}
</style>

View file

@ -0,0 +1,113 @@
<template>
<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false">
<div>
<b><fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b>
<span>{{ $t('clickToShow') }}</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 { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import { getStaticImageUrl } from '../scripts/get-static-image-url';
export default Vue.extend({
i18n,
props: {
image: {
type: Object,
required: true
},
raw: {
default: false
}
},
data() {
return {
hide: true,
faExclamationTriangle
};
},
computed: {
style(): any {
let url = `url(${
this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(this.image.thumbnailUrl)
: this.image.thumbnailUrl
})`;
if (this.$store.state.device.loadRemoteMedia) {
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() {
window.open(this.image.url, '_blank');
}
}
});
</script>
<style lang="scss" 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(--fg);
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

@ -0,0 +1,130 @@
<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">
<x-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';
import XVideo from './media-video.vue';
export default Vue.extend({
components: {
XBanner,
XImage,
XVideo,
},
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`
: '287px';
}
//#endregion
},
methods: {
previewable(file) {
return file.type.startsWith('video') || file.type.startsWith('image');
}
}
});
</script>
<style lang="scss" scoped>
.mk-media-list {
> .gird-container {
position: relative;
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

@ -0,0 +1,79 @@
<template>
<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="video.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false">
<div>
<b><fa icon="exclamation-triangle"/> {{ $t('sensitive') }}</b>
<span>{{ $t('clickToShow') }}</span>
</div>
</div>
<a class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else
:href="video.url"
rel="nofollow noopener"
target="_blank"
:style="imageStyle"
:title="video.name"
>
<fa :icon="faPlayCircle"/>
</a>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPlayCircle } from '@fortawesome/free-regular-svg-icons';
import i18n from '../i18n';
export default Vue.extend({
i18n,
props: {
video: {
type: Object,
required: true
}
},
data() {
return {
hide: true,
faPlayCircle
};
},
computed: {
imageStyle(): any {
return {
'background-image': `url(${this.video.thumbnailUrl})`
};
}
}
});
</script>
<style lang="scss" scoped>
.kkjnbbplepmiyuadieoenjgutgcmtsvu {
display: flex;
justify-content: center;
align-items: center;
font-size: 3.5em;
overflow: hidden;
background-position: center;
background-size: cover;
width: 100%;
height: 100%;
}
.icozogqfvdetwohsdglrbswgrejoxbdj {
display: flex;
justify-content: center;
align-items: center;
background: #111;
color: #fff;
> div {
display: table-cell;
text-align: center;
font-size: 12px;
> b {
display: block;
}
}
}
</style>

View file

@ -0,0 +1,82 @@
<template>
<router-link class="ldlomzub" :to="url" v-user-preview="canonical" v-if="url.startsWith('/')">
<span class="me" v-if="isMe">{{ $t('you') }}</span>
<span class="main">
<span class="username">@{{ username }}</span>
<span class="host" v-if="(host != localHost) || $store.state.settings.showFullAcct">@{{ toUnicode(host) }}</span>
</span>
</router-link>
<a class="ldlomzub" :href="url" target="_blank" rel="noopener" v-else>
<span class="main">
<span class="username">@{{ username }}</span>
<span class="host">@{{ toUnicode(host) }}</span>
</span>
</a>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import { toUnicode } from 'punycode';
import { host as localHost } from '../config';
export default Vue.extend({
i18n,
props: {
username: {
type: String,
required: true
},
host: {
type: String,
required: true
}
},
data() {
return {
localHost
};
},
computed: {
url(): string {
switch (this.host) {
case 'twitter.com':
case 'github.com':
return `https://${this.host}/${this.username}`;
default:
return `/${this.canonical}`;
}
},
canonical(): string {
return this.host === localHost ? `@${this.username}` : `@${this.username}@${toUnicode(this.host)}`;
},
isMe(): boolean {
return this.$store.getters.isSignedIn && (
`@${this.username}@${toUnicode(this.host)}` === `@${this.$store.state.i.username}@${toUnicode(localHost)}`.toLowerCase()
);
}
},
methods: {
toUnicode
}
});
</script>
<style lang="scss" scoped>
.ldlomzub {
color: var(--mention);
> .me {
pointer-events: none;
user-select: none;
font-size: 70%;
vertical-align: top;
}
> .main {
> .host {
opacity: 0.5;
}
}
}
</style>

View file

@ -0,0 +1,165 @@
<template>
<x-popup :source="source" :no-center="noCenter" :fixed="fixed" :width="width" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }">
<sequential-entrance class="rrevdjwt" :class="{ left: align === 'left' }" :delay="15" :direction="direction">
<template v-for="(item, i) in items.filter(item => item !== undefined)">
<div v-if="item === null" class="divider" :key="i" :data-index="i"></div>
<span v-else-if="item.type === 'label'" class="label item" :key="i" :data-index="i">
<span>{{ item.text }}</span>
</span>
<router-link v-else-if="item.type === 'link'" :to="item.to" @click.native="close()" :tabindex="i" class="_button item" :key="i" :data-index="i">
<fa v-if="item.icon" :icon="item.icon" fixed-width/>
<mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span>{{ item.text }}</span>
<i v-if="item.indicate"><fa :icon="faCircle"/></i>
</router-link>
<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item" :key="i" :data-index="i">
<fa v-if="item.icon" :icon="item.icon" fixed-width/>
<span>{{ item.text }}</span>
<i v-if="item.indicate"><fa :icon="faCircle"/></i>
</a>
<button v-else-if="item.type === 'user'" @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i" :data-index="i">
<mk-avatar :user="item.user" class="avatar"/><mk-user-name :user="item.user"/>
<i v-if="item.indicate"><fa :icon="faCircle"/></i>
</button>
<button v-else @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i" :data-index="i">
<fa v-if="item.icon" :icon="item.icon" fixed-width/>
<mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span>{{ item.text }}</span>
<i v-if="item.indicate"><fa :icon="faCircle"/></i>
</button>
</template>
</sequential-entrance>
</x-popup>
</template>
<script lang="ts">
import Vue from 'vue';
import { faCircle } from '@fortawesome/free-solid-svg-icons';
import XPopup from './popup.vue';
export default Vue.extend({
components: {
XPopup
},
props: {
source: {
required: true
},
items: {
type: Array,
required: true
},
align: {
type: String,
required: false
},
noCenter: {
type: Boolean,
required: false
},
fixed: {
type: Boolean,
required: false
},
width: {
type: Number,
required: false
},
direction: {
type: String,
required: false
},
},
data() {
return {
faCircle
};
},
methods: {
clicked(fn) {
fn();
this.close();
},
close() {
this.$refs.popup.close();
}
}
});
</script>
<style lang="scss" scoped>
@keyframes blink {
0% { opacity: 1; }
30% { opacity: 1; }
90% { opacity: 0; }
}
.rrevdjwt {
padding: 8px 0;
&.left {
> .item {
text-align: left;
}
}
> .item {
display: block;
padding: 8px 16px;
width: 100%;
box-sizing: border-box;
white-space: nowrap;
font-size: 0.9em;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
color: #fff;
background: var(--accent);
text-decoration: none;
}
&:active {
color: #fff;
background: var(--accentDarken);
}
&.label {
pointer-events: none;
font-size: 0.7em;
padding-bottom: 4px;
> span {
opacity: 0.7;
}
}
> [data-icon] {
margin-right: 4px;
width: 20px;
}
> .avatar {
margin-right: 4px;
width: 20px;
height: 20px;
}
> i {
position: absolute;
top: 5px;
left: 13px;
color: var(--accent);
font-size: 12px;
animation: blink 1s infinite;
}
}
> .divider {
margin: 8px 0;
height: 1px;
background: var(--divider);
}
}
</style>

View file

@ -0,0 +1,299 @@
import Vue, { VNode } from 'vue';
import { MfmForest } from '../../mfm/types';
import { parse, parsePlain } from '../../mfm/parse';
import MkUrl from './url.vue';
import MkMention from './mention.vue';
import { concat } from '../../prelude/array';
import MkFormula from './formula.vue';
import MkCode from './code.vue';
import MkGoogle from './google.vue';
import { host } from '../config';
export default Vue.component('misskey-flavored-markdown', {
props: {
text: {
type: String,
required: true
},
plain: {
type: Boolean,
default: false
},
nowrap: {
type: Boolean,
default: false
},
author: {
type: Object,
default: null
},
i: {
type: Object,
default: null
},
customEmojis: {
required: false,
},
isNote: {
type: Boolean,
default: true
},
},
render(createElement) {
if (this.text == null || this.text == '') return;
const ast = (this.plain ? parsePlain : parse)(this.text);
const genEl = (ast: MfmForest) => concat(ast.map((token): VNode[] => {
switch (token.node.type) {
case 'text': {
const text = token.node.props.text.replace(/(\r\n|\n|\r)/g, '\n');
if (!this.plain) {
const x = text.split('\n')
.map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]);
x[x.length - 1].pop();
return x;
} else {
return [createElement('span', text.replace(/\n/g, ' '))];
}
}
case 'bold': {
return [createElement('b', genEl(token.children))];
}
case 'strike': {
return [createElement('del', genEl(token.children))];
}
case 'italic': {
return (createElement as any)('i', {
attrs: {
style: 'font-style: oblique;'
},
}, genEl(token.children));
}
case 'big': {
return (createElement as any)('strong', {
attrs: {
style: `display: inline-block; font-size: 150% };`
},
directives: [this.$store.state.settings.disableAnimatedMfm ? {} : {
name: 'animate-css',
value: { classes: 'tada', iteration: 'infinite' }
}]
}, genEl(token.children));
}
case 'small': {
return [createElement('small', {
attrs: {
style: 'opacity: 0.7;'
},
}, genEl(token.children))];
}
case 'center': {
return [createElement('div', {
attrs: {
style: 'text-align:center;'
}
}, genEl(token.children))];
}
case 'motion': {
return (createElement as any)('span', {
attrs: {
style: 'display: inline-block;'
},
directives: [this.$store.state.settings.disableAnimatedMfm ? {} : {
name: 'animate-css',
value: { classes: 'rubberBand', iteration: 'infinite' }
}]
}, genEl(token.children));
}
case 'spin': {
const direction =
token.node.props.attr == 'left' ? 'reverse' :
token.node.props.attr == 'alternate' ? 'alternate' :
'normal';
const style = (this.$store.state.settings.disableAnimatedMfm)
? ''
: `animation: spin 1.5s linear infinite; animation-direction: ${direction};`;
return (createElement as any)('span', {
attrs: {
style: 'display: inline-block;' + style
},
}, genEl(token.children));
}
case 'jump': {
return (createElement as any)('span', {
attrs: {
style: (this.$store.state.settings.disableAnimatedMfm) ? 'display: inline-block;' : 'display: inline-block; animation: jump 0.75s linear infinite;'
},
}, genEl(token.children));
}
case 'flip': {
return (createElement as any)('span', {
attrs: {
style: 'display: inline-block; transform: scaleX(-1);'
},
}, genEl(token.children));
}
case 'url': {
return [createElement(MkUrl, {
key: Math.random(),
props: {
url: token.node.props.url,
rel: 'nofollow noopener',
},
attrs: {
style: 'color:var(--link);'
}
})];
}
case 'link': {
return [createElement('a', {
attrs: {
class: 'link',
href: token.node.props.url,
rel: 'nofollow noopener',
target: '_blank',
title: token.node.props.url,
style: 'color:var(--link);'
}
}, genEl(token.children))];
}
case 'mention': {
return [createElement(MkMention, {
key: Math.random(),
props: {
host: (token.node.props.host == null && this.author && this.author.host != null ? this.author.host : token.node.props.host) || host,
username: token.node.props.username
}
})];
}
case 'hashtag': {
return [createElement('router-link', {
key: Math.random(),
attrs: {
to: this.isNote ? `/tags/${encodeURIComponent(token.node.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.node.props.hashtag)}`,
style: 'color:var(--hashtag);'
}
}, `#${token.node.props.hashtag}`)];
}
case 'blockCode': {
return [createElement(MkCode, {
key: Math.random(),
props: {
code: token.node.props.code,
lang: token.node.props.lang,
}
})];
}
case 'inlineCode': {
return [createElement(MkCode, {
key: Math.random(),
props: {
code: token.node.props.code,
lang: token.node.props.lang,
inline: true
}
})];
}
case 'quote': {
if (this.shouldBreak) {
return [createElement('div', {
attrs: {
class: 'quote'
}
}, genEl(token.children))];
} else {
return [createElement('span', {
attrs: {
class: 'quote'
}
}, genEl(token.children))];
}
}
case 'title': {
return [createElement('div', {
attrs: {
class: 'title'
}
}, genEl(token.children))];
}
case 'emoji': {
const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || [];
return [createElement('mk-emoji', {
key: Math.random(),
attrs: {
emoji: token.node.props.emoji,
name: token.node.props.name
},
props: {
customEmojis: this.customEmojis || customEmojis,
normal: this.plain
}
})];
}
case 'mathInline': {
//const MkFormula = () => import('./formula.vue').then(m => m.default);
return [createElement(MkFormula, {
key: Math.random(),
props: {
formula: token.node.props.formula,
block: false
}
})];
}
case 'mathBlock': {
//const MkFormula = () => import('./formula.vue').then(m => m.default);
return [createElement(MkFormula, {
key: Math.random(),
props: {
formula: token.node.props.formula,
block: true
}
})];
}
case 'search': {
//const MkGoogle = () => import('./google.vue').then(m => m.default);
return [createElement(MkGoogle, {
key: Math.random(),
props: {
q: token.node.props.query
}
})];
}
default: {
console.log('unknown ast type:', token.node.type);
return [];
}
}
}));
// Parse ast to DOM
return createElement('span', genEl(ast));
}
});

View file

@ -0,0 +1,35 @@
<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="scss" scoped>
.havbbuyv {
white-space: pre-wrap;
&.nowrap {
white-space: pre;
word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html
overflow: hidden;
text-overflow: ellipsis;
}
::v-deep .quote {
display: block;
margin: 8px;
padding: 6px 0 6px 12px;
color: var(--mfmQuote);
border-left: solid 3px var(--mfmQuoteLine);
}
}
</style>

View file

@ -0,0 +1,84 @@
<template>
<div class="mk-modal">
<transition name="bg-fade" appear>
<div class="bg" ref="bg" v-if="show" @click="close()"></div>
</transition>
<transition name="modal" appear @after-leave="() => { $emit('closed'); destroyDom(); }">
<div class="content" ref="content" v-if="show" @click.self="close()"><slot></slot></div>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
},
data() {
return {
show: true,
};
},
methods: {
close() {
this.show = false;
(this.$refs.bg as any).style.pointerEvents = 'none';
(this.$refs.content as any).style.pointerEvents = 'none';
}
}
});
</script>
<style lang="scss" scoped>
.modal-enter-active, .modal-leave-active {
transition: opacity 0.3s, transform 0.3s !important;
}
.modal-enter, .modal-leave-to {
opacity: 0;
transform: scale(0.9);
}
.bg-fade-enter-active, .bg-fade-leave-active {
transition: opacity 0.3s !important;
}
.bg-fade-enter, .bg-fade-leave-to {
opacity: 0;
}
.mk-modal {
> .bg {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
width: 100%;
height: 100%;
background: var(--modalBg)
}
> .content {
position: fixed;
z-index: 10000;
top: 0;
bottom: 0;
left: 0;
right: 0;
max-width: calc(100% - 16px);
max-height: calc(100% - 16px);
overflow: auto;
margin: auto;
::v-deep > * {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
max-height: 100%;
max-width: 100%;
}
}
}
</style>

View file

@ -0,0 +1,99 @@
<template>
<header class="kkwtjztg">
<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-bot" v-if="note.user.isBot">bot</span>
<span class="username"><mk-acct :user="note.user"/></span>
<div class="info">
<span class="mobile" v-if="note.viaMobile"><fa :icon="faMobileAlt"/></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="faHome"/>
<fa v-if="note.visibility == 'followers'" :icon="faUnlock"/>
<fa v-if="note.visibility == 'specified'" :icon="faEnvelope"/>
</span>
</div>
</header>
</template>
<script lang="ts">
import Vue from 'vue';
import { faHome, faUnlock, faEnvelope, faMobileAlt } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
props: {
note: {
type: Object,
required: true
},
},
data() {
return {
faHome, faUnlock, faEnvelope, faMobileAlt
};
}
});
</script>
<style lang="scss" scoped>
.kkwtjztg {
display: flex;
align-items: baseline;
white-space: nowrap;
> .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-bot {
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;
}
> .username {
margin: 0 .5em 0 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--noteHeaderAcct);
}
> .info {
margin-left: auto;
font-size: 0.9em;
> * {
color: var(--noteHeaderInfo);
}
> .mobile {
margin-right: 8px;
}
> .visibility {
margin-left: 8px;
}
}
}
</style>

View file

@ -0,0 +1,199 @@
<template>
<x-menu :source="source" :items="items" @closed="closed"/>
</template>
<script lang="ts">
import Vue from 'vue';
import { faStar, faLink, faThumbtack, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
import { faCopy, faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import i18n from '../i18n';
import { url } from '../config';
import copyToClipboard from '../scripts/copy-to-clipboard';
import XMenu from './menu.vue';
export default Vue.extend({
i18n,
components: {
XMenu
},
props: ['note', 'source'],
data() {
return {
isFavorited: false,
isWatching: false
};
},
computed: {
items(): any[] {
if (this.$store.getters.isSignedIn) {
return [{
icon: faCopy,
text: this.$t('copyContent'),
action: this.copyContent
}, {
icon: faLink,
text: this.$t('copyLink'),
action: this.copyLink
}, this.note.uri ? {
icon: faExternalLinkSquareAlt,
text: this.$t('showOnRemote'),
action: () => {
window.open(this.note.uri, '_blank');
}
} : undefined,
null,
this.isFavorited ? {
icon: faStar,
text: this.$t('unfavorite'),
action: () => this.toggleFavorite(false)
} : {
icon: faStar,
text: this.$t('favorite'),
action: () => this.toggleFavorite(true)
},
this.note.userId != this.$store.state.i.id ? this.isWatching ? {
icon: faEyeSlash,
text: this.$t('unwatch'),
action: () => this.toggleWatch(false)
} : {
icon: faEye,
text: this.$t('watch'),
action: () => this.toggleWatch(true)
} : undefined,
this.note.userId == this.$store.state.i.id ? (this.$store.state.i.pinnedNoteIds || []).includes(this.note.id) ? {
icon: faThumbtack,
text: this.$t('unpin'),
action: () => this.togglePin(false)
} : {
icon: faThumbtack,
text: this.$t('pin'),
action: () => this.togglePin(true)
} : undefined,
...(this.note.userId == this.$store.state.i.id ? [
null,
{
icon: faTrashAlt,
text: this.$t('delete'),
action: this.del
}]
: []
)]
.filter(x => x !== undefined);
} else {
return [{
icon: faCopy,
text: this.$t('copyContent'),
action: this.copyContent
}, {
icon: faLink,
text: this.$t('copyLink'),
action: this.copyLink
}, this.note.uri ? {
icon: faExternalLinkSquareAlt,
text: this.$t('showOnRemote'),
action: () => {
window.open(this.note.uri, '_blank');
}
} : undefined]
.filter(x => x !== undefined);
}
}
},
created() {
this.$root.api('notes/state', {
noteId: this.note.id
}).then(state => {
this.isFavorited = state.isFavorited;
this.isWatching = state.isWatching;
});
},
methods: {
copyContent() {
copyToClipboard(this.note.text);
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
},
copyLink() {
copyToClipboard(`${url}/notes/${this.note.id}`);
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
},
togglePin(pin: boolean) {
this.$root.api(pin ? 'i/pin' : 'i/unpin', {
noteId: this.note.id
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
this.$emit('closed');
this.destroyDom();
}).catch(e => {
if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
this.$root.dialog({
type: 'error',
text: this.$t('pinLimitExceeded')
});
}
});
},
del() {
this.$root.dialog({
type: 'warning',
text: this.$t('noteDeleteConfirm'),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) return;
this.$root.api('notes/delete', {
noteId: this.note.id
}).then(() => {
this.$emit('closed');
this.destroyDom();
});
});
},
toggleFavorite(favorite: boolean) {
this.$root.api(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
noteId: this.note.id
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
this.$emit('closed');
this.destroyDom();
});
},
toggleWatch(watch: boolean) {
this.$root.api(watch ? 'notes/watching/create' : 'notes/watching/delete', {
noteId: this.note.id
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
this.destroyDom();
});
},
closed() {
this.$emit('closed');
this.$nextTick(() => {
this.destroyDom();
});
}
}
});
</script>

View file

@ -0,0 +1,121 @@
<template>
<div class="yohlumlkhizgfkvvscwfcrcggkotpvry">
<mk-avatar class="avatar" :user="note.user"/>
<div class="main">
<x-note-header class="header" :note="note" :mini="true"/>
<div class="body">
<p v-if="note.cw != null" class="cw">
<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
<x-cw-button v-model="showContent" :note="note"/>
</p>
<div class="content" v-show="note.cw == null || showContent">
<x-sub-note-content class="text" :note="note"/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue';
import XCwButton from './cw-button.vue';
export default Vue.extend({
components: {
XNoteHeader,
XSubNoteContent,
XCwButton,
},
props: {
note: {
type: Object,
required: true
}
},
data() {
return {
showContent: false
};
}
});
</script>
<style lang="scss" scoped>
.yohlumlkhizgfkvvscwfcrcggkotpvry {
display: flex;
margin: 0;
padding: 0;
overflow: hidden;
font-size: 10px;
@media (min-width: 350px) {
font-size: 12px;
}
@media (min-width: 500px) {
font-size: 14px;
}
> .avatar {
@media (min-width: 350px) {
margin: 0 10px 0 0;
width: 44px;
height: 44px;
}
@media (min-width: 500px) {
margin: 0 12px 0 0;
width: 48px;
height: 48px;
}
}
> .avatar {
flex-shrink: 0;
display: block;
margin: 0 10px 0 0;
width: 40px;
height: 40px;
border-radius: 8px;
}
> .main {
flex: 1;
min-width: 0;
> .header {
margin-bottom: 2px;
}
> .body {
> .cw {
cursor: default;
display: block;
margin: 0;
padding: 0;
overflow-wrap: break-word;
color: var(--noteText);
> .text {
margin-right: 8px;
}
}
> .content {
> .text {
cursor: default;
margin: 0;
padding: 0;
color: var(--subNoteText);
}
}
}
}
}
</style>

View file

@ -0,0 +1,108 @@
<template>
<div class="zlrxdaqttccpwhpaagdmkawtzklsccam">
<mk-avatar class="avatar" :user="note.user"/>
<div class="main">
<x-note-header class="header" :note="note" :mini="true"/>
<div class="body">
<p v-if="note.cw != null" class="cw">
<mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" />
<x-cw-button v-model="showContent" :note="note"/>
</p>
<div class="content" v-show="note.cw == null || showContent">
<x-sub-note-content class="text" :note="note"/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue';
import XCwButton from './cw-button.vue';
export default Vue.extend({
components: {
XNoteHeader,
XSubNoteContent,
XCwButton,
},
props: {
note: {
type: Object,
required: true
},
// TODO
truncate: {
type: Boolean,
default: true
}
},
inject: {
narrow: {
default: false
}
},
data() {
return {
showContent: false
};
}
});
</script>
<style lang="scss" scoped>
.zlrxdaqttccpwhpaagdmkawtzklsccam {
display: flex;
padding: 16px 32px;
font-size: 0.9em;
background: rgba(0, 0, 0, 0.03);
@media (max-width: 450px) {
padding: 14px 16px;
}
> .avatar {
flex-shrink: 0;
display: block;
margin: 0 8px 0 0;
width: 38px;
height: 38px;
border-radius: 8px;
}
> .main {
flex: 1;
min-width: 0;
> .header {
margin-bottom: 2px;
}
> .body {
> .cw {
cursor: default;
display: block;
margin: 0;
padding: 0;
overflow-wrap: break-word;
> .text {
margin-right: 8px;
}
}
> .content {
> .text {
margin: 0;
padding: 0;
}
}
}
}
}
</style>

View file

@ -0,0 +1,729 @@
<template>
<div
class="note _panel"
v-show="appearNote.deletedAt == null && !hideThisNote"
:tabindex="appearNote.deletedAt == null ? '-1' : null"
:class="{ renote: isRenote }"
v-hotkey="keymap"
v-size="[{ max: 500 }, { max: 450 }, { max: 350 }, { max: 300 }]"
>
<x-sub v-for="note in conversation" :key="note.id" :note="note"/>
<x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
<div class="pinned" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div>
<div class="renote" v-if="isRenote">
<mk-avatar class="avatar" :user="note.user"/>
<fa :icon="faRetweet"/>
<i18n path="renotedBy" 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">
<mk-time :time="note.createdAt"/>
<span class="visibility" v-if="note.visibility != 'public'">
<fa v-if="note.visibility == 'home'" :icon="faHome"/>
<fa v-if="note.visibility == 'followers'" :icon="faUnlock"/>
<fa v-if="note.visibility == 'specified'" :icon="faEnvelope"/>
</span>
</div>
</div>
<article class="article">
<mk-avatar class="avatar" :user="appearNote.user"/>
<div class="main">
<x-note-header class="header" :note="appearNote" :mini="true"/>
<div class="body" v-if="appearNote.deletedAt == null">
<p v-if="appearNote.cw != null" class="cw">
<mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" />
<x-cw-button v-model="showContent" :note="appearNote"/>
</p>
<div class="content" v-show="appearNote.cw == null || showContent">
<div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
<router-link class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><fa :icon="faReply"/></router-link>
<mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/>
<a class="rp" v-if="appearNote.renote != null">RN:</a>
</div>
<div class="files" v-if="appearNote.files.length > 0">
<x-media-list :media-list="appearNote.files"/>
</div>
<x-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/>
<x-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" class="url-preview"/>
<div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div>
</div>
</div>
<footer v-if="appearNote.deletedAt == null" class="footer">
<x-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
<button @click="reply()" class="button _button">
<template v-if="appearNote.reply"><fa :icon="faReplyAll"/></template>
<template v-else><fa :icon="faReply"/></template>
<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
</button>
<button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" class="button _button" ref="renoteButton">
<fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
</button>
<button v-else class="button _button">
<fa :icon="faBan"/>
</button>
<button v-if="!isMyNote && appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
<fa :icon="faPlus"/>
</button>
<button v-if="!isMyNote && appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton">
<fa :icon="faMinus"/>
</button>
<button class="button _button" @click="menu()" ref="menuButton">
<fa :icon="faEllipsisH"/>
</button>
</footer>
<div class="deleted" v-if="appearNote.deletedAt != null">{{ $t('deleted') }}</div>
</div>
</article>
<x-sub v-for="note in replies" :key="note.id" :note="note"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan } from '@fortawesome/free-solid-svg-icons';
import { parse } from '../../mfm/parse';
import { sum, unique } from '../../prelude/array';
import i18n from '../i18n';
import XSub from './note.sub.vue';
import XNoteHeader from './note-header.vue';
import XNotePreview from './note-preview.vue';
import XReactionsViewer from './reactions-viewer.vue';
import XMediaList from './media-list.vue';
import XCwButton from './cw-button.vue';
import XPoll from './poll.vue';
import XUrlPreview from './url-preview.vue';
import MkNoteMenu from './note-menu.vue';
import MkReactionPicker from './reaction-picker.vue';
import MkRenotePicker from './renote-picker.vue';
import pleaseLogin from '../scripts/please-login';
function focus(el, fn) {
const target = fn(el);
if (target) {
if (target.hasAttribute('tabindex')) {
target.focus();
} else {
focus(target, fn);
}
}
}
export default Vue.extend({
i18n,
components: {
XSub,
XNoteHeader,
XNotePreview,
XReactionsViewer,
XMediaList,
XCwButton,
XPoll,
XUrlPreview,
},
props: {
note: {
type: Object,
required: true
},
detail: {
type: Boolean,
required: false,
default: false
},
pinned: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
connection: null,
conversation: [],
replies: [],
showContent: false,
hideThisNote: false,
openingMenu: false,
faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan
};
},
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(this.$store.state.settings.reactions[0]),
'2': () => this.reactDirectly(this.$store.state.settings.reactions[1]),
'3': () => this.reactDirectly(this.$store.state.settings.reactions[2]),
'4': () => this.reactDirectly(this.$store.state.settings.reactions[3]),
'5': () => this.reactDirectly(this.$store.state.settings.reactions[4]),
'6': () => this.reactDirectly(this.$store.state.settings.reactions[5]),
'7': () => this.reactDirectly(this.$store.state.settings.reactions[6]),
'8': () => this.reactDirectly(this.$store.state.settings.reactions[7]),
'9': () => this.reactDirectly(this.$store.state.settings.reactions[8]),
'0': () => this.reactDirectly(this.$store.state.settings.reactions[9]),
};
},
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() {
if (this.$store.getters.isSignedIn) {
this.connection = this.$root.stream;
}
if (this.detail) {
this.$root.api('notes/children', {
noteId: this.appearNote.id,
limit: 30
}).then(replies => {
this.replies = replies;
});
if (this.appearNote.replyId) {
this.$root.api('notes/conversation', {
noteId: this.appearNote.replyId
}).then(conversation => {
this.conversation = conversation.reverse();
});
}
}
},
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) {
this.connection.send('sn', { id: this.appearNote.id });
if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
}
},
decapture(withHandler = false) {
if (this.$store.getters.isSignedIn) {
this.connection.send('un', {
id: this.appearNote.id
});
if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
}
},
onStreamConnected() {
this.capture();
},
onStreamNoteUpdated(data) {
const { type, id, body } = data;
if (id !== this.appearNote.id) return;
switch (type) {
case 'reacted': {
const reaction = body.reaction;
if (this.appearNote.reactions == null) {
Vue.set(this.appearNote, 'reactions', {});
}
if (this.appearNote.reactions[reaction] == null) {
Vue.set(this.appearNote.reactions, reaction, 0);
}
// Increment the count
this.appearNote.reactions[reaction]++;
if (body.userId == this.$store.state.i.id) {
Vue.set(this.appearNote, 'myReaction', reaction);
}
break;
}
case 'unreacted': {
const reaction = body.reaction;
if (this.appearNote.reactions == null) {
return;
}
if (this.appearNote.reactions[reaction] == null) {
return;
}
// Decrement the count
if (this.appearNote.reactions[reaction] > 0) this.appearNote.reactions[reaction]--;
if (body.userId == this.$store.state.i.id) {
Vue.set(this.appearNote, 'myReaction', null);
}
break;
}
case 'pollVoted': {
const choice = body.choice;
this.appearNote.poll.choices[choice].votes++;
if (body.userId == this.$store.state.i.id) {
Vue.set(this.appearNote.poll.choices[choice], 'isVoted', true);
}
break;
}
case 'deleted': {
Vue.set(this.appearNote, 'deletedAt', body.deletedAt);
Vue.set(this.appearNote, 'renote', null);
this.appearNote.text = null;
this.appearNote.fileIds = [];
this.appearNote.poll = null;
this.appearNote.cw = null;
break;
}
}
},
reply(viaKeyboard = false) {
pleaseLogin(this.$root);
this.$root.post({
reply: this.appearNote,
animation: !viaKeyboard,
}, () => {
this.focus();
});
},
renote() {
pleaseLogin(this.$root);
this.blur();
this.$root.new(MkRenotePicker, {
source: this.$refs.renoteButton,
note: this.appearNote,
}).$once('closed', this.focus);
},
renoteDirectly() {
(this as any).$root.api('notes/create', {
renoteId: this.appearNote.id
});
},
react(viaKeyboard = false) {
pleaseLogin(this.$root);
this.blur();
const picker = this.$root.new(MkReactionPicker, {
source: this.$refs.reactButton,
showFocus: viaKeyboard,
});
picker.$once('chosen', reaction => {
this.$root.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
}).then(() => {
picker.close();
});
});
picker.$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',
iconOnly: true, autoClose: true
});
});
},
del() {
this.$root.dialog({
type: 'warning',
text: this.$t('noteDeleteConfirm'),
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();
});
},
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);
}
}
});
</script>
<style lang="scss" scoped>
.note {
position: relative;
transition: box-shadow 0.1s ease;
&.max-width_500px {
font-size: 0.9em;
}
&.max-width_450px {
> .renote {
padding: 8px 16px 0 16px;
}
> .article {
padding: 14px 16px 9px;
> .avatar {
margin: 0 10px 8px 0;
width: 50px;
height: 50px;
}
}
}
&.max-width_350px {
> .article {
> .main {
> .footer {
> .button {
&:not(:last-child) {
margin-right: 18px;
}
}
}
}
}
}
&.max-width_300px {
font-size: 0.825em;
> .article {
> .avatar {
width: 44px;
height: 44px;
}
> .main {
> .footer {
> .button {
&:not(:last-child) {
margin-right: 12px;
}
}
}
}
}
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px var(--focus);
}
&:hover > .article > .main > .footer > .button {
opacity: 1;
}
> *:first-child {
border-radius: var(--radius) var(--radius) 0 0;
}
> *:last-child {
border-radius: 0 0 var(--radius) var(--radius);
}
> .pinned {
padding: 16px 32px 8px 32px;
line-height: 24px;
font-size: 90%;
white-space: pre;
color: #d28a3f;
@media (max-width: 450px) {
padding: 8px 16px 0 16px;
}
> [data-icon] {
margin-right: 4px;
}
}
> .pinned + .article {
padding-top: 8px;
}
> .renote {
display: flex;
align-items: center;
padding: 16px 32px 8px 32px;
line-height: 28px;
white-space: pre;
color: var(--renote);
> .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;
> .mk-time {
flex-shrink: 0;
}
> .visibility {
margin-left: 8px;
[data-icon] {
margin-right: 0;
}
}
}
}
> .renote + .article {
padding-top: 8px;
}
> .article {
display: flex;
padding: 28px 32px 18px;
> .avatar {
flex-shrink: 0;
display: block;
//position: sticky;
//top: 72px;
margin: 0 14px 8px 0;
width: 58px;
height: 58px;
}
> .main {
flex: 1;
min-width: 0;
> .body {
> .cw {
cursor: default;
display: block;
margin: 0;
padding: 0;
overflow-wrap: break-word;
> .text {
margin-right: 8px;
}
}
> .content {
> .text {
overflow-wrap: break-word;
> .reply {
color: var(--accent);
margin-right: 0.5em;
}
> .rp {
margin-left: 4px;
font-style: oblique;
color: var(--renote);
}
}
> .url-preview {
margin-top: 8px;
}
> .mk-poll {
font-size: 80%;
}
> .renote {
padding: 8px 0;
> * {
padding: 16px;
border: dashed 1px var(--renote);
border-radius: 8px;
}
}
}
}
> .footer {
> .button {
margin: 0;
padding: 8px;
opacity: 0.7;
&:not(:last-child) {
margin-right: 28px;
}
&:hover {
color: var(--mkykhqkw);
}
> .count {
display: inline;
margin: 0 0 0 8px;
opacity: 0.7;
}
&.reacted {
color: var(--accent);
}
}
}
> .deleted {
opacity: 0.7;
}
}
}
}
</style>

View file

@ -0,0 +1,144 @@
<template>
<div class="mk-notes" v-size="[{ max: 500 }]">
<div class="empty" v-if="empty">{{ $t('noNotes') }}</div>
<mk-error v-if="error" @retry="init()"/>
<x-list ref="notes" class="notes" :items="notes" v-slot="{ item: note, i }">
<x-note :note="note" :detail="detail" :key="note.id" :data-index="i"/>
</x-list>
<footer v-if="more">
<button @click="fetchMore()" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" class="_buttonPrimary">
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
<template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template>
</button>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import paging from '../scripts/paging';
import XNote from './note.vue';
import XList from './date-separated-list.vue';
import getUserName from '../../misc/get-user-name';
import getNoteSummary from '../../misc/get-note-summary';
export default Vue.extend({
i18n,
components: {
XNote, XList
},
mixins: [
paging({
onPrepend: (self, note) => {
//
if (document.hidden) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(getUserName(note.user), {
body: getNoteSummary(note),
icon: note.user.avatarUrl,
tag: 'newNote'
});
}
}
},
before: (self) => {
self.$emit('before');
},
after: (self, e) => {
self.$emit('after', e);
}
}),
],
props: {
pagination: {
required: true
},
detail: {
type: Boolean,
required: false,
default: false
},
extract: {
required: false
}
},
data() {
return {
faSpinner
};
},
computed: {
notes(): any[] {
return this.extract ? this.extract(this.items) : this.items;
},
},
methods: {
focus() {
this.$refs.notes.focus();
}
}
});
</script>
<style lang="scss" scoped>
.mk-notes {
> .empty {
margin: 0 auto;
padding: 32px;
text-align: center;
background: rgba(0, 0, 0, 0.3);
color: #fff;
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
border-radius: 6px;
}
> .notes {
> ::v-deep * {
margin-bottom: var(--marginFull);
}
}
&.max-width_500px {
> .notes {
> ::v-deep * {
margin-bottom: var(--marginHalf);
}
}
}
> footer {
text-align: center;
&:empty {
display: none;
}
> button {
margin: 0;
padding: 16px;
width: 100%;
border-radius: var(--radius);
&:disabled {
opacity: 0.7;
}
}
}
}
</style>

View file

@ -0,0 +1,219 @@
<template>
<div class="mk-notification" :class="notification.type">
<div class="head">
<mk-avatar class="avatar" :user="notification.user"/>
<div class="icon" :class="notification.type">
<fa :icon="faPlus" v-if="notification.type === 'follow'"/>
<fa :icon="faClock" v-if="notification.type === 'receiveFollowRequest'"/>
<fa :icon="faCheck" v-if="notification.type === 'followRequestAccepted'"/>
<fa :icon="faRetweet" v-if="notification.type === 'renote'"/>
<fa :icon="faReply" v-if="notification.type === 'reply'"/>
<fa :icon="faAt" v-if="notification.type === 'mention'"/>
<fa :icon="faQuoteLeft" v-if="notification.type === 'quote'"/>
<x-reaction-icon v-if="notification.type === 'reaction'" :reaction="notification.reaction" :no-style="true"/>
</div>
</div>
<div class="tail">
<header>
<router-link class="name" :to="notification.user | userPage" v-user-preview="notification.user.id"><mk-user-name :user="notification.user"/></router-link>
<mk-time :time="notification.createdAt" v-if="withTime"/>
</header>
<router-link v-if="notification.type === 'reaction'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
<fa :icon="faQuoteLeft"/>
<mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/>
<fa :icon="faQuoteRight"/>
</router-link>
<router-link v-if="notification.type === 'renote'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)">
<fa :icon="faQuoteLeft"/>
<mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.renote.emojis"/>
<fa :icon="faQuoteRight"/>
</router-link>
<router-link v-if="notification.type === 'reply'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
<mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/>
</router-link>
<router-link v-if="notification.type === 'mention'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
<mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/>
</router-link>
<router-link v-if="notification.type === 'quote'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
<mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/>
</router-link>
<span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $t('youGotNewFollower') }}</span>
<span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $t('followRequestAccepted') }}</span>
<span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $t('receiveFollowRequest') }}<div v-if="!nowrap && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $t('reject') }}</button></div></span>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faCheck } from '@fortawesome/free-solid-svg-icons';
import { faClock } from '@fortawesome/free-regular-svg-icons';
import getNoteSummary from '../../misc/get-note-summary';
import XReactionIcon from './reaction-icon.vue';
export default Vue.extend({
components: {
XReactionIcon
},
props: {
notification: {
type: Object,
required: true,
},
withTime: {
type: Boolean,
required: false,
default: false,
},
nowrap: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
getNoteSummary,
followRequestDone: false,
faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faClock, faCheck
};
},
methods: {
acceptFollowRequest() {
this.followRequestDone = true;
this.$root.api('following/requests/accept', { userId: this.notification.user.id });
},
rejectFollowRequest() {
this.followRequestDone = true;
this.$root.api('following/requests/reject', { userId: this.notification.user.id });
},
}
});
</script>
<style lang="scss" scoped>
.mk-notification {
position: relative;
box-sizing: border-box;
padding: 16px;
font-size: 0.9em;
overflow-wrap: break-word;
display: flex;
@media (max-width: 500px) {
padding: 12px;
font-size: 0.8em;
}
&:after {
content: "";
display: block;
clear: both;
}
> .head {
position: sticky;
top: 0;
flex-shrink: 0;
width: 42px;
height: 42px;
margin-right: 8px;
> .avatar {
display: block;
width: 100%;
height: 100%;
border-radius: 6px;
}
> .icon {
position: absolute;
z-index: 1;
bottom: -2px;
right: -2px;
width: 20px;
height: 20px;
box-sizing: border-box;
border-radius: 100%;
background: var(--panel);
box-shadow: 0 0 0 3px var(--panel);
font-size: 12px;
pointer-events: none;
> * {
color: #fff;
width: 100%;
height: 100%;
}
&.follow, &.followRequestAccepted, &.receiveFollowRequest {
padding: 3px;
background: #36aed2;
}
&.retweet {
padding: 3px;
background: #36d298;
}
&.quote {
padding: 3px;
background: #36d298;
}
&.reply {
padding: 3px;
background: #007aff;
}
&.mention {
padding: 3px;
background: #88a6b7;
}
}
}
> .tail {
flex: 1;
min-width: 0;
> header {
display: flex;
align-items: baseline;
white-space: nowrap;
> .name {
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
overflow: hidden;
}
> .mk-time {
margin-left: auto;
font-size: 0.9em;
}
}
> .text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
> [data-icon] {
vertical-align: super;
font-size: 50%;
opacity: 0.5;
}
> [data-icon]:first-child {
margin-right: 4px;
}
> [data-icon]:last-child {
margin-left: 4px;
}
}
}
}
</style>

View file

@ -0,0 +1,136 @@
<template>
<div class="mk-notifications">
<div class="contents">
<x-list class="notifications" :items="items" v-slot="{ item: notification, i }">
<x-notification :notification="notification" :with-time="true" :nowrap="false" class="notification" :key="notification.id" :data-index="i"/>
</x-list>
<button class="more _button" v-if="more" @click="fetchMore" :disabled="moreFetching">
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
<template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template>
</button>
<p class="empty" v-if="empty">{{ $t('noNotifications') }}</p>
<mk-error v-if="error" @retry="init()"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import paging from '../scripts/paging';
import XNotification from './notification.vue';
import XList from './date-separated-list.vue';
export default Vue.extend({
i18n,
components: {
XNotification,
XList,
},
mixins: [
paging({}),
],
props: {
type: {
type: String,
required: false
},
wide: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
connection: null,
pagination: {
endpoint: 'i/notifications',
limit: 10,
params: () => ({
includeTypes: this.type ? [this.type] : undefined
})
},
faSpinner
};
},
watch: {
type() {
this.reload();
}
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('notification', this.onNotification);
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onNotification(notification) {
// TODO: ()
this.$root.stream.send('readNotification', {
id: notification.id
});
this.prepend(notification);
},
}
});
</script>
<style lang="scss" scoped>
.mk-notifications {
> .contents {
overflow: auto;
height: 100%;
padding: 8px 8px 0 8px;
> .notifications {
> ::v-deep * {
margin-bottom: 8px;
}
> .notification {
background: var(--panel);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
> .more {
display: block;
width: 100%;
padding: 16px;
> [data-icon] {
margin-right: 4px;
}
}
> .empty {
margin: 0;
padding: 16px;
text-align: center;
color: var(--fg);
}
> .placeholder {
padding: 32px;
opacity: 0.3;
}
}
}
</style>

View file

@ -0,0 +1,163 @@
<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="scss" 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

@ -0,0 +1,40 @@
<template>
<component :is="'x-' + value.type" :value="value" :page="page" :script="script" :key="value.id" :h="h"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XText from './page.text.vue';
import XSection from './page.section.vue';
import XImage from './page.image.vue';
import XButton from './page.button.vue';
import XNumberInput from './page.number-input.vue';
import XTextInput from './page.text-input.vue';
import XTextareaInput from './page.textarea-input.vue';
import XSwitch from './page.switch.vue';
import XIf from './page.if.vue';
import XTextarea from './page.textarea.vue';
import XPost from './page.post.vue';
import XCounter from './page.counter.vue';
import XRadioButton from './page.radio-button.vue';
export default Vue.extend({
components: {
XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton
},
props: {
value: {
required: true
},
script: {
required: true
},
page: {
required: true
},
h: {
required: true
}
},
});
</script>

View file

@ -0,0 +1,59 @@
<template>
<div>
<mk-button class="kudkigyw" @click="click()" :primary="value.primary">{{ script.interpolate(value.text) }}</mk-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MkButton from '../ui/button.vue';
export default Vue.extend({
components: {
MkButton
},
props: {
value: {
required: true
},
script: {
required: true
}
},
methods: {
click() {
if (this.value.action === 'dialog') {
this.script.eval();
this.$root.dialog({
text: this.script.interpolate(this.value.content)
});
} else if (this.value.action === 'resetRandom') {
this.script.aiScript.updateRandomSeed(Math.random());
this.script.eval();
} else if (this.value.action === 'pushEvent') {
this.$root.api('page-push', {
pageId: this.script.page.id,
event: this.value.event,
...(this.value.var ? {
var: this.script.vars[this.value.var]
} : {})
});
this.$root.dialog({
type: 'success',
text: this.script.interpolate(this.value.message)
});
}
}
}
});
</script>
<style lang="scss" scoped>
.kudkigyw {
display: inline-block;
min-width: 200px;
max-width: 450px;
margin: 8px 0;
}
</style>

View file

@ -0,0 +1,49 @@
<template>
<div>
<mk-button class="llumlmnx" @click="click()">{{ script.interpolate(value.text) }}</mk-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MkButton from '../ui/button.vue';
export default Vue.extend({
components: {
MkButton
},
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
v: 0,
};
},
watch: {
v() {
this.script.aiScript.updatePageVar(this.value.name, this.v);
this.script.eval();
}
},
methods: {
click() {
this.v = this.v + (this.value.inc || 1);
}
}
});
</script>
<style lang="scss" scoped>
.llumlmnx {
display: inline-block;
min-width: 300px;
max-width: 450px;
margin: 8px 0;
}
</style>

View file

@ -0,0 +1,29 @@
<template>
<div v-show="script.vars[value.var]">
<x-block v-for="child in value.children" :value="child" :page="page" :script="script" :key="child.id" :h="h"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: true
},
script: {
required: true
},
page: {
required: true
},
h: {
required: true
}
},
beforeCreate() {
this.$options.components.XBlock = require('./page.block.vue').default;
},
});
</script>

View file

@ -0,0 +1,36 @@
<template>
<div class="lzyxtsnt">
<img v-if="image" :src="image.url" alt=""/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: true
},
page: {
required: true
},
},
data() {
return {
image: null,
};
},
created() {
this.image = this.page.attachedFiles.find(x => x.id === this.value.fileId);
}
});
</script>
<style lang="scss" scoped>
.lzyxtsnt {
> img {
max-width: 100%;
}
}
</style>

View file

@ -0,0 +1,44 @@
<template>
<div>
<mk-input class="kudkigyw" v-model="v" type="number">{{ script.interpolate(value.text) }}</mk-input>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MkInput from '../ui/input.vue';
export default Vue.extend({
components: {
MkInput
},
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
v: this.value.default,
};
},
watch: {
v() {
this.script.aiScript.updatePageVar(this.value.name, this.v);
this.script.eval();
}
}
});
</script>
<style lang="scss" scoped>
.kudkigyw {
display: inline-block;
min-width: 300px;
max-width: 450px;
margin: 8px 0;
}
</style>

View file

@ -0,0 +1,75 @@
<template>
<div class="ngbfujlo">
<mk-textarea class="textarea" :value="text" readonly></mk-textarea>
<mk-button primary @click="post()" :disabled="posting || posted">{{ posted ? $t('posted-from-post-form') : $t('post-from-post-form') }}</mk-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import MkTextarea from '../ui/textarea.vue';
import MkButton from '../ui/button.vue';
export default Vue.extend({
i18n,
components: {
MkTextarea,
MkButton,
},
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
text: this.script.interpolate(this.value.text),
posted: false,
posting: false,
};
},
watch: {
'script.vars': {
handler() {
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',
iconOnly: true, autoClose: true
});
});
}
}
});
</script>
<style lang="scss" scoped>
.ngbfujlo {
padding: 0 32px 32px 32px;
border: solid 2px var(--divider);
border-radius: 6px;
@media (max-width: 600px) {
padding: 0 16px 16px 16px;
> .textarea {
margin-top: 16px;
margin-bottom: 16px;
}
}
}
</style>

View file

@ -0,0 +1,36 @@
<template>
<div>
<div>{{ script.interpolate(value.title) }}</div>
<mk-radio v-for="x in value.values" v-model="v" :value="x" :key="x">{{ x }}</mk-radio>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MkRadio from '../ui/radio.vue';
export default Vue.extend({
components: {
MkRadio
},
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
v: this.value.default,
};
},
watch: {
v() {
this.script.aiScript.updatePageVar(this.value.name, this.v);
this.script.eval();
}
}
});
</script>

View file

@ -0,0 +1,58 @@
<template>
<section class="sdgxphyu">
<component :is="'h' + h">{{ value.title }}</component>
<div class="children">
<x-block v-for="child in value.children" :value="child" :page="page" :script="script" :key="child.id" :h="h + 1"/>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: true
},
script: {
required: true
},
page: {
required: true
},
h: {
required: true
}
},
beforeCreate() {
this.$options.components.XBlock = require('./page.block.vue').default;
},
});
</script>
<style lang="scss" scoped>
.sdgxphyu {
margin: 1.5em 0;
> h2 {
font-size: 1.35em;
margin: 0 0 0.5em 0;
}
> h3 {
font-size: 1em;
margin: 0 0 0.5em 0;
}
> h4 {
font-size: 1em;
margin: 0 0 0.5em 0;
}
> .children {
//padding 16px
}
}
</style>

View file

@ -0,0 +1,46 @@
<template>
<div class="hkcxmtwj">
<mk-switch v-model="v">{{ script.interpolate(value.text) }}</mk-switch>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MkSwitch from '../ui/switch.vue';
export default Vue.extend({
components: {
MkSwitch
},
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
v: this.value.default,
};
},
watch: {
v() {
this.script.aiScript.updatePageVar(this.value.name, this.v);
this.script.eval();
}
}
});
</script>
<style lang="scss" scoped>
.hkcxmtwj {
display: inline-block;
margin: 16px auto;
& + .hkcxmtwj {
margin-left: 16px;
}
}
</style>

View file

@ -0,0 +1,44 @@
<template>
<div>
<mk-input class="kudkigyw" v-model="v" type="text">{{ script.interpolate(value.text) }}</mk-input>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MkInput from '../ui/input.vue';
export default Vue.extend({
components: {
MkInput
},
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
v: this.value.default,
};
},
watch: {
v() {
this.script.aiScript.updatePageVar(this.value.name, this.v);
this.script.eval();
}
}
});
</script>
<style lang="scss" scoped>
.kudkigyw {
display: inline-block;
min-width: 300px;
max-width: 450px;
margin: 8px 0;
}
</style>

View file

@ -0,0 +1,65 @@
<template>
<div class="mrdgzndn">
<mfm :text="text" :is-note="false" :i="$store.state.i" :key="text"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url" class="url"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { parse } from '../../../mfm/parse';
import { unique } from '../../../prelude/array';
export default Vue.extend({
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
text: this.script.interpolate(this.value.text),
};
},
computed: {
urls(): string[] {
if (this.text) {
const ast = parse(this.text);
// TODO: URL調
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 [];
}
}
},
watch: {
'script.vars': {
handler() {
this.text = this.script.interpolate(this.value.text);
},
deep: true
}
},
});
</script>
<style lang="scss" scoped>
.mrdgzndn {
&:not(:first-child) {
margin-top: 0.5em;
}
&:not(:last-child) {
margin-bottom: 0.5em;
}
> .url {
margin: 0.5em 0;
}
}
</style>

View file

@ -0,0 +1,35 @@
<template>
<div>
<mk-textarea v-model="v">{{ script.interpolate(value.text) }}</mk-textarea>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import MkTextarea from '../ui/textarea.vue';
export default Vue.extend({
components: {
MkTextarea
},
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
v: this.value.default,
};
},
watch: {
v() {
this.script.aiScript.updatePageVar(this.value.name, this.v);
this.script.eval();
}
}
});
</script>

View file

@ -0,0 +1,35 @@
<template>
<mk-textarea :value="text" readonly></mk-textarea>
</template>
<script lang="ts">
import Vue from 'vue';
import MkTextarea from '../ui/textarea.vue';
export default Vue.extend({
components: {
MkTextarea
},
props: {
value: {
required: true
},
script: {
required: true
}
},
data() {
return {
text: this.script.interpolate(this.value.text),
};
},
watch: {
'script.vars': {
handler() {
this.text = this.script.interpolate(this.value.text);
},
deep: true
}
}
});
</script>

View file

@ -0,0 +1,230 @@
<template>
<div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }">
<header v-if="showTitle">
<div class="title">{{ page.title }}</div>
</header>
<div v-if="script">
<x-block v-for="child in page.content" :value="child" @input="v => updateBlock(v)" :page="page" :script="script" :key="child.id" :h="2"/>
</div>
<footer v-if="showFooter">
<small>@{{ page.user.username }}</small>
<template v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId">
<router-link :to="`/my/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link>
<a v-if="$store.state.i.pinnedPageId === page.id" @click="pin(false)">{{ $t('unpin-this-page') }}</a>
<a v-else @click="pin(true)">{{ $t('pin-this-page') }}</a>
</template>
<router-link :to="`./${page.name}/view-source`">{{ $t('view-source') }}</router-link>
<div class="like">
<button @click="unlike()" v-if="page.isLiked" :title="$t('unlike')"><fa :icon="faHeartS"/></button>
<button @click="like()" v-else :title="$t('like')"><fa :icon="faHeart"/></button>
<span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span>
</div>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../i18n';
import { faHeart as faHeartS } from '@fortawesome/free-solid-svg-icons';
import { faHeart } from '@fortawesome/free-regular-svg-icons';
import XBlock from './page.block.vue';
import { ASEvaluator } from '../../scripts/aiscript/evaluator';
import { collectPageVars } from '../../scripts/collect-page-vars';
import { url } from '../../config';
class Script {
public aiScript: ASEvaluator;
private onError: any;
public vars: Record<string, any>;
public page: Record<string, any>;
constructor(page, aiScript, onError) {
this.page = page;
this.aiScript = aiScript;
this.onError = onError;
this.eval();
}
public eval() {
try {
this.vars = this.aiScript.evaluateVars();
} catch (e) {
this.onError(e);
}
}
public interpolate(str: string) {
if (str == null) return null;
return str.replace(/{(.+?)}/g, match => {
const v = this.vars[match.slice(1, -1).trim()];
return v == null ? 'NULL' : v.toString();
});
}
}
export default Vue.extend({
i18n,
components: {
XBlock
},
props: {
page: {
type: Object,
required: true
},
showTitle: {
type: Boolean,
required: false,
default: true
},
showFooter: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
script: null,
faHeartS, faHeart
};
},
created() {
const pageVars = this.getPageVars();
this.script = new Script(this.page, new ASEvaluator(this.page.variables, pageVars, {
randomSeed: Math.random(),
user: this.page.user,
visitor: this.$store.state.i,
page: this.page,
url: url
}), e => {
console.dir(e);
});
},
methods: {
getPageVars() {
return collectPageVars(this.page.content);
},
like() {
this.$root.api('pages/like', {
pageId: this.page.id,
}).then(() => {
this.page.isLiked = true;
this.page.likedCount++;
});
},
unlike() {
this.$root.api('pages/unlike', {
pageId: this.page.id,
}).then(() => {
this.page.isLiked = false;
this.page.likedCount--;
});
},
pin(pin) {
this.$root.api('i/update', {
pinnedPageId: pin ? this.page.id : null,
}).then(() => {
this.$root.dialog({
type: 'success',
splash: true
});
});
}
}
});
</script>
<style lang="scss" scoped>
.iroscrza {
&.serif {
> div {
font-family: serif;
}
}
&.center {
text-align: center;
}
> header {
> .title {
z-index: 1;
margin: 0;
padding: 16px 32px;
font-size: 20px;
font-weight: bold;
color: var(--text);
box-shadow: 0 var(--lineWidth) rgba(#000, 0.07);
@media (max-width: 600px) {
padding: 16px 32px;
font-size: 20px;
}
@media (max-width: 400px) {
padding: 10px 20px;
font-size: 16px;
}
}
}
> div {
color: var(--text);
padding: 24px 32px;
font-size: 16px;
@media (max-width: 600px) {
padding: 24px 32px;
font-size: 16px;
}
@media (max-width: 400px) {
padding: 20px 20px;
font-size: 15px;
}
}
> footer {
color: var(--text);
padding: 0 32px 28px 32px;
@media (max-width: 600px) {
padding: 0 32px 28px 32px;
}
@media (max-width: 400px) {
padding: 0 20px 20px 20px;
font-size: 14px;
}
> small {
display: block;
opacity: 0.5;
}
> a {
font-size: 90%;
}
> a + a {
margin-left: 8px;
}
> .like {
margin-top: 16px;
}
}
}
</style>

View file

@ -0,0 +1,218 @@
<template>
<div class="zmdxowus">
<p class="caution" v-if="choices.length < 2">
<fa :icon="faExclamationTriangle"/>{{ $t('_poll.noOnlyOneChoice') }}
</p>
<ul ref="choices">
<li v-for="(choice, i) in choices" :key="i">
<mk-input class="input" :value="choice" @input="onInput(i, $event)">
<span>{{ $t('_poll.choiceN', { n: i + 1 }) }}</span>
</mk-input>
<button @click="remove(i)" class="_button">
<fa :icon="faTimes"/>
</button>
</li>
</ul>
<mk-button class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</mk-button>
<mk-button class="add" v-else disabled>{{ $t('_poll.noMore') }}</mk-button>
<section>
<mk-switch v-model="multiple">{{ $t('_poll.canMultipleVote') }}</mk-switch>
<div>
<mk-select v-model="expiration">
<template #label>{{ $t('_poll.expiration') }}</template>
<option value="infinite">{{ $t('_poll.infinite') }}</option>
<option value="at">{{ $t('_poll.at') }}</option>
<option value="after">{{ $t('_poll.after') }}</option>
</mk-select>
<section v-if="expiration === 'at'">
<mk-input v-model="atDate" type="date" class="input">
<span>{{ $t('_poll.deadlineDate') }}</span>
</mk-input>
<mk-input v-model="atTime" type="time" class="input">
<span>{{ $t('_poll.deadlineTime') }}</span>
</mk-input>
</section>
<section v-if="expiration === 'after'">
<mk-input v-model="after" type="number" class="input">
<span>{{ $t('_poll.duration') }}</span>
</mk-input>
<mk-select v-model="unit">
<option value="second">{{ $t('_time.second') }}</option>
<option value="minute">{{ $t('_time.minute') }}</option>
<option value="hour">{{ $t('_time.hour') }}</option>
<option value="day">{{ $t('_time.day') }}</option>
</mk-select>
</section>
</div>
</section>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faExclamationTriangle, faTimes } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import { erase } from '../../prelude/array';
import { addTimespan } from '../../prelude/time';
import { formatDateTimeString } from '../../misc/format-time-string';
import MkInput from './ui/input.vue';
import MkSelect from './ui/select.vue';
import MkSwitch from './ui/switch.vue';
import MkButton from './ui/button.vue';
export default Vue.extend({
i18n,
components: {
MkInput,
MkSelect,
MkSwitch,
MkButton,
},
data() {
return {
choices: ['', ''],
multiple: false,
expiration: 'infinite',
atDate: formatDateTimeString(addTimespan(new Date(), 1, 'days'), 'yyyy-MM-dd'),
atTime: '00:00',
after: 0,
unit: 'second',
faExclamationTriangle, faTimes
};
},
watch: {
choices() {
this.$emit('updated');
}
},
methods: {
onInput(i, e) {
Vue.set(this.choices, i, e);
},
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);
},
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="scss" 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: flex;
margin: 8px 0;
padding: 0;
width: 100%;
> .input {
flex: 1;
margin-top: 16px;
margin-bottom: 0;
}
> button {
width: 32px;
padding: 4px 0;
}
}
}
> .add {
margin: 8px 0 0 0;
z-index: 1;
}
> 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;
}
> .input {
flex: 1 0 auto;
}
}
}
}
}
}
</style>

View file

@ -0,0 +1,174 @@
<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 }">
<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span>
<template v-if="choice.isVoted"><fa :icon="faCheck"/></template>
<mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/>
<span class="votes" v-if="showResult">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span>
</span>
</li>
</ul>
<p>
<span>{{ $t('_poll.totalVotes', { n: total }) }}</span>
<span> · </span>
<a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $t('_poll.vote') : $t('_poll.showResult') }}</a>
<span v-if="isVoted">{{ $t('_poll.voted') }}</span>
<span v-else-if="closed">{{ $t('_poll.closed') }}</span>
<span v-if="remaining > 0"> · {{ timer }}</span>
</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import { sum } from '../../prelude/array';
export default Vue.extend({
i18n,
props: {
note: {
type: Object,
required: true
}
},
data() {
return {
remaining: -1,
showResult: false,
faCheck
};
},
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 ? '_poll.remainingDays' :
this.remaining > 3600 ? '_poll.remainingHours' :
this.remaining > 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', {
s: Math.floor(this.remaining % 60),
m: Math.floor(this.remaining / 60) % 60,
h: Math.floor(this.remaining / 3600) % 24,
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="scss" scoped>
.mk-poll {
> ul {
display: block;
margin: 0;
padding: 0;
list-style: none;
> li {
display: block;
position: relative;
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(--accent);
transition: width 1s ease;
}
> span {
position: relative;
> [data-icon] {
margin-right: 4px;
}
> .votes {
margin-left: 4px;
}
}
}
}
> p {
color: var(--fg);
a {
color: inherit;
}
}
&[data-done] {
> ul > li {
cursor: default;
&:hover {
background: transparent;
}
&:active {
background: transparent;
}
}
}
}
</style>

View file

@ -0,0 +1,147 @@
<template>
<div class="mk-popup">
<transition name="bg-fade" appear>
<div class="bg" ref="bg" @click="close()" v-if="show"></div>
</transition>
<transition name="popup" appear @after-leave="() => { $emit('closed'); destroyDom(); }">
<div class="content" :class="{ fixed }" ref="content" v-if="show" :style="{ width: width ? width + 'px' : 'auto' }"><slot></slot></div>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
source: {
required: true
},
noCenter: {
type: Boolean,
required: false
},
fixed: {
type: Boolean,
required: false
},
width: {
type: Number,
required: false
}
},
data() {
return {
show: true,
};
},
mounted() {
this.$nextTick(() => {
const popover = this.$refs.content as any;
const rect = this.source.getBoundingClientRect();
const width = popover.offsetWidth;
const height = popover.offsetHeight;
let left;
let top;
if (this.$root.isMobile && !this.noCenter) {
const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2);
const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.source.offsetHeight / 2);
left = (x - (width / 2));
top = (y - (height / 2));
popover.style.transformOrigin = 'center';
} else {
const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2);
const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.source.offsetHeight;
left = (x - (width / 2));
top = y;
}
if (this.fixed) {
if (left + width > window.innerWidth) {
left = window.innerWidth - width;
popover.style.transformOrigin = 'center';
}
if (top + height > window.innerHeight) {
top = window.innerHeight - height;
popover.style.transformOrigin = 'center';
}
} else {
if (left + width - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - width + window.pageXOffset;
popover.style.transformOrigin = 'center';
}
if (top + height - window.pageYOffset > window.innerHeight) {
top = window.innerHeight - height + window.pageYOffset;
popover.style.transformOrigin = 'center';
}
}
if (top < 0) {
top = 0;
}
if (left < 0) {
left = 0;
}
popover.style.left = left + 'px';
popover.style.top = top + 'px';
});
},
methods: {
close() {
this.show = false;
(this.$refs.bg as any).style.pointerEvents = 'none';
(this.$refs.content as any).style.pointerEvents = 'none';
}
}
});
</script>
<style lang="scss" scoped>
.popup-enter-active, .popup-leave-active {
transition: opacity 0.3s, transform 0.3s !important;
}
.popup-enter, .popup-leave-to {
opacity: 0;
transform: scale(0.9);
}
.bg-fade-enter-active, .bg-fade-leave-active {
transition: opacity 0.3s !important;
}
.bg-fade-enter, .bg-fade-leave-to {
opacity: 0;
}
.mk-popup {
> .bg {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
width: 100%;
height: 100%;
background: var(--modalBg)
}
> .content {
position: absolute;
z-index: 10001;
background: var(--panel);
border-radius: 4px;
box-shadow: 0 3px 12px rgba(27, 31, 35, 0.15);
overflow: hidden;
transform-origin: center top;
&.fixed {
position: fixed;
}
}
}
</style>

View file

@ -0,0 +1,158 @@
<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"/>
<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 { faTimesCircle, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import { faExclamationTriangle, faICursor } from '@fortawesome/free-solid-svg-icons';
import XFileThumbnail from './drive-file-thumbnail.vue'
export default Vue.extend({
i18n,
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;
this.$parent.updateMedia(file);
});
},
async rename(file) {
const { canceled, result } = await this.$root.dialog({
title: this.$t('enterFileName'),
input: {
default: file.name
},
allowEmpty: false
});
if (canceled) return;
this.$root.api('drive/files/update', {
fileId: file.id,
name: result
}).then(() => {
file.name = result;
this.$parent.updateMedia(file);
});
},
showFileMenu(file, ev: MouseEvent) {
this.$root.menu({
items: [{
text: this.$t('renameFile'),
icon: faICursor,
action: () => { this.rename(file) }
}, {
text: file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'),
icon: file.isSensitive ? faEyeSlash : faEye,
action: () => { this.toggleSensitive(file) }
}, {
text: this.$t('attachCancel'),
icon: faTimesCircle,
action: () => { this.detachMedia(file.id) }
}],
source: ev.currentTarget || ev.target
});
}
}
});
</script>
<style lang="scss" scoped>
.skeikyzd {
padding: 4px;
position: relative;
> .files {
display: flex;
flex-wrap: wrap;
> div {
position: relative;
width: 64px;
height: 64px;
margin: 4px;
cursor: move;
&:hover > .remove {
display: block;
}
> .thumbnail {
width: 100%;
height: 100%;
z-index: 1;
color: var(--fg);
}
> .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;
}
}
</style>

View file

@ -0,0 +1,157 @@
<template>
<div class="ulveipglmagnxfgvitaxyszerjwiqmwl">
<transition name="form-fade" appear>
<div class="bg" ref="bg" v-if="show" @click="close()"></div>
</transition>
<div class="main" ref="main" @click.self="close()" @keydown="onKeydown">
<transition name="form" appear
@after-leave="destroyDom"
>
<x-post-form ref="form"
v-if="show"
:reply="reply"
:renote="renote"
:mention="mention"
:specified="specified"
:initial-text="initialText"
:initial-note="initialNote"
:instant="instant"
@posted="onPosted"
@cancel="onCanceled"/>
</transition>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XPostForm from './post-form.vue';
export default Vue.extend({
components: {
XPostForm
},
props: {
reply: {
type: Object,
required: false
},
renote: {
type: Object,
required: false
},
mention: {
type: Object,
required: false
},
specified: {
type: Object,
required: false
},
initialText: {
type: String,
required: false
},
initialNote: {
type: Object,
required: false
},
instant: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
show: true
};
},
methods: {
focus() {
this.$refs.form.focus();
},
close() {
this.show = false;
(this.$refs.bg as any).style.pointerEvents = 'none';
(this.$refs.main as any).style.pointerEvents = 'none';
},
onPosted() {
this.$emit('posted');
this.close();
},
onCanceled() {
this.$emit('cancel');
this.close();
},
onKeydown(e) {
if (e.which === 27) { // Esc
e.preventDefault();
e.stopPropagation();
this.close();
}
},
}
});
</script>
<style lang="scss" scoped>
.form-enter-active, .form-leave-active {
transition: opacity 0.3s, transform 0.3s !important;
}
.form-enter, .form-leave-to {
opacity: 0;
transform: scale(0.9);
}
.form-fade-enter-active, .form-fade-leave-active {
transition: opacity 0.3s !important;
}
.form-fade-enter, .form-fade-leave-to {
opacity: 0;
}
.ulveipglmagnxfgvitaxyszerjwiqmwl {
> .bg {
display: block;
position: fixed;
z-index: 10000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(#000, 0.7);
}
> .main {
display: block;
position: fixed;
z-index: 10000;
top: 32px;
left: 0;
right: 0;
height: calc(100% - 64px);
width: 500px;
max-width: calc(100% - 16px);
overflow: auto;
margin: 0 auto 0 auto;
@media (max-width: 550px) {
top: 16px;
height: calc(100% - 32px);
}
@media (max-width: 520px) {
top: 8px;
height: calc(100% - 16px);
}
}
}
</style>

View file

@ -0,0 +1,747 @@
<template>
<div class="gafaadew"
@dragover.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.stop="onDrop"
>
<header>
<button class="cancel _button" @click="cancel"><fa :icon="faTimes"/></button>
<div>
<span class="text-count" :class="{ over: trimmedLength(text) > 500 }">{{ 500 - trimmedLength(text) }}</span>
<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}</button>
</div>
</header>
<div class="form">
<x-note-preview class="preview" v-if="reply" :note="reply"/>
<x-note-preview class="preview" v-if="renote" :note="renote"/>
<div class="with-quote" v-if="quoteId"><fa icon="quote-left"/> {{ $t('@.post-form.quote-attached') }}<button @click="quoteId = null"><fa icon="times"/></button></div>
<div v-if="visibility === 'specified'" class="to-specified">
<span style="margin-right: 8px;">{{ $t('recipient') }}</span>
<div class="visibleUsers">
<span v-for="u in visibleUsers">
<mk-acct :user="u"/>
<button class="_button" @click="removeVisibleUser(u)"><fa :icon="faTimes"/></button>
</span>
<button @click="addVisibleUser" class="_buttonPrimary"><fa :icon="faPlus" fixed-width/></button>
</div>
</div>
<input v-show="useCw" ref="cw" v-model="cw" :placeholder="$t('annotation')" v-autocomplete="{ model: 'cw' }">
<textarea v-model="text" ref="text" :disabled="posting" :placeholder="placeholder" v-autocomplete="{ model: 'text' }" @keydown="onKeydown" @paste="onPaste"></textarea>
<x-post-form-attaches class="attaches" :files="files"/>
<x-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="onPollUpdate()"/>
<x-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/>
<footer>
<button class="_button" @click="chooseFileFrom"><fa :icon="faPhotoVideo"/></button>
<button class="_button" @click="poll = !poll"><fa :icon="faChartPie"/></button>
<button class="_button" @click="useCw = !useCw"><fa :icon="faEyeSlash"/></button>
<button class="_button" @click="insertMention"><fa :icon="faAt"/></button>
<button class="_button" @click="insertEmoji"><fa :icon="faLaughSquint"/></button>
<button class="_button" @click="setVisibility" ref="visibilityButton">
<span v-if="visibility === 'public'"><fa :icon="faGlobe"/></span>
<span v-if="visibility === 'home'"><fa :icon="faHome"/></span>
<span v-if="visibility === 'followers'"><fa :icon="faUnlock"/></span>
<span v-if="visibility === 'specified'"><fa :icon="faEnvelope"/></span>
</button>
</footer>
<input ref="file" class="file _button" type="file" multiple="multiple" @change="onChangeFile"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faTimes, faUpload, faChartPie, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt } from '@fortawesome/free-solid-svg-icons';
import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons';
import insertTextAtCursor from 'insert-text-at-cursor';
import { length } from 'stringz';
import { toASCII } from 'punycode';
import i18n from '../i18n';
import MkVisibilityChooser from './visibility-chooser.vue';
import MkUserSelect from './user-select.vue';
import XNotePreview from './note-preview.vue';
import XEmojiPicker from './emoji-picker.vue';
import { parse } from '../../mfm/parse';
import { host, url } from '../config';
import { erase, unique } from '../../prelude/array';
import extractMentions from '../../misc/extract-mentions';
import getAcct from '../../misc/acct/render';
import { formatTimeString } from '../../misc/format-time-string';
import { selectDriveFile } from '../scripts/select-drive-file';
export default Vue.extend({
i18n,
components: {
XNotePreview,
XUploader: () => import('./uploader.vue').then(m => m.default),
XPostFormAttaches: () => import('./post-form-attaches.vue').then(m => m.default),
XPollEditor: () => import('./poll-editor.vue').then(m => m.default)
},
props: {
reply: {
type: Object,
required: false
},
renote: {
type: Object,
required: false
},
mention: {
type: Object,
required: false
},
specified: {
type: Object,
required: false
},
initialText: {
type: String,
required: false
},
initialNote: {
type: Object,
required: false
},
instant: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
posting: false,
text: '',
files: [],
uploadings: [],
poll: false,
pollChoices: [],
pollMultiple: false,
pollExpiration: [],
useCw: false,
cw: null,
visibility: 'public',
visibleUsers: [],
autocomplete: null,
draghover: false,
quoteId: null,
recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
faTimes, faUpload, faChartPie, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt
};
},
computed: {
draftId(): string {
return this.renote
? `renote:${this.renote.id}`
: this.reply
? `reply:${this.reply.id}`
: 'note';
},
placeholder(): string {
const xs = [
this.$t('_postForm._placeholders.a'),
this.$t('_postForm._placeholders.b'),
this.$t('_postForm._placeholders.c'),
this.$t('_postForm._placeholders.d'),
this.$t('_postForm._placeholders.e'),
this.$t('_postForm._placeholders.f')
];
const x = xs[Math.floor(Math.random() * xs.length)];
return this.renote
? this.$t('_postForm.quotePlaceholder')
: this.reply
? this.$t('_postForm.replyPlaceholder')
: x;
},
submitText(): string {
return this.renote
? this.$t('renote')
: this.reply
? this.$t('reply')
: this.$t('_postForm.post');
},
canPost(): boolean {
return !this.posting &&
(1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) &&
(length(this.text.trim()) <= 500) &&
(!this.poll || this.pollChoices.length >= 2);
}
},
mounted() {
if (this.initialText) {
this.text = this.initialText;
}
if (this.mention) {
this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
this.text += ' ';
}
if (this.reply && this.reply.user.host != null) {
this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `;
}
if (this.reply && this.reply.text != null) {
const ast = parse(this.reply.text);
for (const x of extractMentions(ast)) {
const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
//
if (this.$store.state.i.username == x.username && x.host == null) continue;
if (this.$store.state.i.username == x.username && x.host == host) continue;
//
if (this.text.indexOf(`${mention} `) != -1) continue;
this.text += `${mention} `;
}
}
//
this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility);
//
if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
this.visibility = this.reply.visibility;
if (this.reply.visibility === 'specified') {
this.$root.api('users/show', {
userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$store.state.i.id && uid !== this.reply.userId)
}).then(users => {
this.visibleUsers.push(...users);
});
if (this.reply.userId !== this.$store.state.i.id) {
this.$root.api('users/show', { userId: this.reply.userId }).then(user => {
this.visibleUsers.push(user);
});
}
}
}
if (this.specified) {
this.visibility = 'specified';
this.visibleUsers.push(this.specified);
}
// keep cw when reply
if (this.$store.state.settings.keepCw && this.reply && this.reply.cw) {
this.useCw = true;
this.cw = this.reply.cw;
}
this.focus();
this.$nextTick(() => {
this.focus();
});
this.$nextTick(() => {
// 稿
if (!this.instant && !this.mention) {
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId];
if (draft) {
this.text = draft.data.text;
this.files = (draft.data.files || []).filter(e => e);
if (draft.data.poll) {
this.poll = true;
this.$nextTick(() => {
(this.$refs.poll as any).set(draft.data.poll);
});
}
this.$emit('change-attached-files', this.files);
}
}
//
if (this.initialNote) {
const init = this.initialNote;
this.text = init.text ? init.text : '';
this.files = init.files;
this.cw = init.cw;
this.useCw = init.cw != null;
if (init.poll) {
this.poll = true;
this.$nextTick(() => {
(this.$refs.poll as any).set({
choices: init.poll.choices.map(c => c.text),
multiple: init.poll.multiple
});
});
}
this.visibility = init.visibility;
this.quoteId = init.renote ? init.renote.id : null;
}
this.$nextTick(() => this.watch());
});
},
methods: {
watch() {
this.$watch('text', () => this.saveDraft());
this.$watch('poll', () => this.saveDraft());
this.$watch('files', () => this.saveDraft());
},
trimmedLength(text: string) {
return length(text.trim());
},
addTag(tag: string) {
insertTextAtCursor(this.$refs.text, ` #${tag} `);
},
focus() {
(this.$refs.text as any).focus();
},
chooseFileFrom(ev) {
this.$root.menu({
items: [{
type: 'label',
text: this.$t('attachFile'),
}, {
text: this.$t('upload'),
icon: faUpload,
action: () => { this.chooseFileFromPc() }
}, {
text: this.$t('fromDrive'),
icon: faCloud,
action: () => { this.chooseFileFromDrive() }
}, {
text: this.$t('fromUrl'),
icon: faLink,
action: () => { this.chooseFileFromUrl() }
}],
source: ev.currentTarget || ev.target
});
},
chooseFileFromPc() {
(this.$refs.file as any).click();
},
chooseFileFromDrive() {
selectDriveFile(this.$root, true).then(files => {
for (const file of files) {
this.attachMedia(file);
}
});
},
attachMedia(driveFile) {
this.files.push(driveFile);
},
detachMedia(id) {
this.files = this.files.filter(x => x.id != id);
},
updateMedia(file) {
Vue.set(this.files, this.files.findIndex(x => x.id === file.id), file);
},
onChangeFile() {
for (const x of Array.from((this.$refs.file as any).files)) this.upload(x);
},
upload(file: File, name?: string) {
(this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name);
},
onChangeUploadings(uploads) {
this.$emit('change-uploadings', uploads);
},
onPollUpdate() {
const got = this.$refs.poll.get();
this.pollChoices = got.choices;
this.pollMultiple = got.multiple;
this.pollExpiration = [got.expiration, got.expiresAt || got.expiredAfter];
this.saveDraft();
},
setVisibility() {
const w = this.$root.new(MkVisibilityChooser, {
source: this.$refs.visibilityButton,
currentVisibility: this.visibility
});
w.$once('chosen', v => {
this.applyVisibility(v);
});
},
applyVisibility(v: string) {
this.visibility = v;
},
addVisibleUser() {
const vm = this.$root.new(MkUserSelect, {});
vm.$once('selected', user => {
this.visibleUsers.push(user);
});
},
removeVisibleUser(user) {
this.visibleUsers = erase(user, this.visibleUsers);
},
clear() {
this.text = '';
this.files = [];
this.poll = false;
this.quoteId = null;
this.$emit('change-attached-files', this.files);
},
onKeydown(e) {
if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
},
async onPaste(e: ClipboardEvent) {
for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
if (item.kind == 'file') {
const file = item.getAsFile();
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
this.upload(file, formatted);
}
}
const paste = e.clipboardData.getData('text');
if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
e.preventDefault();
this.$root.dialog({
type: 'info',
text: this.$t('@.post-form.quote-question'),
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) {
insertTextAtCursor(this.$refs.text, paste);
return;
}
this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
});
}
},
onDragover(e) {
if (!e.dataTransfer.items[0]) return;
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
if (isFile || isDriveFile) {
e.preventDefault();
this.draghover = true;
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
}
},
onDragenter(e) {
this.draghover = true;
},
onDragleave(e) {
this.draghover = false;
},
onDrop(e): void {
this.draghover = false;
//
if (e.dataTransfer.files.length > 0) {
e.preventDefault();
for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
return;
}
//#region
const driveFile = e.dataTransfer.getData('mk_drive_file');
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.files.push(file);
this.$emit('change-attached-files', this.files);
e.preventDefault();
}
//#endregion
},
saveDraft() {
if (this.instant) return;
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
data[this.draftId] = {
updatedAt: new Date(),
data: {
text: this.text,
files: this.files,
poll: this.poll && this.$refs.poll ? (this.$refs.poll as any).get() : undefined
}
};
localStorage.setItem('drafts', JSON.stringify(data));
},
deleteDraft() {
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
delete data[this.draftId];
localStorage.setItem('drafts', JSON.stringify(data));
},
post() {
this.posting = true;
this.$root.api('notes/create', {
text: this.text == '' ? undefined : this.text,
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
replyId: this.reply ? this.reply.id : undefined,
renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
poll: this.poll ? (this.$refs.poll as any).get() : undefined,
cw: this.useCw ? this.cw || '' : undefined,
visibility: this.visibility,
visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
viaMobile: this.$root.isMobile
}).then(data => {
this.clear();
this.deleteDraft();
this.$emit('posted');
}).catch(err => {
}).then(() => {
this.posting = false;
});
if (this.text && this.text != '') {
const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag);
const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
}
},
cancel() {
this.$emit('cancel');
},
insertMention() {
const vm = this.$root.new(MkUserSelect, {});
vm.$once('selected', user => {
insertTextAtCursor(this.$refs.text, getAcct(user) + ' ');
});
},
insertEmoji(ev) {
const vm = this.$root.new(XEmojiPicker, {
source: ev.currentTarget || ev.target
}).$once('chosen', emoji => {
insertTextAtCursor(this.$refs.text, emoji);
vm.close();
});
}
}
});
</script>
<style lang="scss" scoped>
.gafaadew {
background: var(--panel);
border-radius: var(--radius);
box-shadow: 0 0 2px rgba(#000, 0.1);
> header {
z-index: 1000;
height: 66px;
@media (max-width: 500px) {
height: 50px;
}
> .cancel {
padding: 0;
font-size: 20px;
width: 64px;
line-height: 66px;
@media (max-width: 500px) {
width: 50px;
line-height: 50px;
}
}
> div {
position: absolute;
top: 0;
right: 0;
> .text-count {
line-height: 66px;
@media (max-width: 500px) {
line-height: 50px;
}
}
> .submit {
margin: 16px;
padding: 0 16px;
line-height: 34px;
vertical-align: bottom;
border-radius: 4px;
@media (max-width: 500px) {
margin: 8px;
}
&:disabled {
opacity: 0.7;
}
}
}
}
> .form {
max-width: 500px;
margin: 0 auto;
> .preview {
padding: 16px;
}
> .with-quote {
margin: 0 0 8px 0;
color: var(--accent);
> button {
padding: 4px 8px;
color: var(--accentAlpha04);
&:hover {
color: var(--accentAlpha06);
}
&:active {
color: var(--accentDarken30);
}
}
}
> .to-specified {
padding: 6px 24px;
margin-bottom: 8px;
overflow: auto;
white-space: nowrap;
@media (max-width: 500px) {
padding: 6px 16px;
}
> .visibleUsers {
display: inline;
top: -1px;
font-size: 14px;
> button {
padding: 4px;
border-radius: 8px;
}
> span {
margin-right: 14px;
padding: 8px 0 8px 8px;
border-radius: 8px;
background: var(--nwjktjjq);
> button {
padding: 4px 8px;
}
}
}
}
> input {
z-index: 1;
}
> input,
> textarea {
display: block;
box-sizing: border-box;
padding: 0 24px;
margin: 0;
width: 100%;
font-size: 16px;
border: none;
border-radius: 0;
background: transparent;
color: var(--fg);
font-family: initial;
@media (max-width: 500px) {
padding: 0 16px;
}
&:focus {
outline: none;
}
&:disabled {
opacity: 0.5;
}
}
> textarea {
max-width: 100%;
min-width: 100%;
min-height: 90px;
@media (max-width: 500px) {
min-height: 80px;
}
}
> .mk-uploader {
margin: 8px 0 0 0;
padding: 8px;
}
> .file {
display: none;
}
> footer {
padding: 0 16px 16px 16px;
@media (max-width: 500px) {
padding: 0 8px 8px 8px;
}
> * {
display: inline-block;
padding: 0;
margin: 0;
font-size: 16px;
width: 48px;
height: 48px;
border-radius: 6px;
&:hover {
background: var(--geavgsxy);
}
}
}
}
}
</style>

View file

@ -0,0 +1,32 @@
<template>
<mk-emoji :emoji="reaction.startsWith(':') ? null : reaction" :name="reaction.startsWith(':') ? reaction.substr(1, reaction.length - 2) : null" :is-reaction="true" :custom-emojis="customEmojis" :normal="true" :no-style="noStyle"/>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
export default Vue.extend({
i18n,
props: {
reaction: {
type: String,
required: true
},
noStyle: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
customEmojis: []
};
},
created() {
this.$root.getMeta().then(meta => {
if (meta && meta.emojis) this.customEmojis = meta.emojis;
});
},
});
</script>

View file

@ -0,0 +1,229 @@
<template>
<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap">
<div class="rdfaahpb">
<transition-group
name="reaction-fade"
tag="div"
class="buttons"
ref="buttons"
:class="{ showFocus }"
:css="false"
@before-enter="beforeEnter"
@enter="enter"
mode="out-in"
appear
>
<button class="_button" v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" :data-index="i" :tabindex="i + 1" :title="/^[a-z]+$/.test(reaction) ? $t('@.reactions.' + reaction) : reaction"><x-reaction-icon :reaction="reaction"/></button>
</transition-group>
<input class="text" v-model="text" :placeholder="$t('enterEmoji')" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }">
</div>
</x-popup>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import { emojiRegex } from '../../misc/emoji-regex';
import XReactionIcon from './reaction-icon.vue';
import XPopup from './popup.vue';
export default Vue.extend({
i18n,
components: {
XPopup,
XReactionIcon,
},
props: {
source: {
required: true
},
reactions: {
required: false
},
showFocus: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
rs: this.reactions || this.$store.state.settings.reactions,
text: null,
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(this.rs[0]),
'2': () => this.react(this.rs[1]),
'3': () => this.react(this.rs[2]),
'4': () => this.react(this.rs[3]),
'5': () => this.react(this.rs[4]),
'6': () => this.react(this.rs[5]),
'7': () => this.react(this.rs[6]),
'8': () => this.react(this.rs[7]),
'9': () => this.react(this.rs[8]),
'0': () => this.react(this.rs[9]),
};
},
},
watch: {
focus(i) {
this.$refs.buttons.children[i].elm.focus();
}
},
mounted() {
this.focus = 0;
},
methods: {
close() {
this.$refs.popup.close();
},
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();
},
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.children[this.focus].elm.click();
},
beforeEnter(el) {
el.style.opacity = 0;
el.style.transform = 'scale(0.7)';
},
enter(el, done) {
el.style.transition = [getComputedStyle(el).transition, 'transform 1s cubic-bezier(0.23, 1, 0.32, 1)', 'opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1)'].filter(x => x != '').join(',');
setTimeout(() => {
el.style.opacity = 1;
el.style.transform = 'scale(1)';
setTimeout(done, 1000);
}, 0 * el.dataset.index)
},
}
});
</script>
<style lang="scss" scoped>
.rdfaahpb {
> .buttons {
padding: 6px 6px 0 6px;
width: 212px;
box-sizing: border-box;
text-align: center;
@media (max-width: 1025px) {
padding: 8px 8px 0 8px;
width: 256px;
}
&.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(--focus);
border-radius: 4px;
}
}
}
> button {
padding: 0;
width: 40px;
height: 40px;
font-size: 24px;
border-radius: 2px;
@media (max-width: 1025px) {
width: 48px;
height: 48px;
font-size: 26px;
}
> * {
height: 1em;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&:active {
background: var(--accent);
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
}
}
}
> .text {
width: 208px;
padding: 8px;
margin: 0 0 6px 0;
box-sizing: border-box;
text-align: center;
font-size: 16px;
outline: none;
border: none;
background: transparent;
color: var(--fg);
@media (max-width: 1025px) {
width: 256px;
margin: 4px 0 8px 0;
}
}
}
</style>

View file

@ -0,0 +1,117 @@
<template>
<transition name="zoom-in-top">
<div class="buebdbiu" ref="popover" v-if="show">
<template v-if="users.length <= 10">
<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>
</template>
<template v-if="10 < users.length">
<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 slot="omitted">+{{ count - 10 }}</span>
</template>
</div>
</transition>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
export default Vue.extend({
i18n,
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="scss" scoped>
.buebdbiu {
z-index: 10000;
display: block;
position: absolute;
max-width: 240px;
font-size: 0.8em;
padding: 6px 8px;
background: var(--panel);
text-align: center;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,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(0,0,0,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 var(--panel);
border-left: solid 14px transparent;
}
}
</style>

View file

@ -0,0 +1,167 @@
<template>
<span
class="reaction _button"
:class="{ reacted: note.myReaction == reaction }"
@click="toggleReaction(reaction)"
v-if="count > 0"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
ref="reaction"
>
<x-reaction-icon :reaction="reaction" ref="icon"/>
<span>{{ count }}</span>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import XDetails from './reactions-viewer.details.vue';
import XReactionIcon from './reaction-icon.vue';
export default Vue.extend({
components: {
XReactionIcon
},
props: {
reaction: {
type: String,
required: true,
},
count: {
type: Number,
required: true,
},
isInitial: {
type: Boolean,
required: true,
},
note: {
type: Object,
required: true,
},
canToggle: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
details: null,
detailsTimeoutId: null,
isHovering: false
};
},
computed: {
isMe(): boolean {
return this.$store.getters.isSignedIn && this.$store.state.i.id === this.note.userId;
},
},
mounted() {
if (!this.isInitial) this.anime();
},
watch: {
count(newCount, oldCount) {
if (oldCount < newCount) this.anime();
if (this.details != null) this.openDetails();
},
},
methods: {
toggleReaction() {
if (this.isMe) return;
if (!this.canToggle) return;
const oldReaction = this.note.myReaction;
if (oldReaction) {
this.$root.api('notes/reactions/delete', {
noteId: this.note.id
}).then(() => {
if (oldReaction !== this.reaction) {
this.$root.api('notes/reactions/create', {
noteId: this.note.id,
reaction: this.reaction
});
}
});
} else {
this.$root.api('notes/reactions/create', {
noteId: this.note.id,
reaction: this.reaction
});
}
},
onMouseover() {
this.isHovering = true;
this.detailsTimeoutId = setTimeout(this.openDetails, 300);
},
onMouseleave() {
this.isHovering = false;
clearTimeout(this.detailsTimeoutId);
this.closeDetails();
},
openDetails() {
if (this.$root.isMobile) return;
this.$root.api('notes/reactions', {
noteId: this.note.id,
type: this.reaction,
limit: 11
}).then((reactions: any[]) => {
const users = reactions
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.map(x => x.user);
this.closeDetails();
if (!this.isHovering) return;
this.details = this.$root.new(XDetails, {
reaction: this.reaction,
users,
count: this.count,
source: this.$refs.reaction
});
});
},
closeDetails() {
if (this.details != null) {
this.details.close();
this.details = null;
}
},
anime() {
if (document.hidden) return;
// TODO
},
}
});
</script>
<style lang="scss" scoped>
.reaction {
display: inline-block;
height: 32px;
margin: 2px;
padding: 0 6px;
border-radius: 4px;
&.reacted {
background: var(--accent);
> span {
color: #fff;
}
}
&:not(.reacted) {
background: rgba(0, 0, 0, 0.05);
&:hover {
background: rgba(0, 0, 0, 0.1);
}
}
> span {
font-size: 0.9em;
line-height: 32px;
}
}
</style>

View file

@ -0,0 +1,48 @@
<template>
<div class="mk-reactions-viewer" :class="{ isMe }">
<x-reaction v-for="(count, reaction) in note.reactions" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note" :key="reaction"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XReaction from './reactions-viewer.reaction.vue';
export default Vue.extend({
components: {
XReaction
},
data() {
return {
initialReactions: new Set(Object.keys(this.note.reactions))
};
},
props: {
note: {
type: Object,
required: true
},
},
computed: {
isMe(): boolean {
return this.$store.getters.isSignedIn && this.$store.state.i.id === this.note.userId;
},
},
});
</script>
<style lang="scss" scoped>
.mk-reactions-viewer {
margin: 4px -2px 0 -2px;
&:empty {
display: none;
}
&.isMe {
> span {
cursor: default !important;
}
}
}
</style>

View file

@ -0,0 +1,94 @@
<template>
<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap">
<div class="rdfaahpc">
<button class="_button" @click="renote()"><fa :icon="faRetweet"/></button>
<button class="_button" @click="quote()"><fa :icon="faQuoteRight"/></button>
</div>
</x-popup>
</template>
<script lang="ts">
import Vue from 'vue';
import { faQuoteRight, faRetweet } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import XPopup from './popup.vue';
export default Vue.extend({
i18n,
components: {
XPopup,
},
props: {
note: {
type: Object,
required: true
},
source: {
required: true
},
},
data() {
return {
faQuoteRight, faRetweet
};
},
computed: {
keymap(): any {
return {
'esc': this.close,
};
}
},
methods: {
renote() {
(this as any).$root.api('notes/create', {
renoteId: this.note.id
}).then(() => {
this.$emit('closed');
this.destroyDom();
});
},
quote() {
this.$emit('closed');
this.destroyDom();
this.$root.post({
renote: this.note,
});
}
}
});
</script>
<style lang="scss" scoped>
.rdfaahpc {
padding: 4px;
> button {
padding: 0;
width: 40px;
height: 40px;
font-size: 16px;
border-radius: 2px;
> * {
height: 1em;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&:active {
background: var(--accent);
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
}
}
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<transition-group
name="staggered-fade"
tag="div"
:css="false"
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
mode="out-in"
appear
>
<slot></slot>
</transition-group>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
delay: {
type: Number,
required: false,
default: 40
},
direction: {
type: String,
required: false,
default: 'down'
}
},
methods: {
beforeEnter(el) {
el.style.opacity = 0;
el.style.transform = this.direction === 'down' ? 'translateY(-64px)' : 'translateY(64px)';
},
enter(el, done) {
el.style.transition = [getComputedStyle(el).transition, 'transform 0.7s cubic-bezier(0.23, 1, 0.32, 1)', 'opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1)'].filter(x => x != '').join(',');
setTimeout(() => {
el.style.opacity = 1;
el.style.transform = 'translateY(0px)';
setTimeout(done, 700);
}, this.delay * el.dataset.index)
},
leave(el, done) {
setTimeout(() => {
el.style.opacity = 0;
el.style.transform = this.direction === 'down' ? 'translateY(64px)' : 'translateY(-64px)';
setTimeout(done, 700);
}, this.delay * el.dataset.index)
},
focus() {
this.$slots.default[0].elm.focus();
}
}
});
</script>
<style lang="scss">
.staggered-fade-move {
transition: transform 0.7s !important;
}
</style>

View file

@ -0,0 +1,37 @@
<template>
<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }">
<template #header>{{ $t('login') }}</template>
<x-signin :auto-set="autoSet" @login="onLogin"/>
</x-window>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import XWindow from './window.vue';
import XSignin from './signin.vue';
export default Vue.extend({
i18n,
components: {
XSignin,
XWindow,
},
props: {
autoSet: {
type: Boolean,
required: false,
default: false,
}
},
methods: {
onLogin(res) {
this.$emit('login', res);
this.$refs.window.close();
}
}
});
</script>

View file

@ -0,0 +1,219 @@
<template>
<form class="eppvobhk" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
<div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div>
<div class="normal-signin" v-if="!totpLogin">
<mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
<span>{{ $t('username') }}</span>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</mk-input>
<mk-input v-model="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required>
<span>{{ $t('password') }}</span>
<template #prefix><fa :icon="faLock"/></template>
</mk-input>
<mk-button type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</mk-button>
<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p>
<p v-if="meta && meta.enableGithubIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p>
<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p>
</div>
<div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }">
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
<p>{{ $t('tap-key') }}</p>
<mk-button @click="queryKey" v-if="!queryingKey">
{{ $t('@.error.retry') }}
</mk-button>
</div>
<div class="or-hr" v-if="user && user.securityKeys">
<p class="or-msg">{{ $t('or') }}</p>
</div>
<div class="twofa-group totp-group">
<p style="margin-bottom:0;">{{ $t('twoStepAuthentication') }}</p>
<mk-input v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required>
<span>{{ $t('password') }}</span>
<template #prefix><fa :icon="faLock"/></template>
</mk-input>
<mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
<span>{{ $t('token') }}</span>
<template #prefix><fa :icon="faGavel"/></template>
</mk-input>
<mk-button type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</mk-button>
</div>
</div>
</form>
</template>
<script lang="ts">
import Vue from 'vue';
import { toUnicode } from 'punycode';
import { faLock, faGavel } from '@fortawesome/free-solid-svg-icons';
import MkButton from './ui/button.vue';
import MkInput from './ui/input.vue';
import i18n from '../i18n';
import { apiUrl, host } from '../config';
import { hexifyAB } from '../scripts/2fa';
export default Vue.extend({
i18n,
components: {
MkButton,
MkInput,
},
props: {
withAvatar: {
type: Boolean,
required: false,
default: true
},
autoSet: {
type: Boolean,
required: false,
default: false,
}
},
data() {
return {
signing: false,
user: null,
username: '',
password: '',
token: '',
apiUrl,
host: toUnicode(host),
meta: null,
totpLogin: false,
credential: null,
challengeData: null,
queryingKey: false,
faLock, faGavel
};
},
created() {
this.$root.getMeta().then(meta => {
this.meta = meta;
});
if (this.autoSet) {
this.$once('login', res => {
localStorage.setItem('i', res.i);
location.reload();
});
}
},
methods: {
onUsernameChange() {
this.$root.api('users/show', {
username: this.username
}).then(user => {
this.user = user;
}, () => {
this.user = null;
});
},
queryKey() {
this.queryingKey = true;
return navigator.credentials.get({
publicKey: {
challenge: Buffer.from(
this.challengeData.challenge
.replace(/\-/g, '+')
.replace(/_/g, '/'),
'base64'
),
allowCredentials: this.challengeData.securityKeys.map(key => ({
id: Buffer.from(key.id, 'hex'),
type: 'public-key',
transports: ['usb', 'nfc', 'ble', 'internal']
})),
timeout: 60 * 1000
}
}).catch(() => {
this.queryingKey = false;
return Promise.reject(null);
}).then(credential => {
this.queryingKey = false;
this.signing = true;
return this.$root.api('signin', {
username: this.username,
password: this.password,
signature: hexifyAB(credential.response.signature),
authenticatorData: hexifyAB(credential.response.authenticatorData),
clientDataJSON: hexifyAB(credential.response.clientDataJSON),
credentialId: credential.id,
challengeId: this.challengeData.challengeId
});
}).then(res => {
this.$emit('login', res);
}).catch(err => {
if (err === null) return;
this.$root.dialog({
type: 'error',
text: this.$t('login-failed')
});
this.signing = false;
});
},
onSubmit() {
this.signing = true;
if (!this.totpLogin && this.user && this.user.twoFactorEnabled) {
if (window.PublicKeyCredential && this.user.securityKeys) {
this.$root.api('signin', {
username: this.username,
password: this.password
}).then(res => {
this.totpLogin = true;
this.signing = false;
this.challengeData = res;
return this.queryKey();
}).catch(() => {
this.$root.dialog({
type: 'error',
text: this.$t('login-failed')
});
this.challengeData = null;
this.totpLogin = false;
this.signing = false;
});
} else {
this.totpLogin = true;
this.signing = false;
}
} else {
this.$root.api('signin', {
username: this.username,
password: this.password,
token: this.user && this.user.twoFactorEnabled ? this.token : undefined
}).then(res => {
this.$emit('login', res);
}).catch(() => {
this.$root.dialog({
type: 'error',
text: this.$t('loginFailed')
});
this.signing = false;
});
}
}
}
});
</script>
<style lang="scss" scoped>
.eppvobhk {
> .avatar {
margin: 0 auto 0 auto;
width: 64px;
height: 64px;
background: #ddd;
background-position: center;
background-size: cover;
border-radius: 100%;
}
}
</style>

View file

@ -0,0 +1,22 @@
<template>
<x-window @closed="() => { $emit('closed'); destroyDom(); }">
<template #header>{{ $t('signup') }}</template>
<x-signup/>
</x-window>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import XWindow from './window.vue';
import XSignup from './signup.vue';
export default Vue.extend({
i18n,
components: {
XSignup,
XWindow,
},
});
</script>

View file

@ -0,0 +1,191 @@
<template>
<form class="mk-signup" @submit.prevent="onSubmit" :autocomplete="Math.random()">
<template v-if="meta">
<mk-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required>
<span>{{ $t('invitation-code') }}</span>
<template #prefix><fa icon="id-card-alt"/></template>
<template #desc v-html="this.$t('invitation-info').replace('{}', 'mailto:' + meta.maintainerEmail)"></template>
</mk-input>
<mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername">
<span>{{ $t('username') }}</span>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
<template #desc>
<span v-if="usernameState == 'wait'" style="color:#999"><fa :icon="faSpinner" pulse fixed-width/> {{ $t('checking') }}</span>
<span v-if="usernameState == 'ok'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('available') }}</span>
<span v-if="usernameState == 'unavailable'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('unavailable') }}</span>
<span v-if="usernameState == 'error'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('error') }}</span>
<span v-if="usernameState == 'invalid-format'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('invalid-format') }}</span>
<span v-if="usernameState == 'min-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('too-short') }}</span>
<span v-if="usernameState == 'max-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('too-long') }}</span>
</template>
</mk-input>
<mk-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword">
<span>{{ $t('password') }}</span>
<template #prefix><fa :icon="faLock"/></template>
<template #desc>
<p v-if="passwordStrength == 'low'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('weak-password') }}</p>
<p v-if="passwordStrength == 'medium'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('normal-password') }}</p>
<p v-if="passwordStrength == 'high'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('strong-password') }}</p>
</template>
</mk-input>
<mk-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype">
<span>{{ $t('password') }} ({{ $t('retype') }})</span>
<template #prefix><fa :icon="faLock"/></template>
<template #desc>
<p v-if="passwordRetypeState == 'match'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('password-matched') }}</p>
<p v-if="passwordRetypeState == 'not-match'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('password-not-matched') }}</p>
</template>
</mk-input>
<mk-switch v-model="ToSAgreement" v-if="meta.tosUrl">
<i18n path="agreeTo">
<a :href="meta.tosUrl" target="_blank">{{ $t('tos') }}</a>
</i18n>
</mk-switch>
<div v-if="meta.enableRecaptcha" class="g-recaptcha" :data-sitekey="meta.recaptchaSiteKey" style="margin: 16px 0;"></div>
<mk-button type="submit" :disabled=" submitting || !(meta.tosUrl ? ToSAgreement : true) || passwordRetypeState == 'not-match'" primary>{{ $t('start') }}</mk-button>
</template>
</form>
</template>
<script lang="ts">
import Vue from 'vue';
import { faLock, faExclamationTriangle, faSpinner, faCheck } from '@fortawesome/free-solid-svg-icons';
const getPasswordStrength = require('syuilo-password-strength');
import { toUnicode } from 'punycode';
import i18n from '../i18n';
import { host, url } from '../config';
import MkButton from './ui/button.vue';
import MkInput from './ui/input.vue';
import MkSwitch from './ui/switch.vue';
export default Vue.extend({
i18n,
components: {
MkButton,
MkInput,
MkSwitch,
},
data() {
return {
host: toUnicode(host),
username: '',
password: '',
retypedPassword: '',
invitationCode: '',
url,
usernameState: null,
passwordStrength: '',
passwordRetypeState: null,
meta: {},
submitting: false,
ToSAgreement: false,
faLock, faExclamationTriangle, faSpinner, faCheck
}
},
computed: {
shouldShowProfileUrl(): boolean {
return (this.username != '' &&
this.usernameState != 'invalid-format' &&
this.usernameState != 'min-range' &&
this.usernameState != 'max-range');
}
},
created() {
this.$root.getMeta().then(meta => {
this.meta = meta;
});
},
mounted() {
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js');
head.appendChild(script);
},
methods: {
onChangeUsername() {
if (this.username == '') {
this.usernameState = null;
return;
}
const err =
!this.username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
this.username.length < 1 ? 'min-range' :
this.username.length > 20 ? 'max-range' :
null;
if (err) {
this.usernameState = err;
return;
}
this.usernameState = 'wait';
this.$root.api('username/available', {
username: this.username
}).then(result => {
this.usernameState = result.available ? 'ok' : 'unavailable';
}).catch(err => {
this.usernameState = 'error';
});
},
onChangePassword() {
if (this.password == '') {
this.passwordStrength = '';
return;
}
const strength = getPasswordStrength(this.password);
this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
},
onChangePasswordRetype() {
if (this.retypedPassword == '') {
this.passwordRetypeState = null;
return;
}
this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match';
},
onSubmit() {
if (this.submitting) return;
this.submitting = true;
this.$root.api('signup', {
username: this.username,
password: this.password,
invitationCode: this.invitationCode,
'g-recaptcha-response': this.meta.enableRecaptcha ? (window as any).grecaptcha.getResponse() : null
}).then(() => {
this.$root.api('signin', {
username: this.username,
password: this.password
}).then(res => {
localStorage.setItem('i', res.i);
location.href = '/';
});
}).catch(() => {
this.submitting = false;
this.$root.dialog({
type: 'error',
text: this.$t('some-error')
});
if (this.meta.enableRecaptcha) {
(window as any).grecaptcha.reset();
}
});
}
}
});
</script>

View file

@ -0,0 +1,65 @@
<template>
<div class="wrmlmaau">
<div class="body">
<span v-if="note.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span>
<router-link class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><fa :icon="faReply"/></router-link>
<mfm v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/>
<router-link class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</router-link>
</div>
<details v-if="note.files.length > 0">
<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
<x-media-list :media-list="note.files"/>
</details>
<details v-if="note.poll">
<summary>{{ $t('poll') }}</summary>
<x-poll :note="note"/>
</details>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faReply } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import XPoll from './poll.vue';
import XMediaList from './media-list.vue';
export default Vue.extend({
i18n,
components: {
XPoll,
XMediaList,
},
props: {
note: {
type: Object,
required: true
}
},
data() {
return {
faReply
};
}
});
</script>
<style lang="scss" scoped>
.wrmlmaau {
overflow-wrap: break-word;
> .body {
> .reply {
margin-right: 6px;
color: var(--accent);
}
> .rp {
margin-left: 4px;
font-style: oblique;
color: var(--renote);
}
}
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<time class="mk-time" :title="absolute">
<span v-if="mode == 'relative'">{{ relative }}</span>
<span v-if="mode == 'absolute'">{{ absolute }}</span>
<span v-if="mode == 'detail'">{{ absolute }} ({{ relative }})</span>
</time>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
export default Vue.extend({
i18n,
props: {
time: {
type: [Date, String],
required: true
},
mode: {
type: String,
default: 'relative'
}
},
data() {
return {
tickId: null,
now: new Date()
};
},
computed: {
_time(): Date {
return typeof this.time == 'string' ? new Date(this.time) : this.time;
},
absolute(): string {
return this._time.toLocaleString();
},
relative(): string {
const time = this._time;
const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/;
return (
ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
ago >= -1 ? this.$t('_ago.justNow') :
ago < -1 ? this.$t('_ago.future') :
this.$t('@.time.unknown'));
}
},
created() {
if (this.mode == 'relative' || this.mode == 'detail') {
this.tickId = window.requestAnimationFrame(this.tick);
}
},
destroyed() {
if (this.mode === 'relative' || this.mode === 'detail') {
window.clearTimeout(this.tickId);
}
},
methods: {
tick() {
this.now = new Date();
this.tickId = setTimeout(() => {
window.requestAnimationFrame(this.tick);
}, 10000);
}
}
});
</script>

View file

@ -0,0 +1,118 @@
<template>
<x-notes ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './notes.vue';
export default Vue.extend({
components: {
XNotes
},
props: {
src: {
type: String,
required: true
},
list: {
required: false
},
antenna: {
required: false
}
},
data() {
return {
connection: null,
pagination: null,
baseQuery: {
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
},
query: {},
};
},
created() {
this.$once('hook:beforeDestroy', () => {
this.connection.dispose();
});
const prepend = note => {
(this.$refs.tl as any).prepend(note);
};
const onUserAdded = () => {
(this.$refs.tl as any).reload();
};
const onUserRemoved = () => {
(this.$refs.tl as any).reload();
};
let endpoint;
if (this.src == 'antenna') {
endpoint = 'antennas/notes';
this.query = {
antennaId: this.antenna.id
};
this.connection = this.$root.stream.connectToChannel('antenna', {
antennaId: this.antenna.id
});
this.connection.on('note', prepend);
} else if (this.src == 'home') {
endpoint = 'notes/timeline';
const onChangeFollowing = () => {
this.fetch();
};
this.connection = this.$root.stream.useSharedConnection('homeTimeline');
this.connection.on('note', prepend);
this.connection.on('follow', onChangeFollowing);
this.connection.on('unfollow', onChangeFollowing);
} else if (this.src == 'local') {
endpoint = 'notes/local-timeline';
this.connection = this.$root.stream.useSharedConnection('localTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'social') {
endpoint = 'notes/hybrid-timeline';
this.connection = this.$root.stream.useSharedConnection('hybridTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'global') {
endpoint = 'notes/global-timeline';
this.connection = this.$root.stream.useSharedConnection('globalTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'list') {
endpoint = 'notes/user-list-timeline';
this.query = {
listId: this.list.id
};
this.connection = this.$root.stream.connectToChannel('userList', {
listId: this.list.id
});
this.connection.on('note', prepend);
this.connection.on('userAdded', onUserAdded);
this.connection.on('userRemoved', onUserRemoved);
}
this.pagination = {
endpoint: endpoint,
limit: 10,
params: init => ({
untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
...this.baseQuery, ...this.query
})
};
},
methods: {
focus() {
this.$refs.tl.focus();
}
}
});
</script>

View file

@ -0,0 +1,76 @@
<template>
<div class="mk-toast">
<transition name="notification-slide" appear @after-leave="() => { destroyDom(); }">
<x-notification :notification="notification" class="notification" v-if="show"/>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotification from './notification.vue';
export default Vue.extend({
components: {
XNotification
},
props: {
notification: {
type: Object,
required: true
}
},
data() {
return {
show: true
};
},
mounted() {
setTimeout(() => {
this.show = false;
}, 6000);
}
});
</script>
<style lang="scss" scoped>
.notification-slide-enter-active, .notification-slide-leave-active {
transition: opacity 0.3s, transform 0.3s !important;
}
.notification-slide-enter, .notification-slide-leave-to {
opacity: 0;
transform: translateX(-250px);
}
.mk-toast {
position: fixed;
z-index: 10000;
left: 0;
width: 250px;
top: 32px;
padding: 0 32px;
pointer-events: none;
@media (max-width: 700px) {
top: initial;
bottom: 112px;
padding: 0 16px;
}
@media (max-width: 500px) {
bottom: 92px;
padding: 0 8px;
}
> .notification {
height: 100%;
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
background-color: var(--toastBg);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
border-radius: 8px;
color: var(--toastFg);
overflow: hidden;
}
}
</style>

View file

@ -0,0 +1,204 @@
<template>
<component class="bghgjjyj _button"
:is="link ? 'a' : 'button'"
:class="{ inline, primary }"
:type="type"
@click="$emit('click', $event)"
@mousedown="onMousedown"
>
<div ref="ripples" class="ripples"></div>
<div class="content">
<slot></slot>
</div>
</component>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
type: {
type: String,
required: false
},
primary: {
type: Boolean,
required: false,
default: false
},
inline: {
type: Boolean,
required: false,
default: false
},
link: {
type: Boolean,
required: false,
default: false
},
autofocus: {
type: Boolean,
required: false,
default: false
},
wait: {
type: Boolean,
required: false,
default: false
},
},
mounted() {
if (this.autofocus) {
this.$nextTick(() => {
this.$el.focus();
});
}
},
methods: {
onMousedown(e: MouseEvent) {
function distance(p, q) {
return Math.hypot(p.x - q.x, p.y - q.y);
}
function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) {
const origin = {x: circleCenterX, y: circleCenterY};
const dist1 = distance({x: 0, y: 0}, origin);
const dist2 = distance({x: boxW, y: 0}, origin);
const dist3 = distance({x: 0, y: boxH}, origin);
const dist4 = distance({x: boxW, y: boxH }, origin);
return Math.max(dist1, dist2, dist3, dist4) * 2;
}
const rect = e.target.getBoundingClientRect();
const ripple = document.createElement('div');
ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px';
ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px';
this.$refs.ripples.appendChild(ripple);
const circleCenterX = e.clientX - rect.left;
const circleCenterY = e.clientY - rect.top;
const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY);
setTimeout(() => {
ripple.style.transform = 'scale(' + (scale / 2) + ')';
}, 1);
setTimeout(() => {
ripple.style.transition = 'all 1s ease';
ripple.style.opacity = '0';
}, 1000);
setTimeout(() => {
if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple);
}, 2000);
}
}
});
</script>
<style lang="scss" scoped>
.bghgjjyj {
position: relative;
display: block;
min-width: 100px;
padding: 8px 14px;
text-align: center;
font-weight: normal;
font-size: 14px;
line-height: 24px;
box-shadow: none;
text-decoration: none;
background: var(--buttonBg);
border-radius: 6px;
overflow: hidden;
&:not(:disabled):hover {
background: var(--buttonHoverBg);
}
&:not(:disabled):active {
background: var(--buttonHoverBg);
}
&.primary {
color: #fff;
background: var(--accent);
&:not(:disabled):hover {
background: var(--jkhztclx);
}
&:not(:disabled):active {
background: var(--jkhztclx);
}
}
&:disabled {
opacity: 0.7;
}
&:focus {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--accentAlpha03);
border-radius: 10px;
}
}
&.inline + .bghgjjyj {
margin-left: 12px;
}
&:not(.inline) + .bghgjjyj {
margin-top: 16px;
}
&.inline {
display: inline-block;
width: auto;
min-width: 100px;
}
&.primary {
font-weight: bold;
}
> .ripples {
position: absolute;
z-index: 0;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 6px;
overflow: hidden;
::v-deep div {
position: absolute;
width: 2px;
height: 2px;
border-radius: 100%;
background: rgba(0, 0, 0, 0.1);
opacity: 1;
transform: scale(1);
transition: all 0.5s cubic-bezier(0,.5,0,1);
}
}
&.primary > .ripples ::v-deep div {
background: rgba(0, 0, 0, 0.15);
}
> .content {
position: relative;
z-index: 1;
}
}
</style>

View file

@ -0,0 +1,104 @@
<template>
<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader }">
<header v-if="showHeader">
<div class="title"><slot name="header"></slot></div>
<slot name="func"></slot>
<button class="_button" v-if="bodyTogglable" @click="() => showBody = !showBody">
<template v-if="showBody"><fa :icon="faAngleUp"/></template>
<template v-else><fa :icon="faAngleDown"/></template>
</button>
</header>
<div v-show="showBody">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
props: {
showHeader: {
type: Boolean,
required: false,
default: true
},
naked: {
type: Boolean,
required: false,
default: false
},
bodyTogglable: {
type: Boolean,
required: false,
default: false
},
expanded: {
type: Boolean,
required: false,
default: true
},
},
data() {
return {
showBody: this.expanded,
faAngleUp, faAngleDown
};
},
methods: {
toggleContent(show: boolean) {
if (!this.bodyTogglable) return;
this.showBody = show;
}
}
});
</script>
<style lang="scss" scoped>
.ukygtjoj {
position: relative;
overflow: hidden;
& + .ukygtjoj {
margin-top: var(--margin);
}
&.naked {
background: transparent !important;
box-shadow: none !important;
}
> header {
position: relative;
> .title {
margin: 0;
padding: 12px 16px;
@media (max-width: 500px) {
padding: 8px 10px;
}
> [data-icon] {
margin-right: 6px;
}
&:empty {
display: none;
}
}
> button {
position: absolute;
z-index: 2;
top: 0;
right: 0;
padding: 0;
width: 42px;
height: 100%;
}
}
}
</style>

View file

@ -0,0 +1,15 @@
<template>
<div class="evrzpitu"></div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({});
</script>
<style lang="scss" scoped>
.evrzpitu
margin 16px 0
border-bottom solid var(--lineWidth) var(--faceDivider)
</style>

View file

@ -0,0 +1,55 @@
<template>
<div class="fpezltsf" :class="{ warn }">
<i v-if="warn"><fa :icon="faExclamationTriangle"/></i>
<i v-else><fa :icon="faInfoCircle"/></i>
<slot></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faInfoCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
props: {
warn: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
faInfoCircle, faExclamationTriangle
};
}
});
</script>
<style lang="scss" scoped>
.fpezltsf {
margin: 16px 0;
padding: 16px;
font-size: 90%;
background: var(--infoBg);
color: var(--infoFg);
border-radius: 5px;
&.warn {
background: var(--infoWarnBg);
color: var(--infoWarnFg);
}
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
> i {
margin-right: 4px;
}
}
</style>

View file

@ -0,0 +1,443 @@
<template>
<div class="juejbjww" :class="{ focused, filled, inline, disabled }">
<div class="icon" ref="icon"><slot name="icon"></slot></div>
<div class="input">
<span class="label" ref="label"><slot></slot></span>
<span class="title" ref="title">
<slot name="title"></slot>
<span class="warning" v-if="invalid"><fa :icon="faExclamationCircle"/>{{ $refs.input.validationMessage }}</span>
</span>
<div class="prefix" ref="prefix"><slot name="prefix"></slot></div>
<template v-if="type != 'file'">
<input v-if="debounce" ref="input"
v-debounce="500"
:type="type"
v-model.lazy="v"
:disabled="disabled"
:required="required"
:readonly="readonly"
:placeholder="placeholder"
:pattern="pattern"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
@focus="focused = true"
@blur="focused = false"
@keydown="$emit('keydown', $event)"
@input="onInput"
:list="id"
>
<input v-else ref="input"
:type="type"
v-model="v"
:disabled="disabled"
:required="required"
:readonly="readonly"
:placeholder="placeholder"
:pattern="pattern"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
@focus="focused = true"
@blur="focused = false"
@keydown="$emit('keydown', $event)"
@input="onInput"
:list="id"
>
<datalist :id="id" v-if="datalist">
<option v-for="data in datalist" :value="data"/>
</datalist>
</template>
<template v-else>
<input ref="input"
type="text"
:value="filePlaceholder"
readonly
@click="chooseFile"
>
<input ref="file"
type="file"
:value="value"
@change="onChangeFile"
>
</template>
<div class="suffix" ref="suffix"><slot name="suffix"></slot></div>
</div>
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button>
<div class="desc"><slot name="desc"></slot></div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import debounce from 'v-debounce';
import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
directives: {
debounce
},
props: {
value: {
required: false
},
type: {
type: String,
required: false
},
required: {
type: Boolean,
required: false
},
readonly: {
type: Boolean,
required: false
},
disabled: {
type: Boolean,
required: false
},
pattern: {
type: String,
required: false
},
placeholder: {
type: String,
required: false
},
autofocus: {
type: Boolean,
required: false,
default: false
},
autocomplete: {
required: false
},
spellcheck: {
required: false
},
debounce: {
required: false
},
datalist: {
type: Array,
required: false,
},
inline: {
type: Boolean,
required: false,
default: false
},
save: {
type: Function,
required: false,
},
},
data() {
return {
v: this.value,
focused: false,
invalid: false,
changed: false,
id: Math.random().toString(),
faExclamationCircle
};
},
computed: {
filled(): boolean {
return this.v !== '' && this.v != null;
},
filePlaceholder(): string | null {
if (this.type != 'file') return null;
if (this.v == null) return null;
if (typeof this.v == 'string') return this.v;
if (Array.isArray(this.v)) {
return this.v.map(file => file.name).join(', ');
} else {
return this.v.name;
}
}
},
watch: {
value(v) {
this.v = v;
},
v(v) {
if (this.type === 'number') {
this.$emit('input', parseInt(v, 10));
} else {
this.$emit('input', v);
}
this.invalid = this.$refs.input.validity.badInput;
}
},
mounted() {
if (this.autofocus) {
this.$nextTick(() => {
this.$refs.input.focus();
});
}
this.$nextTick(() => {
//
// 0
const clock = setInterval(() => {
if (this.$refs.prefix) {
this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px';
if (this.$refs.prefix.offsetWidth) {
this.$refs.input.style.paddingLeft = this.$refs.prefix.offsetWidth + 'px';
}
}
if (this.$refs.suffix) {
if (this.$refs.suffix.offsetWidth) {
this.$refs.input.style.paddingRight = this.$refs.suffix.offsetWidth + 'px';
}
}
}, 100);
this.$once('hook:beforeDestroy', () => {
clearInterval(clock);
});
});
this.$on('keydown', (e: KeyboardEvent) => {
if (e.code == 'Enter') {
this.$emit('enter');
}
});
},
methods: {
focus() {
this.$refs.input.focus();
},
togglePassword() {
if (this.type == 'password') {
this.type = 'text'
} else {
this.type = 'password'
}
},
chooseFile() {
this.$refs.file.click();
},
onChangeFile() {
this.v = Array.from((this.$refs.file as any).files);
this.$emit('input', this.v);
this.$emit('change', this.v);
},
onInput(ev) {
this.changed = true;
this.$emit('change', ev);
}
}
});
</script>
<style lang="scss" scoped>
.juejbjww {
position: relative;
margin: 32px 0;
> .icon {
position: absolute;
top: 0;
left: 0;
width: 24px;
text-align: center;
line-height: 32px;
&:not(:empty) + .input {
margin-left: 28px;
}
}
> .input {
position: relative;
&:before {
content: '';
display: block;
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: var(--inputBorder);
}
&:after {
content: '';
display: block;
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
opacity: 0;
transform: scaleX(0.12);
transition: border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: border opacity transform;
}
> .label {
position: absolute;
z-index: 1;
top: 0;
left: 0;
pointer-events: none;
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
transition-duration: 0.3s;
font-size: 16px;
line-height: 32px;
color: var(--inputLabel);
pointer-events: none;
//will-change transform
transform-origin: top left;
transform: scale(1);
}
> .title {
position: absolute;
z-index: 1;
top: -17px;
left: 0 !important;
pointer-events: none;
font-size: 16px;
line-height: 32px;
color: var(--inputLabel);
pointer-events: none;
//will-change transform
transform-origin: top left;
transform: scale(.75);
white-space: nowrap;
width: 133%;
overflow: hidden;
text-overflow: ellipsis;
> .warning {
margin-left: 0.5em;
color: var(--infoWarnFg);
> svg {
margin-right: 0.1em;
}
}
}
> input {
display: block;
width: 100%;
margin: 0;
padding: 0;
font: inherit;
font-weight: normal;
font-size: 16px;
line-height: 32px;
color: var(--inputText);
background: transparent;
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
box-sizing: border-box;
&[type='file'] {
display: none;
}
}
> .prefix,
> .suffix {
display: block;
position: absolute;
z-index: 1;
top: 0;
font-size: 16px;
line-height: 32px;
color: var(--inputLabel);
pointer-events: none;
&:empty {
display: none;
}
> * {
display: inline-block;
min-width: 16px;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
> .prefix {
left: 0;
padding-right: 4px;
}
> .suffix {
right: 0;
padding-left: 4px;
}
}
> .save {
margin: 6px 0 0 0;
font-size: 13px;
}
> .desc {
margin: 6px 0 0 0;
font-size: 13px;
opacity: 0.7;
&:empty {
display: none;
}
* {
margin: 0;
}
}
&.focused {
> .input {
&:after {
opacity: 1;
transform: scaleX(1);
}
> .label {
color: var(--accent);
}
}
}
&.focused,
&.filled {
> .input {
> .label {
top: -17px;
left: 0 !important;
transform: scale(0.75);
}
}
}
&.inline {
display: inline-block;
margin: 0;
}
&.disabled {
opacity: 0.7;
&, * {
cursor: not-allowed !important;
}
}
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<sequential-entrance class="cxiknjgy" :class="{ autoMargin }">
<slot :items="items"></slot>
<div class="empty" v-if="empty" key="_empty_">
<slot name="empty"></slot>
</div>
<div class="more" v-if="more" key="_more_">
<mk-button :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore()">
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
<template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template>
</mk-button>
</div>
</sequential-entrance>
</template>
<script lang="ts">
import Vue from 'vue';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import MkButton from './button.vue';
import paging from '../../scripts/paging';
export default Vue.extend({
mixins: [
paging({}),
],
components: {
MkButton
},
props: {
pagination: {
required: true
},
autoMargin: {
required: false,
default: true
}
},
data() {
return {
faSpinner
};
},
});
</script>
<style lang="scss" scoped>
.cxiknjgy {
&.autoMargin > *:not(:last-child) {
margin-bottom: 16px;
@media (max-width: 500px) {
margin-bottom: 8px;
}
}
}
</style>

View file

@ -0,0 +1,119 @@
<template>
<div
class="novjtctn"
:class="{ disabled, checked }"
:aria-checked="checked"
:aria-disabled="disabled"
@click="toggle"
>
<input type="radio"
:disabled="disabled"
>
<span class="button">
<span></span>
</span>
<span class="label"><slot></slot></span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
model: {
prop: 'model',
event: 'change'
},
props: {
model: {
required: false
},
value: {
required: false
},
disabled: {
type: Boolean,
default: false
}
},
computed: {
checked(): boolean {
return this.model === this.value;
}
},
methods: {
toggle() {
this.$emit('change', this.value);
}
}
});
</script>
<style lang="scss" scoped>
.novjtctn {
display: inline-block;
margin: 0 32px 0 0;
cursor: pointer;
transition: all 0.3s;
> * {
user-select: none;
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.checked {
> .button {
border-color: var(--radioActive);
&:after {
background-color: var(--radioActive);
transform: scale(1);
opacity: 1;
}
}
}
> input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
> .button {
position: absolute;
width: 20px;
height: 20px;
background: none;
border: solid 2px var(--inputLabel);
border-radius: 100%;
transition: inherit;
&:after {
content: '';
display: block;
position: absolute;
top: 3px;
right: 3px;
bottom: 3px;
left: 3px;
border-radius: 100%;
opacity: 0;
transform: scale(0);
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}
}
> .label {
margin-left: 28px;
display: block;
font-size: 16px;
line-height: 20px;
cursor: pointer;
}
}
</style>

View file

@ -0,0 +1,220 @@
<template>
<div class="eiipwacr" :class="{ focused, disabled, filled, inline }">
<div class="icon" ref="icon"><slot name="icon"></slot></div>
<div class="input" @click="focus">
<span class="label" ref="label"><slot name="label"></slot></span>
<div class="prefix" ref="prefix"><slot name="prefix"></slot></div>
<select ref="input"
v-model="v"
:required="required"
:disabled="disabled"
@focus="focused = true"
@blur="focused = false"
>
<slot></slot>
</select>
<div class="suffix"><slot name="suffix"></slot></div>
</div>
<div class="text"><slot name="text"></slot></div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: false
},
required: {
type: Boolean,
required: false
},
disabled: {
type: Boolean,
required: false
},
inline: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
focused: false
};
},
computed: {
v: {
get() {
return this.value;
},
set(v) {
this.$emit('input', v);
}
},
filled(): boolean {
return this.v != '' && this.v != null;
}
},
mounted() {
if (this.$refs.prefix) {
this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px';
}
},
methods: {
focus() {
this.$refs.input.focus();
}
}
});
</script>
<style lang="scss" scoped>
.eiipwacr {
position: relative;
margin: 32px 0;
> .icon {
position: absolute;
top: 0;
left: 0;
width: 24px;
text-align: center;
line-height: 32px;
&:not(:empty) + .input {
margin-left: 28px;
}
}
> .input {
display: flex;
&:before {
content: '';
display: block;
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: var(--inputBorder);
}
&:after {
content: '';
display: block;
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
opacity: 0;
transform: scaleX(0.12);
transition: border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: border opacity transform;
}
> .label {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
transition-duration: 0.3s;
font-size: 16px;
line-height: 32px;
pointer-events: none;
//will-change transform
transform-origin: top left;
transform: scale(1);
}
> select {
display: block;
flex: 1;
width: 100%;
padding: 0;
font: inherit;
font-weight: normal;
font-size: 16px;
height: 32px;
background: var(--panel);
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
color: var(--fg);
}
> .prefix,
> .suffix {
display: block;
align-self: center;
justify-self: center;
font-size: 16px;
line-height: 32px;
color: rgba(#000, 0.54);
pointer-events: none;
&:empty {
display: none;
}
> * {
display: block;
min-width: 16px;
}
}
> .prefix {
padding-right: 4px;
}
> .suffix {
padding-left: 4px;
}
}
> .text {
margin: 6px 0;
font-size: 13px;
&:empty {
display: none;
}
* {
margin: 0;
}
}
&.focused {
> .input {
&:after {
opacity: 1;
transform: scaleX(1);
}
> .label {
color: var(--accent);
}
}
}
&.focused,
&.filled {
> .input {
> .label {
top: -17px;
left: 0 !important;
transform: scale(0.75);
}
}
}
}
</style>

View file

@ -0,0 +1,150 @@
<template>
<div
class="ziffeoms"
:class="{ disabled, checked }"
role="switch"
:aria-checked="checked"
:aria-disabled="disabled"
@click="toggle"
>
<input
type="checkbox"
ref="input"
:disabled="disabled"
@keydown.enter="toggle"
>
<span class="button">
<span></span>
</span>
<span class="label">
<span :aria-hidden="!checked"><slot></slot></span>
<p :aria-hidden="!checked">
<slot name="desc"></slot>
</p>
</span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
model: {
prop: 'value',
event: 'change'
},
props: {
value: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
},
computed: {
checked(): boolean {
return this.value;
}
},
methods: {
toggle() {
if (this.disabled) return;
this.$emit('change', !this.checked);
}
}
});
</script>
<style lang="scss" scoped>
.ziffeoms {
position: relative;
display: flex;
margin: 32px 0;
cursor: pointer;
transition: all 0.3s;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
> * {
user-select: none;
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.checked {
> .button {
background-color: var(--xxubwiul);
border-color: var(--xxubwiul);
> * {
background-color: var(--accent);
transform: translateX(14px);
}
}
}
> input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
> .button {
position: relative;
display: inline-block;
flex-shrink: 0;
margin: 3px 0 0 0;
width: 34px;
height: 14px;
background: var(--nhzhphzx);
outline: none;
border-radius: 14px;
transition: inherit;
> * {
position: absolute;
top: -3px;
left: 0;
border-radius: 100%;
transition: background-color 0.3s, transform 0.3s;
width: 20px;
height: 20px;
background-color: #fff;
box-shadow: 0 2px 1px -1px rgba(#000, 0.2), 0 1px 1px 0 rgba(#000, 0.14), 0 1px 3px 0 rgba(#000, 0.12);
}
}
> .label {
margin-left: 8px;
display: block;
font-size: 16px;
cursor: pointer;
transition: inherit;
color: var(--fg);
> span {
display: block;
line-height: 20px;
transition: inherit;
}
> p {
margin: 0;
opacity: 0.7;
font-size: 90%;
}
}
}
</style>

View file

@ -0,0 +1,218 @@
<template>
<div class="adhpbeos" :class="{ focused, filled, tall, pre }">
<div class="input">
<span class="label" ref="label"><slot></slot></span>
<textarea ref="input"
:value="value"
:required="required"
:readonly="readonly"
:pattern="pattern"
:autocomplete="autocomplete"
@input="onInput"
@focus="focused = true"
@blur="focused = false"
></textarea>
</div>
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button>
<div class="desc"><slot name="desc"></slot></div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
value: {
required: false
},
required: {
type: Boolean,
required: false
},
readonly: {
type: Boolean,
required: false
},
pattern: {
type: String,
required: false
},
autocomplete: {
type: String,
required: false
},
tall: {
type: Boolean,
required: false,
default: false
},
pre: {
type: Boolean,
required: false,
default: false
},
save: {
type: Function,
required: false,
},
},
data() {
return {
focused: false,
changed: false,
}
},
computed: {
filled(): boolean {
return this.value != '' && this.value != null;
}
},
methods: {
focus() {
this.$refs.input.focus();
},
onInput(ev) {
this.changed = true;
this.$emit('input', ev.target.value);
}
}
});
</script>
<style lang="scss" scoped>
.adhpbeos {
margin: 42px 0 32px 0;
position: relative;
&:last-child {
margin-bottom: 0;
}
> .input {
position: relative;
&:before {
content: '';
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: none;
border: solid 1px var(--inputBorder);
border-radius: 3px;
pointer-events: none;
}
&:after {
content: '';
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: none;
border: solid 2px var(--accent);
border-radius: 3px;
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
> .label {
position: absolute;
top: 6px;
left: 12px;
pointer-events: none;
transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
transition-duration: 0.3s;
font-size: 16px;
line-height: 32px;
pointer-events: none;
//will-change transform
transform-origin: top left;
transform: scale(1);
}
> textarea {
display: block;
width: 100%;
min-width: 100%;
max-width: 100%;
min-height: 130px;
padding: 12px;
box-sizing: border-box;
font: inherit;
font-weight: normal;
font-size: 16px;
background: transparent;
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
color: var(--fg);
}
}
> .save {
margin: 6px 0 0 0;
font-size: 13px;
}
> .desc {
margin: 6px 0 0 0;
font-size: 13px;
opacity: 0.7;
&:empty {
display: none;
}
* {
margin: 0;
}
}
&.focused {
> .input {
&:after {
opacity: 1;
}
> .label {
color: var(--accent);
}
}
}
&.focused,
&.filled {
> .input {
> .label {
top: -24px;
left: 0 !important;
transform: scale(0.75);
}
}
}
&.tall {
> .input {
> textarea {
min-height: 200px;
}
}
}
&.pre {
> .input {
> textarea {
white-space: pre;
}
}
}
}
</style>

View file

@ -0,0 +1,242 @@
<template>
<div class="mk-uploader">
<ol v-if="uploads.length > 0">
<li v-for="ctx in uploads" :key="ctx.id">
<div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div>
<div class="top">
<p class="name"><fa icon="spinner" pulse/>{{ ctx.name }}</p>
<p class="status">
<span class="initing" v-if="ctx.progress == undefined">{{ $t('waiting') }}<mk-ellipsis/></span>
<span class="kb" v-if="ctx.progress != undefined">{{ String(Math.floor(ctx.progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
<span class="percentage" v-if="ctx.progress != undefined">{{ Math.floor((ctx.progress.value / ctx.progress.max) * 100) }}</span>
</p>
</div>
<progress v-if="ctx.progress != undefined && ctx.progress.value != ctx.progress.max" :value="ctx.progress.value" :max="ctx.progress.max"></progress>
<div class="progress initing" v-if="ctx.progress == undefined"></div>
<div class="progress waiting" v-if="ctx.progress != undefined && ctx.progress.value == ctx.progress.max"></div>
</li>
</ol>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import { apiUrl } from '../config';
//import getMD5 from '../../scripts/get-md5';
export default Vue.extend({
i18n,
data() {
return {
uploads: []
};
},
methods: {
checkExistence(fileData: ArrayBuffer): Promise<any> {
return new Promise((resolve, reject) => {
const data = new FormData();
data.append('md5', getMD5(fileData));
this.$root.api('drive/files/find-by-hash', {
md5: getMD5(fileData)
}).then(resp => {
resolve(resp.length > 0 ? resp[0] : null);
});
});
},
upload(file: File, folder: any, name?: string) {
if (folder && typeof folder == 'object') folder = folder.id;
const id = Math.random();
const reader = new FileReader();
reader.onload = (e: any) => {
const ctx = {
id: id,
name: name || file.name || 'untitled',
progress: undefined,
img: window.URL.createObjectURL(file)
};
this.uploads.push(ctx);
this.$emit('change', this.uploads);
const data = new FormData();
data.append('i', this.$store.state.i.token);
data.append('force', 'true');
data.append('file', file);
if (folder) data.append('folderId', folder);
if (name) data.append('name', name);
const xhr = new XMLHttpRequest();
xhr.open('POST', apiUrl + '/drive/files/create', true);
xhr.onload = (e: any) => {
const driveFile = JSON.parse(e.target.response);
this.$emit('uploaded', driveFile);
this.uploads = this.uploads.filter(x => x.id != id);
this.$emit('change', this.uploads);
};
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
if (ctx.progress == undefined) ctx.progress = {};
ctx.progress.max = e.total;
ctx.progress.value = e.loaded;
}
};
xhr.send(data);
}
reader.readAsArrayBuffer(file);
}
}
});
</script>
<style lang="scss" scoped>
.mk-uploader {
overflow: auto;
}
.mk-uploader:empty {
display: none;
}
.mk-uploader > ol {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
.mk-uploader > ol > li {
display: grid;
margin: 8px 0 0 0;
padding: 0;
height: 36px;
width: 100%;
box-shadow: 0 -1px 0 var(--accentAlpha01);
border-top: solid 8px transparent;
grid-template-columns: 36px calc(100% - 44px);
grid-template-rows: 1fr 8px;
column-gap: 8px;
box-sizing: content-box;
}
.mk-uploader > ol > li:first-child {
margin: 0;
box-shadow: none;
border-top: none;
}
.mk-uploader > ol > li > .img {
display: block;
background-size: cover;
background-position: center center;
grid-column: 1/2;
grid-row: 1/3;
}
.mk-uploader > ol > li > .top {
display: flex;
grid-column: 2/3;
grid-row: 1/2;
}
.mk-uploader > ol > li > .top > .name {
display: block;
padding: 0 8px 0 0;
margin: 0;
font-size: 0.8em;
color: var(--accentAlpha07);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex-shrink: 1;
}
.mk-uploader > ol > li > .top > .name > [data-icon] {
margin-right: 4px;
}
.mk-uploader > ol > li > .top > .status {
display: block;
margin: 0 0 0 auto;
padding: 0;
font-size: 0.8em;
flex-shrink: 0;
}
.mk-uploader > ol > li > .top > .status > .initing {
color: var(--accentAlpha05);
}
.mk-uploader > ol > li > .top > .status > .kb {
color: var(--accentAlpha05);
}
.mk-uploader > ol > li > .top > .status > .percentage {
display: inline-block;
width: 48px;
text-align: right;
color: var(--accentAlpha07);
}
.mk-uploader > ol > li > .top > .status > .percentage:after {
content: '%';
}
.mk-uploader > ol > li > progress {
display: block;
background: transparent;
border: none;
border-radius: 4px;
overflow: hidden;
grid-column: 2/3;
grid-row: 2/3;
z-index: 2;
}
.mk-uploader > ol > li > progress::-webkit-progress-value {
background: var(--accent);
}
.mk-uploader > ol > li > progress::-webkit-progress-bar {
background: var(--accentAlpha01);
}
.mk-uploader > ol > li > .progress {
display: block;
border: none;
border-radius: 4px;
background: linear-gradient(45deg, var(--accentLighten30) 25%, var(--accent) 25%, var(--accent) 50%, var(--accentLighten30) 50%, var(--accentLighten30) 75%, var(--accent) 75%, var(--accent));
background-size: 32px 32px;
animation: bg 1.5s linear infinite;
grid-column: 2/3;
grid-row: 2/3;
z-index: 1;
}
.mk-uploader > ol > li > .progress.initing {
opacity: 0.3;
}
@-moz-keyframes bg {
from {
background-position: 0 0;
}
to {
background-position: -64px 32px;
}
}
@-webkit-keyframes bg {
from {
background-position: 0 0;
}
to {
background-position: -64px 32px;
}
}
@-o-keyframes bg {
from {
background-position: 0 0;
}
to {
background-position: -64px 32px;
}
}
@keyframes bg {
from {
background-position: 0 0;
}
to {
background-position: -64px 32px;
}
}
</style>

View file

@ -0,0 +1,331 @@
<template>
<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
<button class="disablePlayer" @click="playerEnabled = false" :title="$t('disable-player')"><fa icon="times"/></button>
<iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen />
</div>
<div v-else-if="tweetUrl && detail" class="twitter">
<blockquote ref="tweet" class="twitter-tweet" :data-theme="$store.state.device.darkmode ? 'dark' : null">
<a :href="url"></a>
</blockquote>
</div>
<div v-else class="mk-url-preview" v-size="[{ max: 400 }, { max: 350 }]">
<transition name="zoom" mode="out-in">
<component :is="hasRoute ? 'router-link' : 'a'" :class="{ compact }" :[attr]="hasRoute ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching">
<div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`">
<button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$t('enable-player')"><fa :icon="faPlayCircle"/></button>
</div>
<article>
<header>
<h1 :title="title">{{ title }}</h1>
</header>
<p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p>
<footer>
<img class="icon" v-if="icon" :src="icon"/>
<p :title="sitename">{{ sitename }}</p>
</footer>
</article>
</component>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPlayCircle } from '@fortawesome/free-regular-svg-icons';
import i18n from '../i18n';
import { url as local, lang } from '../config';
export default Vue.extend({
i18n,
props: {
url: {
type: String,
require: true
},
detail: {
type: Boolean,
required: false,
default: false
},
compact: {
type: Boolean,
required: false,
default: false
},
},
data() {
const isSelf = this.url.startsWith(local);
const hasRoute =
(this.url.substr(local.length) === '/') ||
this.url.substr(local.length).startsWith('/@') ||
this.url.substr(local.length).startsWith('/notes/') ||
this.url.substr(local.length).startsWith('/tags/');
return {
local,
fetching: true,
title: null,
description: null,
thumbnail: null,
icon: null,
sitename: null,
player: {
url: null,
width: null,
height: null
},
tweetUrl: null,
playerEnabled: false,
self: isSelf,
hasRoute: hasRoute,
attr: hasRoute ? 'to' : 'href',
target: hasRoute ? null : '_blank',
faPlayCircle
};
},
created() {
const requestUrl = new URL(this.url);
if (this.detail && requestUrl.hostname == 'twitter.com' && /^\/.+\/status(es)?\/\d+/.test(requestUrl.pathname)) {
this.tweetUrl = requestUrl;
const twttr = (window as any).twttr || {};
const loadTweet = () => twttr.widgets.load(this.$refs.tweet);
if (twttr.widgets) {
Vue.nextTick(loadTweet);
} else {
const wjsId = 'twitter-wjs';
if (!document.getElementById(wjsId)) {
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('id', wjsId);
script.setAttribute('src', 'https://platform.twitter.com/widgets.js');
head.appendChild(script);
}
twttr.ready = loadTweet;
(window as any).twttr = twttr;
}
return;
}
if (requestUrl.hostname === 'music.youtube.com') {
requestUrl.hostname = 'youtube.com';
}
const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP');
requestUrl.hash = '';
fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
res.json().then(info => {
if (info.url == null) return;
this.title = info.title;
this.description = info.description;
this.thumbnail = info.thumbnail;
this.icon = info.icon;
this.sitename = info.sitename;
this.fetching = false;
this.player = info.player;
})
});
}
});
</script>
<style lang="scss" scoped>
.player {
position: relative;
width: 100%;
> button {
position: absolute;
top: -1.5em;
right: 0;
font-size: 1em;
width: 1.5em;
height: 1.5em;
padding: 0;
margin: 0;
color: var(--fg);
background: rgba(128, 128, 128, 0.2);
opacity: 0.7;
&:hover {
opacity: 0.9;
}
}
> iframe {
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
}
.mk-url-preview {
&.max-width_400px {
> a {
font-size: 12px;
> .thumbnail {
height: 80px;
}
> article {
padding: 12px;
}
}
}
&.max-width_350px {
> a {
font-size: 10px;
> .thumbnail {
height: 70px;
}
> article {
padding: 8px;
> header {
margin-bottom: 4px;
}
> footer {
margin-top: 4px;
> img {
width: 12px;
height: 12px;
}
}
}
&.compact {
> .thumbnail {
position: absolute;
width: 56px;
height: 100%;
}
> article {
left: 56px;
width: calc(100% - 56px);
padding: 4px;
> header {
margin-bottom: 2px;
}
> footer {
margin-top: 2px;
}
}
}
}
}
> a {
position: relative;
display: block;
font-size: 14px;
box-shadow: 0 1px 4px var(--tyvedwbe);
border-radius: 4px;
overflow: hidden;
&:hover {
text-decoration: none;
border-color: rgba(0, 0, 0, 0.2);
> article > header > h1 {
text-decoration: underline;
}
}
> .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 {
position: relative;
box-sizing: border-box;
padding: 16px;
> header {
margin-bottom: 8px;
> h1 {
margin: 0;
font-size: 1em;
}
}
> p {
margin: 0;
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;
}
}
}
&.compact {
> article {
> header h1, p, footer {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
}
}
</style>

View file

@ -0,0 +1,95 @@
<template>
<component :is="hasRoute ? 'router-link' : 'a'" class="mk-url" :[attr]="hasRoute ? url.substr(local.length) : url" :rel="rel" :target="target">
<template v-if="!self">
<span class="schema">{{ schema }}//</span>
<span class="hostname">{{ hostname }}</span>
<span class="port" v-if="port != ''">:{{ port }}</span>
</template>
<template v-if="pathname === '/' && self">
<span class="self">{{ hostname }}</span>
</template>
<span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span>
<span class="query">{{ query }}</span>
<span class="hash">{{ hash }}</span>
<fa :icon="faExternalLinkSquareAlt" v-if="target === '_blank'" class="icon"/>
</component>
</template>
<script lang="ts">
import Vue from 'vue';
import { faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
import { toUnicode as decodePunycode } from 'punycode';
import { url as local } from '../config';
export default Vue.extend({
props: ['url', 'rel'],
data() {
const isSelf = this.url.startsWith(local);
const hasRoute = isSelf && (
(this.url.substr(local.length) === '/') ||
this.url.substr(local.length).startsWith('/@') ||
this.url.substr(local.length).startsWith('/notes/') ||
this.url.substr(local.length).startsWith('/tags/'));
return {
local,
schema: null,
hostname: null,
port: null,
pathname: null,
query: null,
hash: null,
self: isSelf,
hasRoute: hasRoute,
attr: hasRoute ? 'to' : 'href',
target: hasRoute ? null : '_blank',
faExternalLinkSquareAlt
};
},
created() {
const url = new URL(this.url);
this.schema = url.protocol;
this.hostname = decodePunycode(url.hostname);
this.port = url.port;
this.pathname = decodeURIComponent(url.pathname);
this.query = decodeURIComponent(url.search);
this.hash = decodeURIComponent(url.hash);
}
});
</script>
<style lang="scss" scoped>
.mk-url {
word-break: break-all;
> .icon {
padding-left: 2px;
font-size: .9em;
font-weight: 400;
font-style: normal;
}
> .self {
font-weight: bold;
}
> .schema {
opacity: 0.5;
}
> .hostname {
font-weight: bold;
}
> .pathname {
opacity: 0.8;
}
> .query {
opacity: 0.5;
}
> .hash {
font-style: italic;
}
}
</style>

View file

@ -0,0 +1,148 @@
<template>
<mk-container :body-togglable="true" :expanded="expanded">
<template #header><slot></slot></template>
<mk-error v-if="error" @retry="init()"/>
<div class="efvhhmdq">
<div class="no-users" v-if="empty">
<p>{{ $t('no-users') }}</p>
</div>
<div class="user" v-for="user in users" :key="user.id">
<mk-avatar class="avatar" :user="user"/>
<div class="body">
<div class="name">
<router-link class="name" :to="user | userPage" v-user-preview="user.id"><mk-user-name :user="user"/></router-link>
<span class="username"><mk-acct :user="user"/></span>
</div>
<div class="description">
<mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
<span v-else class="empty">{{ $t('noAccountDescription') }}</span>
</div>
</div>
<x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/>
</div>
<button class="more" :class="{ fetching: moreFetching }" v-if="more" @click="fetchMore()" :disabled="moreFetching">
<template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? $t('@.loading') : $t('@.load-more') }}
</button>
</div>
</mk-container>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import paging from '../scripts/paging';
import MkContainer from './ui/container.vue';
import XFollowButton from './follow-button.vue';
export default Vue.extend({
i18n,
components: {
MkContainer,
XFollowButton,
},
mixins: [
paging({}),
],
props: {
pagination: {
required: true
},
extract: {
required: false
},
expanded: {
type: Boolean,
default: true
},
},
computed: {
users() {
return this.extract ? this.extract(this.items) : this.items;
}
}
});
</script>
<style lang="scss" scoped>
.efvhhmdq {
> .no-users {
text-align: center;
}
> .user {
position: relative;
display: flex;
padding: 16px;
border-bottom: solid 1px var(--divider);
&:last-child {
border-bottom: none;
}
> .avatar {
display: block;
flex-shrink: 0;
margin: 0 12px 0 0;
width: 42px;
height: 42px;
border-radius: 8px;
}
> .body {
flex: 1;
> .name {
font-weight: bold;
> .name {
margin-right: 8px;
}
> .username {
opacity: 0.7;
}
}
> .description {
font-size: 90%;
> .empty {
opacity: 0.7;
}
}
}
> .koudoku-button {
flex-shrink: 0;
}
}
> .more {
display: block;
width: 100%;
padding: 16px;
&:hover {
background: rgba(#000, 0.025);
}
&:active {
background: rgba(#000, 0.05);
}
&.fetching {
cursor: wait;
}
> [data-icon] {
margin-right: 4px;
}
}
}
</style>

View file

@ -0,0 +1,188 @@
<template>
<x-menu :source="source" :items="items" @closed="$emit('closed')"/>
</template>
<script lang="ts">
import Vue from 'vue';
import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments } from '@fortawesome/free-solid-svg-icons';
import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons';
import i18n from '../i18n';
import XMenu from './menu.vue';
import copyToClipboard from '../scripts/copy-to-clipboard';
import { host } from '../config';
import getAcct from '../../misc/acct/render';
export default Vue.extend({
i18n,
components: {
XMenu
},
props: ['user', 'source'],
data() {
let menu = [{
icon: faAt,
text: this.$t('copyUsername'),
action: () => {
copyToClipboard(`@${this.user.username}@${this.user.host || host}`);
}
}, {
icon: faEnvelope,
text: this.$t('sendMessage'),
action: () => {
this.$root.post({ specified: this.user });
}
}, this.$store.state.i.id != this.user.id ? {
type: 'link',
to: `/my/messaging/${getAcct(this.user)}`,
icon: faComments,
text: this.$t('startMessaging'),
} : undefined, null, {
icon: faListUl,
text: this.$t('addToList'),
action: this.pushList
}] as any;
if (this.$store.getters.isSignedIn && this.$store.state.i.id != this.user.id) {
menu = menu.concat([null, {
icon: this.user.isMuted ? faEye : faEyeSlash,
text: this.user.isMuted ? this.$t('unmute') : this.$t('mute'),
action: this.toggleMute
}, {
icon: faBan,
text: this.user.isBlocking ? this.$t('unblock') : this.$t('block'),
action: this.toggleBlock
}]);
if (this.$store.state.i.isAdmin) {
menu = menu.concat([null, {
icon: faSnowflake,
text: this.user.isSuspended ? this.$t('unsuspend') : this.$t('suspend'),
action: this.toggleSuspend
}]);
}
}
if (this.$store.getters.isSignedIn && this.$store.state.i.id === this.user.id) {
menu = menu.concat([null, {
icon: faPencilAlt,
text: this.$t('editProfile'),
action: () => {
this.$router.push('/my/settings');
}
}]);
}
return {
items: menu
};
},
methods: {
async pushList() {
const t = this.$t('selectList'); // null
const lists = await this.$root.api('users/lists/list');
if (lists.length === 0) {
this.$root.dialog({
type: 'error',
text: this.$t('youHaveNoLists')
});
return;
}
const { canceled, result: listId } = await this.$root.dialog({
type: null,
title: t,
select: {
items: lists.map(list => ({
value: list.id, text: list.name
}))
},
showCancelButton: true
});
if (canceled) return;
this.$root.api('users/lists/push', {
listId: listId,
userId: this.user.id
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
},
async toggleMute() {
this.$root.api(this.user.isMuted ? 'mute/delete' : 'mute/create', {
userId: this.user.id
}).then(() => {
this.user.isMuted = !this.user.isMuted;
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}, e => {
this.$root.dialog({
type: 'error',
text: e
});
});
},
async toggleBlock() {
if (!await this.getConfirmed(this.user.isBlocking ? this.$t('unblockConfirm') : this.$t('blockConfirm'))) return;
this.$root.api(this.user.isBlocking ? 'blocking/delete' : 'blocking/create', {
userId: this.user.id
}).then(() => {
this.user.isBlocking = !this.user.isBlocking;
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}, e => {
this.$root.dialog({
type: 'error',
text: e
});
});
},
async toggleSuspend() {
if (!await this.getConfirmed(this.$t(this.user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return;
this.$root.api(this.user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', {
userId: this.user.id
}).then(() => {
this.user.isSuspended = !this.user.isSuspended;
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}, e => {
this.$root.dialog({
type: 'error',
text: e
});
});
},
async getConfirmed(text: string): Promise<Boolean> {
const confirm = await this.$root.dialog({
type: 'warning',
showCancelButton: true,
title: 'confirm',
text,
});
return !confirm.canceled;
},
}
});
</script>

View file

@ -0,0 +1,108 @@
<template>
<x-window @closed="() => { $emit('closed'); destroyDom(); }" :avatar="user">
<template #header><mk-user-name :user="user"/></template>
<div class="vrcsvlkm">
<mk-button @click="changePassword()">{{ $t('changePassword') }}</mk-button>
<mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch>
<mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch>
</div>
</x-window>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import MkButton from './ui/button.vue';
import MkSwitch from './ui/switch.vue';
import XWindow from './window.vue';
export default Vue.extend({
i18n,
components: {
MkButton,
MkSwitch,
XWindow,
},
props: {
user: {
type: Object,
required: true
}
},
data() {
return {
silenced: this.user.isSilenced,
suspended: this.user.isSuspended,
};
},
methods: {
async changePassword() {
const { canceled: canceled, result: newPassword } = await this.$root.dialog({
title: this.$t('newPassword'),
input: {
type: 'password'
}
});
if (canceled) return;
const dialog = this.$root.dialog({
type: 'waiting',
iconOnly: true
});
this.$root.api('admin/change-password', {
userId: this.user.id,
newPassword
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
}).finally(() => {
dialog.close();
});
},
async toggleSilence() {
const confirm = await this.$root.dialog({
type: 'warning',
showCancelButton: true,
text: this.silenced ? this.$t('silenceConfirm') : this.$t('unsilenceConfirm'),
});
if (confirm.canceled) {
this.silenced = !this.silenced;
} else {
this.$root.api(this.silenced ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
}
},
async toggleSuspend() {
const confirm = await this.$root.dialog({
type: 'warning',
showCancelButton: true,
text: this.suspended ? this.$t('suspendConfirm') : this.$t('unsuspendConfirm'),
});
if (confirm.canceled) {
this.suspended = !this.suspended;
} else {
this.$root.api(this.silenced ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
}
}
}
});
</script>
<style lang="scss" scoped>
.vrcsvlkm {
}
</style>

View file

@ -0,0 +1,20 @@
<template>
<mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
user: {
type: Object,
required: true
},
nowrap: {
type: Boolean,
default: true
},
}
});
</script>

View file

@ -0,0 +1,181 @@
<template>
<transition name="popup" appear @after-leave="() => { $emit('closed'); destroyDom(); }">
<div v-if="show" class="fxxzrfni _panel" ref="content" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }">
<div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl})` : ''"></div>
<mk-avatar class="avatar" :user="u" :disable-preview="true"/>
<div class="title">
<router-link class="name" :to="u | userPage"><mk-user-name :user="u" :nowrap="false"/></router-link>
<p class="username"><mk-acct :user="u"/></p>
</div>
<div class="description">
<mfm v-if="u.description" :text="u.description" :author="u" :i="$store.state.i" :custom-emojis="u.emojis"/>
</div>
<div class="status">
<div>
<p>{{ $t('notes') }}</p><span>{{ u.notesCount }}</span>
</div>
<div>
<p>{{ $t('following') }}</p><span>{{ u.followingCount }}</span>
</div>
<div>
<p>{{ $t('followers') }}</p><span>{{ u.followersCount }}</span>
</div>
</div>
<x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && u.id != $store.state.i.id" :user="u" mini/>
</div>
</transition>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import parseAcct from '../../misc/acct/parse';
import XFollowButton from './follow-button.vue';
export default Vue.extend({
i18n,
components: {
XFollowButton
},
props: {
user: {
type: [Object, String],
required: true
},
source: {
required: true
}
},
data() {
return {
u: null,
show: false,
top: 0,
left: 0,
};
},
mounted() {
if (typeof this.user == 'object') {
this.u = this.user;
this.show = true;
} else {
const query = this.user.startsWith('@') ?
parseAcct(this.user.substr(1)) :
{ userId: this.user };
this.$root.api('users/show', query).then(user => {
this.u = user;
this.show = true;
});
}
const rect = this.source.getBoundingClientRect();
const x = ((rect.left + (this.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset;
const y = rect.top + this.source.offsetHeight + window.pageYOffset;
this.top = y;
this.left = x;
},
methods: {
close() {
this.show = false;
(this.$refs.content as any).style.pointerEvents = 'none';
}
}
});
</script>
<style lang="scss" scoped>
.popup-enter-active, .popup-leave-active {
transition: opacity 0.3s, transform 0.3s !important;
}
.popup-enter, .popup-leave-to {
opacity: 0;
transform: scale(0.9);
}
.fxxzrfni {
position: absolute;
z-index: 11000;
width: 300px;
overflow: hidden;
> .banner {
height: 84px;
background-color: rgba(0, 0, 0, 0.1);
background-size: cover;
background-position: center;
}
> .avatar {
display: block;
position: absolute;
top: 62px;
left: 13px;
z-index: 2;
width: 58px;
height: 58px;
border: solid 3px var(--face);
border-radius: 8px;
}
> .title {
display: block;
padding: 8px 0 8px 82px;
> .name {
display: inline-block;
margin: 0;
font-weight: bold;
line-height: 16px;
word-break: break-all;
}
> .username {
display: block;
margin: 0;
line-height: 16px;
font-size: 0.8em;
color: var(--text);
opacity: 0.7;
}
}
> .description {
padding: 0 16px;
font-size: 0.8em;
color: var(--text);
}
> .status {
padding: 8px 16px;
> div {
display: inline-block;
width: 33%;
> p {
margin: 0;
font-size: 0.7em;
color: var(--text);
}
> span {
font-size: 1em;
color: var(--accent);
}
}
}
> .koudoku-button {
position: absolute;
top: 8px;
right: 8px;
}
}
</style>

View file

@ -0,0 +1,152 @@
<template>
<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="selected == null" @ok="ok()">
<template #header>{{ $t('selectUser') }}</template>
<div class="tbhwbxda">
<div class="inputs">
<mk-input v-model="username" class="input" @input="search" ref="username"><span>{{ $t('username') }}</span><template #prefix>@</template></mk-input>
<mk-input v-model="host" class="input" @input="search"><span>{{ $t('host') }}</span><template #prefix>@</template></mk-input>
</div>
<div class="users">
<div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
<mk-avatar :user="user" class="avatar" :disable-link="true"/>
<div class="body">
<mk-user-name :user="user" class="name"/>
<mk-acct :user="user" class="acct"/>
</div>
</div>
</div>
</div>
</x-window>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons';
import MkInput from './ui/input.vue';
import XWindow from './window.vue';
export default Vue.extend({
i18n,
components: {
MkInput,
XWindow,
},
props: {
},
data() {
return {
username: '',
host: '',
users: [],
selected: null,
faTimes, faCheck
};
},
mounted() {
this.focus();
this.$nextTick(() => {
this.focus();
});
},
methods: {
search() {
if (this.username == '' && this.host == '') {
this.users = [];
return;
}
this.$root.api('users/search-by-username-and-host', {
username: this.username,
host: this.host,
limit: 10,
detail: false
}).then(users => {
this.users = users;
});
},
focus() {
this.$refs.username.focus();
},
close() {
this.$refs.window.close();
},
ok() {
this.$emit('selected', this.selected);
this.close();
},
}
});
</script>
<style lang="scss" scoped>
.tbhwbxda {
display: flex;
flex-direction: column;
overflow: auto;
height: 100%;
> .inputs {
margin-top: 16px;
> .input {
display: inline-block;
width: 50%;
margin: 0;
}
}
> .users {
flex: 1;
overflow: auto;
> .user {
display: flex;
align-items: center;
padding: 8px 16px;
font-size: 14px;
&:hover {
background: var(--bwqtlupy);
}
&.selected {
background: var(--accent);
color: #fff;
}
> * {
pointer-events: none;
user-select: none;
}
> .avatar {
width: 45px;
height: 45px;
}
> .body {
padding: 0 8px;
min-width: 0;
> .name {
display: block;
font-weight: bold;
}
> .acct {
opacity: 0.5;
}
}
}
}
}
</style>

View file

@ -0,0 +1,161 @@
<template>
<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }">
<div class="mk-users-dialog">
<div class="header">
<span>{{ title }}</span>
<button class="_button" @click="close()"><fa :icon="faTimes"/></button>
</div>
<sequential-entrance class="users">
<router-link v-for="(item, i) in items" class="user" :key="item.id" :data-index="i" :to="extract ? extract(item) : item | userPage">
<mk-avatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true"/>
<div class="body">
<mk-user-name :user="extract ? extract(item) : item" class="name"/>
<mk-acct :user="extract ? extract(item) : item" class="acct"/>
</div>
</router-link>
</sequential-entrance>
<button class="more _button" v-if="more" @click="fetchMore" :disabled="moreFetching">
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
<template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template>
</button>
<p class="empty" v-if="empty">{{ $t('noUsers') }}</p>
<mk-error v-if="error" @retry="init()"/>
</div>
</x-modal>
</template>
<script lang="ts">
import Vue from 'vue';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n';
import paging from '../scripts/paging';
import XModal from './modal.vue';
export default Vue.extend({
i18n,
components: {
XModal,
},
mixins: [
paging({}),
],
props: {
title: {
required: true
},
pagination: {
required: true
},
extract: {
required: false
}
},
data() {
return {
faTimes
};
},
methods: {
close() {
this.$refs.modal.close();
},
}
});
</script>
<style lang="scss" scoped>
.mk-users-dialog {
width: 350px;
height: 350px;
background: var(--panel);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
> .header {
display: flex;
flex-shrink: 0;
> button {
height: 58px;
width: 58px;
@media (max-width: 500px) {
height: 42px;
width: 42px;
}
}
> span {
flex: 1;
line-height: 58px;
padding-left: 32px;
font-weight: bold;
@media (max-width: 500px) {
line-height: 42px;
padding-left: 16px;
}
}
}
> .users {
flex: 1;
overflow: auto;
&:empty {
display: none;
}
> .user {
display: flex;
align-items: center;
font-size: 14px;
padding: 8px 32px;
@media (max-width: 500px) {
padding: 8px 16px;
}
> * {
pointer-events: none;
user-select: none;
}
> .avatar {
width: 45px;
height: 45px;
}
> .body {
padding: 0 8px;
overflow: hidden;
> .name {
display: block;
font-weight: bold;
}
> .acct {
opacity: 0.5;
}
}
}
}
> .empty {
text-align: center;
opacity: 0.5;
}
}
</style>

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