Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>
Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
This commit is contained in:
syuilo
2020-01-30 04:37:25 +09:00
committed by GitHub
parent a5955c1123
commit f6154dc0af
871 changed files with 26140 additions and 71950 deletions

View File

@@ -0,0 +1,264 @@
<template>
<section class="_section">
<div class="_title"><fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div>
<div class="_content">
<p v-if="!data && !$store.state.i.twoFactorEnabled"><mk-button @click="register">{{ $t('_2fa.registerDevice') }}</mk-button></p>
<template v-if="$store.state.i.twoFactorEnabled">
<h2 class="heading">{{ $t('totp-header') }}</h2>
<p>{{ $t('already-registered') }}</p>
<mk-button @click="unregister">{{ $t('unregister') }}</mk-button>
<template v-if="supportsCredentials">
<hr class="totp-method-sep">
<h2 class="heading">{{ $t('security-key-header') }}</h2>
<p>{{ $t('security-key') }}</p>
<div class="key-list">
<div class="key" v-for="key in $store.state.i.securityKeysList">
<h3>
{{ key.name }}
</h3>
<div class="last-used">
{{ $t('last-used') }}
<mk-time :time="key.lastUsed"/>
</div>
<mk-button @click="unregisterKey(key)">
{{ $t('unregister') }}
</mk-button>
</div>
</div>
<mk-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">
{{ $t('use-password-less-login') }}
</mk-switch>
<mk-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</mk-info>
<mk-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</mk-button>
<ol v-if="registration && !registration.error">
<li v-if="registration.stage >= 0">
{{ $t('activate-key') }}
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
</li>
<li v-if="registration.stage >= 1">
<mk-form :disabled="registration.stage != 1 || registration.saving">
<mk-input v-model="keyName" :max="30">
<span>{{ $t('security-key-name') }}</span>
</mk-input>
<mk-button @click="registerKey" :disabled="this.keyName.length == 0">
{{ $t('register-security-key') }}
</mk-button>
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
</mk-form>
</li>
</ol>
</template>
</template>
<div v-if="data && !$store.state.i.twoFactorEnabled">
<ol style="margin: 0; padding: 0 0 0 1em;">
<li>
<i18n path="_2fa.step1" tag="span">
<a href="https://authy.com/" rel="noopener" target="_blank" place="a" style="color: var(--link);">Authy</a>
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" place="b" style="color: var(--link);">Google Authenticator</a>
</i18n>
</li>
<li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li>
<li>{{ $t('_2fa.step3') }}<br>
<mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</mk-input>
<mk-button primary @click="submit">{{ $t('done') }}</mk-button>
</li>
</ol>
<mk-info>{{ $t('_2fa.step4') }}</mk-info>
</div>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import i18n from '../../i18n';
import { hostname } from '../../config';
import { hexifyAB } from '../../scripts/2fa';
import MkButton from '../../components/ui/button.vue';
import MkInfo from '../../components/ui/info.vue';
import MkInput from '../../components/ui/input.vue';
function stringifyAB(buffer) {
return String.fromCharCode.apply(null, new Uint8Array(buffer));
}
export default Vue.extend({
i18n,
components: {
MkButton, MkInfo, MkInput
},
data() {
return {
data: null,
supportsCredentials: !!navigator.credentials,
usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin,
registration: null,
keyName: '',
token: null,
faLock
};
},
methods: {
register() {
this.$root.dialog({
title: this.$t('password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
this.$root.api('i/2fa/register', {
password: password
}).then(data => {
this.data = data;
});
});
},
unregister() {
this.$root.dialog({
title: this.$t('password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
this.$root.api('i/2fa/unregister', {
password: password
}).then(() => {
this.usePasswordLessLogin = false;
this.updatePasswordLessLogin();
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
this.$store.state.i.twoFactorEnabled = false;
});
});
},
submit() {
this.$root.api('i/2fa/done', {
token: this.token
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
this.$store.state.i.twoFactorEnabled = true;
}).catch(e => {
this.$root.dialog({
type: 'error',
iconOnly: true, autoClose: true
});
});
},
registerKey() {
this.registration.saving = true;
this.$root.api('i/2fa/key-done', {
password: this.registration.password,
name: this.keyName,
challengeId: this.registration.challengeId,
// we convert each 16 bits to a string to serialise
clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON),
attestationObject: hexifyAB(this.registration.credential.response.attestationObject)
}).then(key => {
this.registration = null;
key.lastUsed = new Date();
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
})
},
unregisterKey(key) {
this.$root.dialog({
title: this.$t('password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
return this.$root.api('i/2fa/remove-key', {
password,
credentialId: key.id
}).then(() => {
this.usePasswordLessLogin = false;
this.updatePasswordLessLogin();
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
});
});
},
addSecurityKey() {
this.$root.dialog({
title: this.$t('password'),
input: {
type: 'password'
}
}).then(({ canceled, result: password }) => {
if (canceled) return;
this.$root.api('i/2fa/register-key', {
password
}).then(registration => {
this.registration = {
password,
challengeId: registration.challengeId,
stage: 0,
publicKeyOptions: {
challenge: Buffer.from(
registration.challenge
.replace(/\-/g, "+")
.replace(/_/g, "/"),
'base64'
),
rp: {
id: hostname,
name: 'Misskey'
},
user: {
id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)),
name: this.$store.state.i.username,
displayName: this.$store.state.i.name,
},
pubKeyCredParams: [{alg: -7, type: 'public-key'}],
timeout: 60000,
attestation: 'direct'
},
saving: true
};
return navigator.credentials.create({
publicKey: this.registration.publicKeyOptions
});
}).then(credential => {
this.registration.credential = credential;
this.registration.saving = false;
this.registration.stage = 1;
}).catch(err => {
console.warn('Error while registering?', err);
this.registration.error = err.message;
this.registration.stage = -1;
});
});
},
updatePasswordLessLogin() {
this.$root.api('i/2fa/password-less', {
value: !!this.usePasswordLessLogin
});
}
}
});
</script>

View File

@@ -0,0 +1,212 @@
<template>
<section class="mk-settings-page-drive _section">
<div class="_title"><fa :icon="faCloud"/> {{ $t('drive') }}</div>
<div class="_content">
<mk-pagination :pagination="drivePagination" #default="{items}" class="drive" ref="drive">
<div class="file" v-for="(file, i) in items" :key="file.id" :data-index="i" @click="selected = file" :class="{ selected: selected && (selected.id === file.id) }">
<x-file-thumbnail class="thumbnail" :file="file" fit="cover"/>
<div class="body">
<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>
<footer>
<span class="type"><x-file-type-icon :type="file.type" class="icon"/>{{ file.type }}</span>
<span class="separator"></span>
<span class="data-size">{{ file.size | bytes }}</span>
<span class="separator"></span>
<span class="created-at"><fa :icon="faClock"/><mk-time :time="file.createdAt"/></span>
<template v-if="file.isSensitive">
<span class="separator"></span>
<span class="nsfw"><fa :icon="faEyeSlash"/> {{ $t('nsfw') }}</span>
</template>
</footer>
</div>
</div>
</mk-pagination>
</div>
<div class="_footer">
<mk-button primary inline :disabled="selected == null" @click="download()"><fa :icon="faDownload"/> {{ $t('download') }}</mk-button>
<mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faCloud, faDownload } from '@fortawesome/free-solid-svg-icons';
import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
import XFileTypeIcon from '../../components/file-type-icon.vue';
import XFileThumbnail from '../../components/drive-file-thumbnail.vue';
import MkButton from '../../components/ui/button.vue';
import MkPagination from '../../components/ui/pagination.vue';
import i18n from '../../i18n';
export default Vue.extend({
i18n,
components: {
XFileTypeIcon,
XFileThumbnail,
MkPagination,
MkButton,
},
data() {
return {
selected: null,
connection: null,
drivePagination: {
endpoint: 'drive/files',
limit: 10,
},
faCloud, faClock, faEyeSlash, faDownload, faTrashAlt
}
},
created() {
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);
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onStreamDriveFileCreated(file) {
this.$refs.drive.prepend(file);
},
onStreamDriveFileUpdated(file) {
// TODO
},
onStreamDriveFileDeleted(fileId) {
this.$refs.drive.remove(x => x.id === fileId);
},
download() {
window.open(this.selected.url, '_blank');
},
async del() {
const { canceled } = await this.$root.dialog({
type: 'warning',
text: this.$t('driveFileDeleteConfirm', { name: this.selected.name }),
showCancelButton: true
});
if (canceled) return;
this.$root.api('drive/files/delete', {
fileId: this.selected.id
});
}
}
});
</script>
<style lang="scss" scoped>
.mk-settings-page-drive {
> ._content {
max-height: 350px;
overflow: auto;
> .drive {
> .file {
display: grid;
margin: 0 auto;
grid-template-columns: 64px 1fr;
grid-column-gap: 10px;
cursor: pointer;
&.selected {
background: var(--accent);
box-shadow: 0 0 0 8px var(--accent);
color: #fff;
}
&:not(:last-child) {
margin-bottom: 16px;
}
> .thumbnail {
width: 64px;
height: 64px;
}
> .body {
display: block;
word-break: break-all;
padding-top: 4px;
> .name {
display: block;
margin: 0;
padding: 0;
font-size: 0.9em;
font-weight: bold;
word-break: break-word;
> .ext {
opacity: 0.5;
}
}
> .tags {
display: block;
margin: 4px 0 0 0;
padding: 0;
list-style: none;
font-size: 0.5em;
> .tag {
display: inline-block;
margin: 0 5px 0 0;
padding: 1px 5px;
border-radius: 2px;
}
}
> footer {
display: block;
margin: 4px 0 0 0;
font-size: 0.7em;
> .separator {
padding: 0 4px;
}
> .type {
opacity: 0.7;
> .icon {
margin-right: 4px;
}
}
> .data-size {
opacity: 0.7;
}
> .created-at {
opacity: 0.7;
> [data-icon] {
margin-right: 2px;
}
}
> .nsfw {
color: #bf4633;
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<section class="mk-settings-page-general _section">
<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
<div class="_content">
<mk-input type="file" @change="onWallpaperChange" style="margin-top: 0;">
<span>{{ $t('wallpaper') }}</span>
<template #icon><fa :icon="faImage"/></template>
<template #desc v-if="wallpaperUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
</mk-input>
<mk-button primary :disabled="$store.state.settings.wallpaper == null" @click="delWallpaper()">{{ $t('removeWallpaper') }}</mk-button>
</div>
<div class="_content">
<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
{{ $t('auto-watch') }}<template #desc>{{ $t('auto-watch-desc') }}</template>
</mk-switch>
</div>
<div class="_content">
<mk-button @click="readAllNotifications">{{ $t('mark-as-read-all-notifications') }}</mk-button>
<mk-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</mk-button>
<mk-button @click="readAllMessagingMessages">{{ $t('mark-as-read-all-talk-messages') }}</mk-button>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faImage, faCog } from '@fortawesome/free-solid-svg-icons';
import MkInput from '../../components/ui/input.vue';
import MkButton from '../../components/ui/button.vue';
import MkSwitch from '../../components/ui/switch.vue';
import i18n from '../../i18n';
import { apiUrl } from '../../config';
export default Vue.extend({
i18n,
components: {
MkInput,
MkButton,
MkSwitch,
},
data() {
return {
wallpaperUploading: false,
faImage, faCog
}
},
computed: {
wallpaper: {
get() { return this.$store.state.settings.wallpaper; },
set(value) { this.$store.dispatch('settings/set', { key: 'wallpaper', value }); }
},
},
methods: {
onWallpaperChange([file]) {
this.wallpaperUploading = true;
const data = new FormData();
data.append('file', file);
data.append('i', this.$store.state.i.token);
fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: data
})
.then(response => response.json())
.then(f => {
this.wallpaper = f.url;
this.wallpaperUploading = false;
document.documentElement.style.backgroundImage = `url(${this.$store.state.settings.wallpaper})`;
})
.catch(e => {
this.wallpaperUploading = false;
this.$root.dialog({
type: 'error',
text: e
});
});
},
delWallpaper() {
this.wallpaper = null;
document.documentElement.style.backgroundImage = 'none';
},
onChangeAutoWatch(v) {
this.$root.api('i/update', {
autoWatch: v
});
},
readAllUnreadNotes() {
this.$root.api('i/read_all_unread_notes');
},
readAllMessagingMessages() {
this.$root.api('i/read_all_messaging_messages');
},
readAllNotifications() {
this.$root.api('notifications/mark_all_as_read');
}
}
});
</script>

View File

@@ -0,0 +1,121 @@
<template>
<section class="mk-settings-page-import-export _section">
<div class="_title"><fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div>
<div class="_content">
<input ref="file" type="file" style="display: none;" @change="onChangeFile"/>
<mk-select v-model="exportTarget" style="margin-top: 0;">
<option value="notes">{{ $t('_exportOrImport.allNotes') }}</option>
<option value="following">{{ $t('_exportOrImport.followingList') }}</option>
<option value="user-lists">{{ $t('_exportOrImport.userLists') }}</option>
<option value="mute">{{ $t('_exportOrImport.muteList') }}</option>
<option value="blocking">{{ $t('_exportOrImport.blockingList') }}</option>
</mk-select>
<mk-button inline @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</mk-button>
<mk-button inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><fa :icon="faUpload"/> {{ $t('import') }}</mk-button>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faDownload, faUpload, faBoxes } from '@fortawesome/free-solid-svg-icons';
import MkButton from '../../components/ui/button.vue';
import MkSelect from '../../components/ui/select.vue';
import i18n from '../../i18n';
import { apiUrl } from '../../config';
export default Vue.extend({
i18n,
components: {
MkButton,
MkSelect,
},
data() {
return {
exportTarget: 'notes',
faDownload, faUpload, faBoxes
}
},
methods: {
doExport() {
this.$root.api(
this.exportTarget == 'notes' ? 'i/export-notes' :
this.exportTarget == 'following' ? 'i/export-following' :
this.exportTarget == 'blocking' ? 'i/export-blocking' :
this.exportTarget == 'user-lists' ? 'i/export-user-lists' :
null, {})
.then(() => {
this.$root.dialog({
type: 'info',
text: this.$t('exportRequested')
});
}).catch((e: any) => {
this.$root.dialog({
type: 'error',
text: e.message
});
});
},
doImport() {
(this.$refs.file as any).click();
},
onChangeFile() {
const [file] = Array.from((this.$refs.file as any).files);
const data = new FormData();
data.append('file', file);
data.append('i', this.$store.state.i.token);
const dialog = this.$root.dialog({
type: 'waiting',
text: this.$t('uploading') + '...',
showOkButton: false,
showCancelButton: false,
cancelableByBgClick: false
});
fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: data
})
.then(response => response.json())
.then(f => {
this.reqImport(f);
})
.catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
})
.finally(() => {
dialog.close();
});
},
reqImport(file) {
this.$root.api(
this.exportTarget == 'following' ? 'i/import-following' :
this.exportTarget == 'user-lists' ? 'i/import-user-lists' :
null, {
fileId: file.id
}).then(() => {
this.$root.dialog({
type: 'info',
text: this.$t('importRequested')
});
}).catch((e: any) => {
this.$root.dialog({
type: 'error',
text: e.message
});
});
}
}
});
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="mk-settings-page">
<portal to="icon"><fa :icon="faCog"/></portal>
<portal to="title">{{ $t('settings') }}</portal>
<x-profile-setting/>
<x-privacy-setting/>
<x-reaction-setting/>
<x-theme/>
<x-import-export/>
<x-drive/>
<x-general/>
<x-mute-block/>
<x-security/>
<x-2fa/>
<x-integration/>
<mk-button @click="cacheClear()" primary class="cacheClear">{{ $t('cacheClear') }}</mk-button>
<mk-button @click="$root.signout()" primary class="logout">{{ $t('logout') }}</mk-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faCog } from '@fortawesome/free-solid-svg-icons';
import XProfileSetting from './profile.vue';
import XPrivacySetting from './privacy.vue';
import XImportExport from './import-export.vue';
import XDrive from './drive.vue';
import XGeneral from './general.vue';
import XReactionSetting from './reaction.vue';
import XMuteBlock from './mute-block.vue';
import XSecurity from './security.vue';
import XTheme from './theme.vue';
import X2fa from './2fa.vue';
import XIntegration from './integration.vue';
import MkButton from '../../components/ui/button.vue';
export default Vue.extend({
metaInfo() {
return {
title: this.$t('settings') as string
};
},
components: {
XProfileSetting,
XPrivacySetting,
XImportExport,
XDrive,
XGeneral,
XReactionSetting,
XMuteBlock,
XSecurity,
XTheme,
X2fa,
XIntegration,
MkButton,
},
data() {
return {
faCog
}
},
methods: {
cacheClear() {
// Clear cache (service worker)
try {
navigator.serviceWorker.controller.postMessage('clear');
navigator.serviceWorker.getRegistrations().then(registrations => {
for (const registration of registrations) registration.unregister();
});
} catch (e) {
console.error(e);
}
// Force reload
location.reload(true);
}
}
});
</script>
<style lang="scss" scoped>
.mk-settings-page {
> .logout,
> .cacheClear {
margin: 8px auto;
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration">
<div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
<div class="_content" v-if="enableTwitterIntegration">
<header><fa :icon="faTwitter"/> Twitter</header>
<p v-if="$store.state.i.twitter">{{ $t('connectedTo') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p>
<mk-button v-if="$store.state.i.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</mk-button>
<mk-button v-else @click="connectTwitter">{{ $t('connectSerice') }}</mk-button>
</div>
<div class="_content" v-if="enableDiscordIntegration">
<header><fa :icon="faDiscord"/> Discord</header>
<p v-if="$store.state.i.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p>
<mk-button v-if="$store.state.i.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</mk-button>
<mk-button v-else @click="connectDiscord">{{ $t('connectSerice') }}</mk-button>
</div>
<div class="_content" v-if="enableGithubIntegration">
<header><fa :icon="faGithub"/> GitHub</header>
<p v-if="$store.state.i.github">{{ $t('connectedTo') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.github.login }}</a></p>
<mk-button v-if="$store.state.i.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</mk-button>
<mk-button v-else @click="connectGithub">{{ $t('connectSerice') }}</mk-button>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faShareAlt } from '@fortawesome/free-solid-svg-icons';
import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
import i18n from '../../i18n';
import { apiUrl } from '../../config';
import MkButton from '../../components/ui/button.vue';
export default Vue.extend({
i18n,
components: {
MkButton
},
data() {
return {
apiUrl,
twitterForm: null,
discordForm: null,
githubForm: null,
enableTwitterIntegration: false,
enableDiscordIntegration: false,
enableGithubIntegration: false,
faShareAlt, faTwitter, faDiscord, faGithub
};
},
created() {
this.$root.getMeta().then(meta => {
this.enableTwitterIntegration = meta.enableTwitterIntegration;
this.enableDiscordIntegration = meta.enableDiscordIntegration;
this.enableGithubIntegration = meta.enableGithubIntegration;
});
},
mounted() {
if (!document.cookie.match(/i=(\w+)/)) {
document.cookie = `i=${this.$store.state.i.token}; path=/;` +
` domain=${document.location.hostname}; max-age=31536000;` +
(document.location.protocol.startsWith('https') ? ' secure' : '');
}
this.$watch('$store.state.i', () => {
if (this.$store.state.i.twitter) {
if (this.twitterForm) this.twitterForm.close();
}
if (this.$store.state.i.discord) {
if (this.discordForm) this.discordForm.close();
}
if (this.$store.state.i.github) {
if (this.githubForm) this.githubForm.close();
}
}, {
deep: true
});
},
methods: {
connectTwitter() {
this.twitterForm = window.open(apiUrl + '/connect/twitter',
'twitter_connect_window',
'height=570, width=520');
},
disconnectTwitter() {
window.open(apiUrl + '/disconnect/twitter',
'twitter_disconnect_window',
'height=570, width=520');
},
connectDiscord() {
this.discordForm = window.open(apiUrl + '/connect/discord',
'discord_connect_window',
'height=570, width=520');
},
disconnectDiscord() {
window.open(apiUrl + '/disconnect/discord',
'discord_disconnect_window',
'height=570, width=520');
},
connectGithub() {
this.githubForm = window.open(apiUrl + '/connect/github',
'github_connect_window',
'height=570, width=520');
},
disconnectGithub() {
window.open(apiUrl + '/disconnect/github',
'github_disconnect_window',
'height=570, width=520');
},
}
});
</script>

View File

@@ -0,0 +1,76 @@
<template>
<section class="mk-settings-page-mute-block _section">
<div class="_title"><fa :icon="faBan"/> {{ $t('muteAndBlock') }}</div>
<div class="_content">
<span>{{ $t('mutedUsers') }}</span>
<mk-pagination :pagination="mutingPagination" class="muting">
<template #empty><span>{{ $t('noUsers') }}</span></template>
<template #default="{items}">
<div class="user" v-for="(mute, i) in items" :key="mute.id" :data-index="i">
<router-link class="name" :to="mute.mutee | userPage">
<mk-acct :user="mute.mutee"/>
</router-link>
</div>
</template>
</mk-pagination>
</div>
<div class="_content">
<span>{{ $t('blockedUsers') }}</span>
<mk-pagination :pagination="blockingPagination" class="blocking">
<template #empty><span>{{ $t('noUsers') }}</span></template>
<template #default="{items}">
<div class="user" v-for="(block, i) in items" :key="block.id" :data-index="i">
<router-link class="name" :to="block.blockee | userPage">
<mk-acct :user="block.blockee"/>
</router-link>
</div>
</template>
</mk-pagination>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faBan } from '@fortawesome/free-solid-svg-icons';
import MkPagination from '../../components/ui/pagination.vue';
import i18n from '../../i18n';
export default Vue.extend({
i18n,
components: {
MkPagination,
},
data() {
return {
mutingPagination: {
endpoint: 'mute/list',
limit: 10,
},
blockingPagination: {
endpoint: 'blocking/list',
limit: 10,
},
faBan
}
},
});
</script>
<style lang="scss" scoped>
.mk-settings-page-mute-block {
> ._content {
max-height: 350px;
overflow: auto;
> .muting,
> .blocking {
> .empty {
opacity: 0.5 !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<section class="mk-settings-page-privacy _section">
<div class="_title"><fa :icon="faLock"/> {{ $t('privacy') }}</div>
<div class="_content">
<mk-switch v-model="isLocked" @change="save()">{{ $t('makeFollowManuallyApprove') }}</mk-switch>
<mk-switch v-model="autoAcceptFollowed" :disabled="!isLocked" @change="save()">{{ $t('autoAcceptFollowed') }}</mk-switch>
</div>
<div class="_content">
<mk-select v-model="defaultNoteVisibility" style="margin-top: 8px;">
<template #label>{{ $t('defaultNoteVisibility') }}</template>
<option value="public">{{ $t('_visibility.public') }}</option>
<option value="followers">{{ $t('_visibility.followers') }}</option>
<option value="specified">{{ $t('_visibility.specified') }}</option>
</mk-select>
<mk-switch v-model="rememberNoteVisibility" @change="save()">{{ $t('rememberNoteVisibility') }}</mk-switch>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import MkSelect from '../../components/ui/select.vue';
import MkSwitch from '../../components/ui/switch.vue';
import i18n from '../../i18n';
export default Vue.extend({
i18n,
components: {
MkSelect,
MkSwitch,
},
data() {
return {
isLocked: false,
autoAcceptFollowed: false,
faLock
}
},
computed: {
defaultNoteVisibility: {
get() { return this.$store.state.settings.defaultNoteVisibility; },
set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); }
},
rememberNoteVisibility: {
get() { return this.$store.state.settings.rememberNoteVisibility; },
set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); }
},
},
created() {
this.isLocked = this.$store.state.i.isLocked;
this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed;
},
methods: {
save() {
this.$root.api('i/update', {
isLocked: !!this.isLocked,
autoAcceptFollowed: !!this.autoAcceptFollowed,
});
}
}
});
</script>

View File

@@ -0,0 +1,246 @@
<template>
<section class="mk-settings-page-profile _section">
<div class="_title"><fa :icon="faUser"/> {{ $t('profile') }}<small style="display: block; font-weight: normal; opacity: 0.6;">@{{ $store.state.i.username }}@{{ host }}</small></div>
<div class="_content">
<mk-input v-model="name" :max="30">
<span>{{ $t('_profile.name') }}</span>
</mk-input>
<mk-textarea v-model="description" :max="500">
<span>{{ $t('_profile.description') }}</span>
<template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template>
</mk-textarea>
<mk-input v-model="location">
<span>{{ $t('location') }}</span>
<template #prefix><fa :icon="faMapMarkerAlt"/></template>
</mk-input>
<mk-input v-model="birthday" type="date">
<template #title>{{ $t('birthday') }}</template>
<template #prefix><fa :icon="faBirthdayCake"/></template>
</mk-input>
<mk-input type="file" @change="onAvatarChange">
<span>{{ $t('avatar') }}</span>
<template #icon><fa :icon="faImage"/></template>
<template #desc v-if="avatarUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
</mk-input>
<mk-input type="file" @change="onBannerChange">
<span>{{ $t('banner') }}</span>
<template #icon><fa :icon="faImage"/></template>
<template #desc v-if="bannerUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
</mk-input>
<details class="fields">
<summary>{{ $t('_profile.metadata') }}</summary>
<div class="row">
<mk-input v-model="fieldName0">{{ $t('_profile.metadataLabel') }}</mk-input>
<mk-input v-model="fieldValue0">{{ $t('_profile.metadataContent') }}</mk-input>
</div>
<div class="row">
<mk-input v-model="fieldName1">{{ $t('_profile.metadataLabel') }}</mk-input>
<mk-input v-model="fieldValue1">{{ $t('_profile.metadataContent') }}</mk-input>
</div>
<div class="row">
<mk-input v-model="fieldName2">{{ $t('_profile.metadataLabel') }}</mk-input>
<mk-input v-model="fieldValue2">{{ $t('_profile.metadataContent') }}</mk-input>
</div>
<div class="row">
<mk-input v-model="fieldName3">{{ $t('_profile.metadataLabel') }}</mk-input>
<mk-input v-model="fieldValue3">{{ $t('_profile.metadataContent') }}</mk-input>
</div>
</details>
<mk-switch v-model="isBot">{{ $t('flagAsBot') }}</mk-switch>
<mk-switch v-model="isCat">{{ $t('flagAsCat') }}</mk-switch>
</div>
<div class="_footer">
<mk-button @click="save(true)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faUnlockAlt, faCogs, faImage, faUser, faMapMarkerAlt, faBirthdayCake } from '@fortawesome/free-solid-svg-icons';
import { faSave } from '@fortawesome/free-regular-svg-icons';
import MkButton from '../../components/ui/button.vue';
import MkInput from '../../components/ui/input.vue';
import MkTextarea from '../../components/ui/textarea.vue';
import MkSwitch from '../../components/ui/switch.vue';
import i18n from '../../i18n';
import { apiUrl, host } from '../../config';
export default Vue.extend({
i18n,
components: {
MkButton,
MkInput,
MkTextarea,
MkSwitch,
},
data() {
return {
host,
name: null,
description: null,
birthday: null,
location: null,
fieldName0: null,
fieldValue0: null,
fieldName1: null,
fieldValue1: null,
fieldName2: null,
fieldValue2: null,
fieldName3: null,
fieldValue3: null,
avatarId: null,
bannerId: null,
isBot: false,
isCat: false,
saving: false,
avatarUploading: false,
bannerUploading: false,
faSave, faUnlockAlt, faCogs, faImage, faUser, faMapMarkerAlt, faBirthdayCake
}
},
created() {
this.name = this.$store.state.i.name;
this.description = this.$store.state.i.description;
this.location = this.$store.state.i.location;
this.birthday = this.$store.state.i.birthday;
this.avatarId = this.$store.state.i.avatarId;
this.bannerId = this.$store.state.i.bannerId;
this.isBot = this.$store.state.i.isBot;
this.isCat = this.$store.state.i.isCat;
this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null;
this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null;
this.fieldName1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].name : null;
this.fieldValue1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].value : null;
this.fieldName2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].name : null;
this.fieldValue2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].value : null;
this.fieldName3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].name : null;
this.fieldValue3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].value : null;
},
methods: {
onAvatarChange([file]) {
this.avatarUploading = true;
const data = new FormData();
data.append('file', file);
data.append('i', this.$store.state.i.token);
fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: data
})
.then(response => response.json())
.then(f => {
this.avatarId = f.id;
this.avatarUploading = false;
})
.catch(e => {
this.avatarUploading = false;
this.$root.dialog({
type: 'error',
text: e
});
});
},
onBannerChange([file]) {
this.bannerUploading = true;
const data = new FormData();
data.append('file', file);
data.append('i', this.$store.state.i.token);
fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: data
})
.then(response => response.json())
.then(f => {
this.bannerId = f.id;
this.bannerUploading = false;
})
.catch(e => {
this.bannerUploading = false;
this.$root.dialog({
type: 'error',
text: e
});
});
},
save(notify) {
const fields = [
{ name: this.fieldName0, value: this.fieldValue0 },
{ name: this.fieldName1, value: this.fieldValue1 },
{ name: this.fieldName2, value: this.fieldValue2 },
{ name: this.fieldName3, value: this.fieldValue3 },
];
this.saving = true;
this.$root.api('i/update', {
name: this.name || null,
description: this.description || null,
location: this.location || null,
birthday: this.birthday || null,
avatarId: this.avatarId || undefined,
bannerId: this.bannerId || undefined,
fields,
isBot: !!this.isBot,
isCat: !!this.isCat,
}).then(i => {
this.saving = false;
this.$store.state.i.avatarId = i.avatarId;
this.$store.state.i.avatarUrl = i.avatarUrl;
this.$store.state.i.bannerId = i.bannerId;
this.$store.state.i.bannerUrl = i.bannerUrl;
if (notify) {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}
}).catch(err => {
this.saving = false;
this.$root.dialog({
type: 'error',
text: err.id
});
});
},
}
});
</script>
<style lang="scss" scoped>
.mk-settings-page-profile {
> ._content {
> *:first-child {
margin-top: 0;
}
> .fields {
> .row {
> * {
display: inline-block;
width: 50%;
margin-bottom: 0;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<section class="mk-settings-page-reaction _section">
<div class="_title"><fa :icon="faLaugh"/> {{ $t('reaction') }}</div>
<div class="_content">
<mk-textarea v-model="reactions" style="margin-top: 16px;">{{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }}</template></mk-textarea>
</div>
<div class="_footer">
<mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
<mk-button inline @click="preview"><fa :icon="faEye"/> {{ $t('preview') }}</mk-button>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons';
import MkTextarea from '../../components/ui/textarea.vue';
import MkButton from '../../components/ui/button.vue';
import MkReactionPicker from '../../components/reaction-picker.vue';
import i18n from '../../i18n';
export default Vue.extend({
i18n,
components: {
MkTextarea,
MkButton,
},
data() {
return {
reactions: this.$store.state.settings.reactions.join('\n'),
changed: false,
faLaugh, faSave, faEye
}
},
watch: {
reactions() {
this.changed = true;
}
},
methods: {
save() {
this.$store.dispatch('settings/set', { key: 'reactions', value: this.reactions.trim().split('\n') });
this.changed = false;
},
preview(ev) {
const picker = this.$root.new(MkReactionPicker, {
source: ev.currentTarget || ev.target,
reactions: this.reactions.trim().split('\n'),
showFocus: false,
});
picker.$once('chosen', reaction => {
picker.close();
});
}
}
});
</script>

View File

@@ -0,0 +1,87 @@
<template>
<section class="_section">
<div class="_title"><fa :icon="faLock"/> {{ $t('password') }}</div>
<div class="_content">
<mk-button primary @click="change()">{{ $t('changePassword') }}</mk-button>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import MkButton from '../../components/ui/button.vue';
import i18n from '../../i18n';
export default Vue.extend({
i18n,
components: {
MkButton,
},
data() {
return {
faLock
}
},
methods: {
async change() {
const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({
title: this.$t('currentPassword'),
input: {
type: 'password'
}
});
if (canceled1) return;
const { canceled: canceled2, result: newPassword } = await this.$root.dialog({
title: this.$t('newPassword'),
input: {
type: 'password'
}
});
if (canceled2) return;
const { canceled: canceled3, result: newPassword2 } = await this.$root.dialog({
title: this.$t('newPasswordRetype'),
input: {
type: 'password'
}
});
if (canceled3) return;
if (newPassword !== newPassword2) {
this.$root.dialog({
type: 'error',
text: this.$t('retypedNotMatch')
});
return;
}
const dialog = this.$root.dialog({
type: 'waiting',
iconOnly: true
});
this.$root.api('i/change-password', {
currentPassword,
newPassword
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
}).finally(() => {
dialog.close();
});
}
}
});
</script>

View File

@@ -0,0 +1,76 @@
<template>
<section class="mk-settings-page-theme _section">
<div class="_title"><fa :icon="faPalette"/> {{ $t('theme') }}</div>
<div class="_content">
<mk-select v-model="theme" :placeholder="$t('theme')">
<template #label>{{ $t('theme') }}</template>
<optgroup :label="$t('lightThemes')">
<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
</optgroup>
<optgroup :label="$t('darkThemes')">
<option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
</optgroup>
</mk-select>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPalette } from '@fortawesome/free-solid-svg-icons';
import MkInput from '../../components/ui/input.vue';
import MkButton from '../../components/ui/button.vue';
import MkSelect from '../../components/ui/select.vue';
import i18n from '../../i18n';
import { Theme, builtinThemes, applyTheme } from '../../theme';
export default Vue.extend({
i18n,
components: {
MkInput,
MkButton,
MkSelect,
},
data() {
return {
wallpaperUploading: false,
faPalette
}
},
computed: {
themes(): Theme[] {
return builtinThemes.concat(this.$store.state.device.themes);
},
installedThemes(): Theme[] {
return this.$store.state.device.themes;
},
darkThemes(): Theme[] {
return this.themes.filter(t => t.base == 'dark' || t.kind == 'dark');
},
lightThemes(): Theme[] {
return this.themes.filter(t => t.base == 'light' || t.kind == 'light');
},
theme: {
get() { return this.$store.state.device.theme; },
set(value) { this.$store.commit('device/set', { key: 'theme', value }); }
},
},
watch: {
theme() {
applyTheme(this.themes.find(x => x.id === this.theme));
}
},
methods: {
}
});
</script>