Migrate to Vue3 (#6587)
* Update reaction.vue
* fix bug
* wip
* wip
* wjio
* wip
* Revert "wip"
This reverts commit e427f2160a
.
* wip
* wip
* wip
* Update init.ts
* Update drive-window.vue
* wip
* wip
* Use PascalCase for components
* Use PascalCase for components
* update dep
* wip
* wip
* wip
* Update init.ts
* wip
* Update paging.ts
* Update test.vue
* watch deep
* wip
* lint
* wip
* wip
* wip
* wip
* wiop
* wip
* Update webpack.config.ts
* alllow null poll
* wip
* wip
* wip
* wiop
* UI redesign & refactor (#6714)
* wip
* wip
* wip
* wip
* wip
* Update drive.vue
* Update word-mute.vue
* wip
* wip
* wip
* clean up
* wip
* Update default.vue
* wip
* Update notes.vue
* Update mfm.ts
* Update index.home.vue
* Update post-form.vue
* Update post-form-attaches.vue
* wip
* Update post-form.vue
* Update sidebar.vue
* wip
* wip
* Update index.vue
* wip
* Update default.vue
* Update index.vue
* Update index.vue
* wip
* Update post-form-attaches.vue
* Update note.vue
* wip
* clean up
* Update notes.vue
* wip
* wip
* Update ja-JP.yml
* wip
* wip
* Update index.vue
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update default.vue
* wip
* Update _dark.json5
* wip
* wip
* wip
* clean up
* wip
* wip
* Update index.vue
* Update test.vue
* wip
* wip
* fix
* wip
* wip
* wip
* wip
* clena yop
* wip
* wip
* Update store.ts
* Update messaging-room.vue
* Update default.widgets.vue
* fix
* wip
* wip
* Update modal.vue
* wip
* Update os.ts
* Update os.ts
* Update deck.vue
* Update init.ts
* wip
* Update ja-JP.yml
* v-sizeは単にwindowのresizeを監視するだけで良いかもしれない
* Update modal.vue
* wip
* Update tooltip.ts
* wip
* wip
* wip
* wip
* wip
* Update image-viewer.vue
* wip
* wip
* Update style.scss
* Update style.scss
* Update visitor.vue
* wip
* Update init.ts
* Update init.ts
* wip
* wip
* Update visitor.vue
* Update visitor.vue
* Update visitor.vue
* Update visitor.vue
* wip
* wip
* Update modal.vue
* Update header.vue
* Update menu.vue
* Update about.vue
* Update about-misskey.vue
* wip
* wip
* Update visitor.vue
* Update tooltip.ts
* wip
* Update drive.vue
* wip
* Update style.scss
* Update header.vue
* wip
* wip
* Update users.user.vue
* Update announcements.vue
* wip
* wip
* wip
* Update emojis.vue
* wip
* Update emojis.vue
* Update style.scss
* Update users.vue
* wip
* Update style.scss
* wip
* Update welcome.entrance.vue
* Update radio.vue
* Update size.ts
* Update emoji-edit-dialog.vue
* wip
* Update emojis.vue
* wip
* Update emojis.vue
* Update emojis.vue
* Update emojis.vue
* wip
* wip
* wip
* wip
* Update file-dialog.vue
* wip
* wip
* Update token-generate-window.vue
* Update notification-setting-window.vue
* wip
* wip
* Update _error_.vue
* Update ja-JP.yml
* wip
* wip
* Update store.ts
* Update emojis.vue
* Update emojis.vue
* Update emojis.vue
* Update announcements.vue
* Update store.ts
* wip
* Update page-editor.vue
* wip
* wip
* Update modal.vue
* wip
* Update select-file.ts
* Update timeline.vue
* Update emojis.vue
* Update os.ts
* wip
* Update user-select.vue
* Update mfm.ts
* Update get-file-info.ts
* Update drive.vue
* Update init.ts
* Update mfm.ts
* wip
* wip
* Update window.vue
* Update note.vue
* wip
* wip
* Update user-info.vue
* wip
* wip
* wip
* wip
* wip
* Update header.vue
* Update header.vue
* wip
* Update explore.vue
* wip
* wip
* wip
* Update webpack.config.ts
* wip
* wip
* wip
* wip
* wip
* wip
* Update autocomplete.ts
* wip
* wip
* wip
* Update toast.vue
* wip
* Update post-form-dialog.vue
* wip
* wip
* wip
* wip
* wip
* Update users.vue
* wip
* Update explore.vue
* wip
* wip
* wip
* Update package.json
* wip
* Update icon-dialog.vue
* wip
* wip
* Update user-preview.ts
* wip
* wip
* wip
* wip
* wip
* Update instance.vue
* Update user-name.vue
* Update federation.vue
* Update instance.vue
* wip
* wip
* Update tag.vue
* wip
* wip
* wip
* wip
* wip
* Update instance.vue
* wip
* Update os.ts
* Update os.ts
* wip
* wip
* wip
* Update router.ts
* wip
* Update init.ts
* Update note.vue
* Update messages.vue
* wip
* wip
* wip
* wip
* wip
* google
* wip
* wip
* wip
* wip
* Update theme-editor.vue
* wip
* wip
* Update room.vue
* Update channel-editor.vue
* wip
* Update window.vue
* Update window.vue
* wip
* Update window.vue
* Update window.vue
* wip
* Update menu.vue
* wip
* wip
* wip
* wip
* Update messaging-room.vue
* wip
* Update post-form.vue
* Update default.widgets.vue
* Update window.vue
* wip
This commit is contained in:
59
src/client/pages/settings/api.vue
Normal file
59
src/client/pages/settings/api.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section class="_section">
|
||||
<div class="_content">
|
||||
<MkButton @click="generateToken">{{ $t('generateAccessToken') }}</MkButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faKey } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import MkInput from '@/components/ui/input.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton, MkInput
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: 'API',
|
||||
icon: faKey
|
||||
}]
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
async generateToken() {
|
||||
os.popup(await import('@/components/token-generate-window.vue'), {}, {
|
||||
done: async result => {
|
||||
const { name, permissions } = result;
|
||||
const { token } = await os.api('miauth/gen-token', {
|
||||
session: null,
|
||||
name: name,
|
||||
permission: permissions,
|
||||
});
|
||||
|
||||
os.dialog({
|
||||
type: 'success',
|
||||
title: this.$t('token'),
|
||||
text: token
|
||||
});
|
||||
},
|
||||
}, 'closed');
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
60
src/client/pages/settings/drive.vue
Normal file
60
src/client/pages/settings/drive.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<section class="uawsfosz _section">
|
||||
<div class="_title"><Fa :icon="faCloud"/> {{ $t('drive') }}</div>
|
||||
<div class="_content">
|
||||
<span>{{ $t('uploadFolder') }}: {{ uploadFolder ? uploadFolder.name : '-' }}</span>
|
||||
<MkButton primary @click="chooseUploadFolder()"><Fa :icon="faFolderOpen"/> {{ $t('selectFolder') }}</MkButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faCloud, faFolderOpen } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
uploadFolder: null,
|
||||
faCloud, faClock, faEyeSlash, faFolderOpen, faTrashAlt
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
if (this.$store.state.settings.uploadFolder) {
|
||||
this.uploadFolder = await os.api('drive/folders/show', {
|
||||
folderId: this.$store.state.settings.uploadFolder
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
chooseUploadFolder() {
|
||||
os.selectDriveFolder(false).then(async folder => {
|
||||
await this.$store.dispatch('settings/set', { key: 'uploadFolder', value: folder ? folder.id : null });
|
||||
os.success();
|
||||
if (this.$store.state.settings.uploadFolder) {
|
||||
this.uploadFolder = await os.api('drive/folders/show', {
|
||||
folderId: this.$store.state.settings.uploadFolder
|
||||
});
|
||||
} else {
|
||||
this.uploadFolder = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.uawsfosz {
|
||||
|
||||
}
|
||||
</style>
|
219
src/client/pages/settings/general.vue
Normal file
219
src/client/pages/settings/general.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="_section">
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><Fa :icon="faCog"/> {{ $t('general') }}</div>
|
||||
<div class="_content">
|
||||
<div>{{ $t('whenServerDisconnected') }}</div>
|
||||
<MkRadio v-model="serverDisconnectedBehavior" value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</MkRadio>
|
||||
<MkRadio v-model="serverDisconnectedBehavior" value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</MkRadio>
|
||||
<MkRadio v-model="serverDisconnectedBehavior" value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</MkRadio>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<MkSwitch v-model:value="imageNewTab">{{ $t('openImageInNewTab') }}</MkSwitch>
|
||||
<MkSwitch v-model:value="showFixedPostForm">{{ $t('showFixedPostForm') }}</MkSwitch>
|
||||
<MkSwitch v-model:value="enableInfiniteScroll">{{ $t('enableInfiniteScroll') }}</MkSwitch>
|
||||
<MkSwitch v-model:value="disablePagesScript">{{ $t('disablePagesScript') }}</MkSwitch>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<div>{{ $t('chatOpenBehavior') }}</div>
|
||||
<MkRadio v-model="chatOpenBehavior" value="page">{{ $t('showInPage') }}</MkRadio>
|
||||
<MkRadio v-model="chatOpenBehavior" value="window">{{ $t('openInWindow') }}</MkRadio>
|
||||
<MkRadio v-model="chatOpenBehavior" value="popout">{{ $t('popout') }}</MkRadio>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<MkSelect v-model:value="lang">
|
||||
<template #label>{{ $t('uiLanguage') }}</template>
|
||||
|
||||
<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
|
||||
</MkSelect>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><Fa :icon="faCog"/> {{ $t('appearance') }}</div>
|
||||
<div class="_content">
|
||||
<MkSwitch v-model:value="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</MkSwitch>
|
||||
<MkSwitch v-model:value="reduceAnimation">{{ $t('reduceUiAnimation') }}</MkSwitch>
|
||||
<MkSwitch v-model:value="useBlurEffectForModal">{{ $t('useBlurEffectForModal') }}</MkSwitch>
|
||||
<MkSwitch v-model:value="useOsNativeEmojis">
|
||||
{{ $t('useOsNativeEmojis') }}
|
||||
<template #desc><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<div>{{ $t('fontSize') }}</div>
|
||||
<MkRadio v-model="fontSize" value="small"><span style="font-size: 14px;">Aa</span></MkRadio>
|
||||
<MkRadio v-model="fontSize" :value="null"><span style="font-size: 16px;">Aa</span></MkRadio>
|
||||
<MkRadio v-model="fontSize" value="large"><span style="font-size: 18px;">Aa</span></MkRadio>
|
||||
<MkRadio v-model="fontSize" value="veryLarge"><span style="font-size: 20px;">Aa</span></MkRadio>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="_card _vMargin">
|
||||
<div class="_title"><Fa :icon="faColumns"/> {{ $t('deck') }}</div>
|
||||
<div class="_content">
|
||||
<MkSwitch v-model:value="deckAlwaysShowMainColumn">
|
||||
{{ $t('_deck.alwaysShowMainColumn') }}
|
||||
</MkSwitch>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<div>{{ $t('_deck.columnAlign') }}</div>
|
||||
<MkRadio v-model="deckColumnAlign" value="left">{{ $t('left') }}</MkRadio>
|
||||
<MkRadio v-model="deckColumnAlign" value="center">{{ $t('center') }}</MkRadio>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MkButton @click="cacheClear()" primary style="margin: var(--margin) auto;">{{ $t('cacheClear') }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faImage, faCog, faColumns, faCogs } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import MkSwitch from '@/components/ui/switch.vue';
|
||||
import MkSelect from '@/components/ui/select.vue';
|
||||
import MkRadio from '@/components/ui/radio.vue';
|
||||
import MkRange from '@/components/ui/range.vue';
|
||||
import { langs } from '@/config';
|
||||
import { clientDb, set } from '@/db';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
MkSwitch,
|
||||
MkSelect,
|
||||
MkRadio,
|
||||
MkRange,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('general'),
|
||||
icon: faCogs
|
||||
}]
|
||||
},
|
||||
langs,
|
||||
lang: localStorage.getItem('lang'),
|
||||
fontSize: localStorage.getItem('fontSize'),
|
||||
faImage, faCog, faColumns
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
serverDisconnectedBehavior: {
|
||||
get() { return this.$store.state.device.serverDisconnectedBehavior; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'serverDisconnectedBehavior', value }); }
|
||||
},
|
||||
|
||||
reduceAnimation: {
|
||||
get() { return !this.$store.state.device.animation; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'animation', value: !value }); }
|
||||
},
|
||||
|
||||
useBlurEffectForModal: {
|
||||
get() { return this.$store.state.device.useBlurEffectForModal; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'useBlurEffectForModal', value: value }); }
|
||||
},
|
||||
|
||||
disableAnimatedMfm: {
|
||||
get() { return !this.$store.state.device.animatedMfm; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'animatedMfm', value: !value }); }
|
||||
},
|
||||
|
||||
useOsNativeEmojis: {
|
||||
get() { return this.$store.state.device.useOsNativeEmojis; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'useOsNativeEmojis', value }); }
|
||||
},
|
||||
|
||||
imageNewTab: {
|
||||
get() { return this.$store.state.device.imageNewTab; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'imageNewTab', value }); }
|
||||
},
|
||||
|
||||
disablePagesScript: {
|
||||
get() { return this.$store.state.device.disablePagesScript; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'disablePagesScript', value }); }
|
||||
},
|
||||
|
||||
showFixedPostForm: {
|
||||
get() { return this.$store.state.device.showFixedPostForm; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'showFixedPostForm', value }); }
|
||||
},
|
||||
|
||||
chatOpenBehavior: {
|
||||
get() { return this.$store.state.device.chatOpenBehavior; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'chatOpenBehavior', value }); }
|
||||
},
|
||||
|
||||
enableInfiniteScroll: {
|
||||
get() { return this.$store.state.device.enableInfiniteScroll; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); }
|
||||
},
|
||||
|
||||
deckAlwaysShowMainColumn: {
|
||||
get() { return this.$store.state.device.deckAlwaysShowMainColumn; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); }
|
||||
},
|
||||
|
||||
deckColumnAlign: {
|
||||
get() { return this.$store.state.device.deckColumnAlign; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); }
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
lang() {
|
||||
localStorage.setItem('lang', this.lang);
|
||||
|
||||
return set('_version_', `changeLang-${(new Date()).toJSON()}`, clientDb.i18n)
|
||||
.then(() => location.reload())
|
||||
.catch(() => {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
fontSize() {
|
||||
if (this.fontSize == null) {
|
||||
localStorage.removeItem('fontSize');
|
||||
} else {
|
||||
localStorage.setItem('fontSize', this.fontSize);
|
||||
}
|
||||
location.reload();
|
||||
},
|
||||
|
||||
enableInfiniteScroll() {
|
||||
location.reload()
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
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>
|
119
src/client/pages/settings/import-export.vue
Normal file
119
src/client/pages/settings/import-export.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<section class="_section">
|
||||
<div class="_title"><Fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div>
|
||||
<div class="_content">
|
||||
<MkSelect v-model:value="exportTarget">
|
||||
<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>
|
||||
</MkSelect>
|
||||
<MkButton inline @click="doExport()"><Fa :icon="faDownload"/> {{ $t('export') }}</MkButton>
|
||||
<MkButton inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><Fa :icon="faUpload"/> {{ $t('import') }}</MkButton>
|
||||
</div>
|
||||
<input ref="file" type="file" style="display: none;" @change="onChangeFile"/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } 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 { apiUrl } from '@/config';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
MkSelect,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
exportTarget: 'notes',
|
||||
faDownload, faUpload, faBoxes
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
doExport() {
|
||||
os.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(() => {
|
||||
os.dialog({
|
||||
type: 'info',
|
||||
text: this.$t('exportRequested')
|
||||
});
|
||||
}).catch((e: any) => {
|
||||
os.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 = os.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 => {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
dialog.close();
|
||||
});
|
||||
},
|
||||
|
||||
reqImport(file) {
|
||||
os.api(
|
||||
this.exportTarget == 'following' ? 'i/import-following' :
|
||||
this.exportTarget == 'user-lists' ? 'i/import-user-lists' :
|
||||
null, {
|
||||
fileId: file.id
|
||||
}).then(() => {
|
||||
os.dialog({
|
||||
type: 'info',
|
||||
text: this.$t('importRequested')
|
||||
});
|
||||
}).catch((e: any) => {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: e.message
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
154
src/client/pages/settings/index.vue
Normal file
154
src/client/pages/settings/index.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="vvcocwet" :class="{ wide: !narrow }" ref="el">
|
||||
<div class="nav" v-if="!narrow || $route.name === 'settings'">
|
||||
<div class="menu">
|
||||
<div class="label">{{ $t('basicSettings') }}</div>
|
||||
<router-link class="item" replace to="/settings/profile"><Fa :icon="faUser" fixed-width class="icon"/>{{ $t('profile') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/privacy"><Fa :icon="faLockOpen" fixed-width class="icon"/>{{ $t('privacy') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/reaction"><Fa :icon="faLaugh" fixed-width class="icon"/>{{ $t('reaction') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/notifications"><Fa :icon="faBell" fixed-width class="icon"/>{{ $t('notifications') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/integration"><Fa :icon="faShareAlt" fixed-width class="icon"/>{{ $t('integration') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/security"><Fa :icon="faLock" fixed-width class="icon"/>{{ $t('security') }}</router-link>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<div class="label">{{ $t('clientSettings') }}</div>
|
||||
<router-link class="item" replace to="/settings/general"><Fa :icon="faCogs" fixed-width class="icon"/>{{ $t('general') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/theme"><Fa :icon="faPalette" fixed-width class="icon"/>{{ $t('theme') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/sidebar"><Fa :icon="faListUl" fixed-width class="icon"/>{{ $t('sidebar') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/sounds"><Fa :icon="faMusic" fixed-width class="icon"/>{{ $t('sounds') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/plugins"><Fa :icon="faPlug" fixed-width class="icon"/>{{ $t('plugins') }}</router-link>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<div class="label">{{ $t('otherSettings') }}</div>
|
||||
<router-link class="item" replace to="/settings/mute-block"><Fa :icon="faBan" fixed-width class="icon"/>{{ $t('muteAndBlock') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/word-mute"><Fa :icon="faCommentSlash" fixed-width class="icon"/>{{ $t('wordMute') }}</router-link>
|
||||
<router-link class="item" replace to="/settings/api"><Fa :icon="faKey" fixed-width class="icon"/>API</router-link>
|
||||
<router-link class="item" replace to="/settings/other"><Fa :icon="faEllipsisH" fixed-width class="icon"/>{{ $t('other') }}</router-link>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<button class="_button item" @click="logout">{{ $t('logout') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition :name="($store.state.device.animation && !narrow) ? 'view-slide' : ''" appear mode="out-in">
|
||||
<component :is="Component" @info="onInfo"/>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, ref } from 'vue';
|
||||
import { faCog, faPalette, faPlug, faUser, faListUl, faLock, faCommentSlash, faMusic, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faLaugh, faBell } from '@fortawesome/free-regular-svg-icons';
|
||||
import { store } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
export default defineComponent({
|
||||
setup(props, context) {
|
||||
const INFO = ref({
|
||||
header: [{
|
||||
title: i18n.global.t('settings'),
|
||||
icon: faCog
|
||||
}]
|
||||
});
|
||||
const narrow = ref(false);
|
||||
const view = ref(null);
|
||||
const el = ref(null);
|
||||
const onInfo = (viewInfo) => {
|
||||
INFO.value = viewInfo;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
narrow.value = el.value.offsetWidth < 650;
|
||||
});
|
||||
|
||||
return {
|
||||
INFO,
|
||||
narrow,
|
||||
view,
|
||||
el,
|
||||
onInfo,
|
||||
logout: () => {
|
||||
store.dispatch('logout');
|
||||
location.href = '/';
|
||||
},
|
||||
faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.view-slide-enter-active, .view-slide-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s !important;
|
||||
}
|
||||
.view-slide-enter-from, .view-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(32px);
|
||||
}
|
||||
|
||||
.vvcocwet {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
|
||||
> .nav {
|
||||
> .menu {
|
||||
margin: 16px 0;
|
||||
|
||||
> .label {
|
||||
padding: 8px 32px;
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> .item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 32px;
|
||||
line-height: 48px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
//background: var(--panel);
|
||||
//border-bottom: solid 1px var(--divider);
|
||||
transition: padding 0.2s ease, color 0.1s ease;
|
||||
|
||||
&:first-of-type {
|
||||
//border-top: solid 1px var(--divider);
|
||||
}
|
||||
|
||||
&.router-link-active {
|
||||
color: var(--accent);
|
||||
padding-left: 42px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
padding-left: 42px;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.wide {
|
||||
display: flex;
|
||||
|
||||
> .nav {
|
||||
width: 30%;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
> .main {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
136
src/client/pages/settings/integration.vue
Normal file
136
src/client/pages/settings/integration.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration">
|
||||
<div class="_content" v-if="enableTwitterIntegration">
|
||||
<header><Fa :icon="faTwitter"/> Twitter</header>
|
||||
<p v-if="integrations.twitter">{{ $t('connectedTo') }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p>
|
||||
<MkButton v-if="integrations.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</MkButton>
|
||||
<MkButton v-else @click="connectTwitter">{{ $t('connectSerice') }}</MkButton>
|
||||
</div>
|
||||
|
||||
<div class="_content" v-if="enableDiscordIntegration">
|
||||
<header><Fa :icon="faDiscord"/> Discord</header>
|
||||
<p v-if="integrations.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p>
|
||||
<MkButton v-if="integrations.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</MkButton>
|
||||
<MkButton v-else @click="connectDiscord">{{ $t('connectSerice') }}</MkButton>
|
||||
</div>
|
||||
|
||||
<div class="_content" v-if="enableGithubIntegration">
|
||||
<header><Fa :icon="faGithub"/> GitHub</header>
|
||||
<p v-if="integrations.github">{{ $t('connectedTo') }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p>
|
||||
<MkButton v-if="integrations.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</MkButton>
|
||||
<MkButton v-else @click="connectGithub">{{ $t('connectSerice') }}</MkButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faShareAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
import { apiUrl } from '@/config';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('integration'),
|
||||
icon: faShareAlt
|
||||
}]
|
||||
},
|
||||
apiUrl,
|
||||
twitterForm: null,
|
||||
discordForm: null,
|
||||
githubForm: null,
|
||||
enableTwitterIntegration: false,
|
||||
enableDiscordIntegration: false,
|
||||
enableGithubIntegration: false,
|
||||
faShareAlt, faTwitter, faDiscord, faGithub
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
integrations() {
|
||||
return this.$store.state.i.integrations;
|
||||
},
|
||||
|
||||
meta() {
|
||||
return this.$store.state.instance.meta;
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
|
||||
this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
|
||||
this.enableGithubIntegration = this.meta.enableGithubIntegration;
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
|
||||
document.cookie = `igi=${this.$store.state.i.token}; path=/;` +
|
||||
` max-age=31536000;` +
|
||||
(document.location.protocol.startsWith('https') ? ' secure' : '');
|
||||
|
||||
this.$watch('integrations', () => {
|
||||
if (this.integrations.twitter) {
|
||||
if (this.twitterForm) this.twitterForm.close();
|
||||
}
|
||||
if (this.integrations.discord) {
|
||||
if (this.discordForm) this.discordForm.close();
|
||||
}
|
||||
if (this.integrations.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>
|
93
src/client/pages/settings/mute-block.vue
Normal file
93
src/client/pages/settings/mute-block.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<section class="rrfwjxfl _section">
|
||||
<MkTab v-model:value="tab" :items="[{ label: $t('mutedUsers'), value: 'mute' }, { label: $t('blockedUsers'), value: 'block' }]" style="margin-bottom: var(--margin);"/>
|
||||
<div class="_content" v-if="tab === 'mute'">
|
||||
<MkPagination :pagination="mutingPagination" class="muting">
|
||||
<template #empty><MkInfo>{{ $t('noUsers') }}</MkInfo></template>
|
||||
<template #default="{items}">
|
||||
<div class="user" v-for="mute in items" :key="mute.id">
|
||||
<router-link class="name" :to="userPage(mute.mutee)">
|
||||
<MkAcct :user="mute.mutee"/>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div class="_content" v-if="tab === 'block'">
|
||||
<MkPagination :pagination="blockingPagination" class="blocking">
|
||||
<template #empty><MkInfo>{{ $t('noUsers') }}</MkInfo></template>
|
||||
<template #default="{items}">
|
||||
<div class="user" v-for="block in items" :key="block.id">
|
||||
<router-link class="name" :to="userPage(block.blockee)">
|
||||
<MkAcct :user="block.blockee"/>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faBan } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkPagination from '@/components/ui/pagination.vue';
|
||||
import MkTab from '@/components/tab.vue';
|
||||
import MkInfo from '@/components/ui/info.vue';
|
||||
import { userPage } from '@/filters/user';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkPagination,
|
||||
MkTab,
|
||||
MkInfo,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('muteAndBlock'),
|
||||
icon: faBan
|
||||
}]
|
||||
},
|
||||
tab: 'mute',
|
||||
mutingPagination: {
|
||||
endpoint: 'mute/list',
|
||||
limit: 10,
|
||||
},
|
||||
blockingPagination: {
|
||||
endpoint: 'blocking/list',
|
||||
limit: 10,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
userPage
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rrfwjxfl {
|
||||
> ._content {
|
||||
max-height: 350px;
|
||||
overflow: auto;
|
||||
|
||||
> .muting,
|
||||
> .blocking {
|
||||
> .empty {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
93
src/client/pages/settings/notifications.vue
Normal file
93
src/client/pages/settings/notifications.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="_section">
|
||||
<MkButton full primary @click="configure"><Fa :icon="faCog"/> {{ $t('notificationSetting') }}</MkButton>
|
||||
</div>
|
||||
<div class="_section">
|
||||
<div class="_card">
|
||||
<div class="_content">
|
||||
<MkSwitch v-model:value="$store.state.i.autoWatch" @update:value="onChangeAutoWatch">
|
||||
{{ $t('autoNoteWatch') }}<template #desc>{{ $t('autoNoteWatchDescription') }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_section">
|
||||
<MkButton full @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</MkButton>
|
||||
<MkButton full @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</MkButton>
|
||||
<MkButton full @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faCog } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faBell } from '@fortawesome/free-regular-svg-icons';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import MkSwitch from '@/components/ui/switch.vue';
|
||||
import { notificationTypes } from '../../../types';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('notifications'),
|
||||
icon: faBell
|
||||
}]
|
||||
},
|
||||
faCog
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChangeAutoWatch(v) {
|
||||
os.api('i/update', {
|
||||
autoWatch: v
|
||||
});
|
||||
},
|
||||
|
||||
readAllUnreadNotes() {
|
||||
os.api('i/read-all-unread-notes');
|
||||
},
|
||||
|
||||
readAllMessagingMessages() {
|
||||
os.api('i/read-all-messaging-messages');
|
||||
},
|
||||
|
||||
readAllNotifications() {
|
||||
os.api('notifications/mark-all-as-read');
|
||||
},
|
||||
|
||||
async configure() {
|
||||
const includingTypes = notificationTypes.filter(x => !this.$store.state.i.mutingNotificationTypes.includes(x));
|
||||
os.popup(await import('@/components/notification-setting-window.vue'), {
|
||||
includingTypes,
|
||||
showGlobalToggle: false,
|
||||
}, {
|
||||
done: async (res) => {
|
||||
const { includingTypes: value } = res;
|
||||
await os.apiWithDialog('i/update', {
|
||||
mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)),
|
||||
}).then(i => {
|
||||
this.$store.state.i.mutingNotificationTypes = i.mutingNotificationTypes;
|
||||
});
|
||||
}
|
||||
}, 'closed');
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
51
src/client/pages/settings/other.vue
Normal file
51
src/client/pages/settings/other.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="_section">
|
||||
<div class="_card">
|
||||
<div class="_content">
|
||||
<MkSwitch v-model:value="$store.state.i.injectFeaturedNote" @update:value="onChangeInjectFeaturedNote">
|
||||
{{ $t('showFeaturedNotesInTimeline') }}
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkSelect from '@/components/ui/select.vue';
|
||||
import MkSwitch from '@/components/ui/switch.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkSelect,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('other'),
|
||||
icon: faEllipsisH
|
||||
}]
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChangeInjectFeaturedNote(v) {
|
||||
os.api('i/update', {
|
||||
injectFeaturedNote: v
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
200
src/client/pages/settings/plugins.vue
Normal file
200
src/client/pages/settings/plugins.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<section class="_section">
|
||||
<div class="_title"><Fa :icon="faPlug"/> {{ $t('plugins') }}</div>
|
||||
<div class="_content">
|
||||
<details>
|
||||
<summary><Fa :icon="faDownload"/> {{ $t('install') }}</summary>
|
||||
<MkInfo warn>{{ $t('pluginInstallWarn') }}</MkInfo>
|
||||
<MkTextarea v-model:value="script" tall>
|
||||
<span>{{ $t('script') }}</span>
|
||||
</MkTextarea>
|
||||
<MkButton @click="install()" primary><Fa :icon="faSave"/> {{ $t('install') }}</MkButton>
|
||||
</details>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<details>
|
||||
<summary><Fa :icon="faFolderOpen"/> {{ $t('manage') }}</summary>
|
||||
<MkSelect v-model:value="selectedPluginId">
|
||||
<option v-for="x in $store.state.deviceUser.plugins" :value="x.id" :key="x.id">{{ x.name }}</option>
|
||||
</MkSelect>
|
||||
<template v-if="selectedPlugin">
|
||||
<div style="margin: -8px 0 8px 0;">
|
||||
<MkSwitch :value="selectedPlugin.active" @update:value="changeActive(selectedPlugin, $event)">{{ $t('makeActive') }}</MkSwitch>
|
||||
</div>
|
||||
<div class="_keyValue">
|
||||
<div>{{ $t('version') }}:</div>
|
||||
<div>{{ selectedPlugin.version }}</div>
|
||||
</div>
|
||||
<div class="_keyValue">
|
||||
<div>{{ $t('author') }}:</div>
|
||||
<div>{{ selectedPlugin.author }}</div>
|
||||
</div>
|
||||
<div class="_keyValue">
|
||||
<div>{{ $t('description') }}:</div>
|
||||
<div>{{ selectedPlugin.description }}</div>
|
||||
</div>
|
||||
<div style="margin-top: 8px;">
|
||||
<MkButton @click="config()" inline v-if="selectedPlugin.config"><Fa :icon="faCog"/> {{ $t('settings') }}</MkButton>
|
||||
<MkButton @click="uninstall()" inline><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { AiScript, parse } from '@syuilo/aiscript';
|
||||
import { serialize } from '@syuilo/aiscript/built/serializer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { faPlug, faSave, faTrashAlt, faFolderOpen, faDownload, faCog } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import MkTextarea from '@/components/ui/textarea.vue';
|
||||
import MkSelect from '@/components/ui/select.vue';
|
||||
import MkInfo from '@/components/ui/info.vue';
|
||||
import MkSwitch from '@/components/ui/switch.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
MkTextarea,
|
||||
MkSelect,
|
||||
MkInfo,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
script: '',
|
||||
selectedPluginId: null,
|
||||
faPlug, faSave, faTrashAlt, faFolderOpen, faDownload, faCog
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedPlugin() {
|
||||
if (this.selectedPluginId == null) return null;
|
||||
return this.$store.state.deviceUser.plugins.find(x => x.id === this.selectedPluginId);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async install() {
|
||||
let ast;
|
||||
try {
|
||||
ast = parse(this.script);
|
||||
} catch (e) {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: 'Syntax error :('
|
||||
});
|
||||
return;
|
||||
}
|
||||
const meta = AiScript.collectMetadata(ast);
|
||||
if (meta == null) {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: 'No metadata found :('
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = meta.get(null);
|
||||
if (data == null) {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: 'No metadata found :('
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { name, version, author, description, permissions, config } = data;
|
||||
if (name == null || version == null || author == null) {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: 'Required property not found :('
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = permissions == null || permissions.length === 0 ? null : await new Promise(async (res, rej) => {
|
||||
os.popup(await import('@/components/token-generate-window.vue'), {
|
||||
title: this.$t('tokenRequested'),
|
||||
information: this.$t('pluginTokenRequestedDescription'),
|
||||
initialName: name,
|
||||
initialPermissions: permissions
|
||||
}, {
|
||||
done: async result => {
|
||||
const { name, permissions } = result;
|
||||
const { token } = await os.api('miauth/gen-token', {
|
||||
session: null,
|
||||
name: name,
|
||||
permission: permissions,
|
||||
});
|
||||
|
||||
res(token);
|
||||
}
|
||||
}, 'closed');
|
||||
});
|
||||
|
||||
this.$store.commit('deviceUser/installPlugin', {
|
||||
id: uuid(),
|
||||
meta: {
|
||||
name, version, author, description, permissions, config
|
||||
},
|
||||
token,
|
||||
ast: serialize(ast)
|
||||
});
|
||||
|
||||
os.success();
|
||||
|
||||
this.$nextTick(() => {
|
||||
location.reload();
|
||||
});
|
||||
},
|
||||
|
||||
uninstall() {
|
||||
this.$store.commit('deviceUser/uninstallPlugin', this.selectedPluginId);
|
||||
os.success();
|
||||
this.$nextTick(() => {
|
||||
location.reload();
|
||||
});
|
||||
},
|
||||
|
||||
// TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする
|
||||
async config() {
|
||||
const config = this.selectedPlugin.config;
|
||||
for (const key in this.selectedPlugin.configData) {
|
||||
config[key].default = this.selectedPlugin.configData[key];
|
||||
}
|
||||
|
||||
const { canceled, result } = await os.form(this.selectedPlugin.name, config);
|
||||
if (canceled) return;
|
||||
|
||||
this.$store.commit('deviceUser/configPlugin', {
|
||||
id: this.selectedPluginId,
|
||||
config: result
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
location.reload();
|
||||
});
|
||||
},
|
||||
|
||||
changeActive(plugin, active) {
|
||||
this.$store.commit('deviceUser/changePluginActive', {
|
||||
id: plugin.id,
|
||||
active: active
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
86
src/client/pages/settings/privacy.vue
Normal file
86
src/client/pages/settings/privacy.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="_section">
|
||||
<div class="_card">
|
||||
<div class="_content">
|
||||
<MkSwitch v-model:value="isLocked" @update:value="save()">{{ $t('makeFollowManuallyApprove') }}</MkSwitch>
|
||||
<MkSwitch v-model:value="autoAcceptFollowed" v-if="isLocked" @update:value="save()">{{ $t('autoAcceptFollowed') }}</MkSwitch>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<MkSwitch v-model:value="rememberNoteVisibility" @update:value="save()">{{ $t('rememberNoteVisibility') }}</MkSwitch>
|
||||
<MkSelect v-model:value="defaultNoteVisibility" style="margin-bottom: 8px;" v-if="!rememberNoteVisibility">
|
||||
<template #label>{{ $t('defaultNoteVisibility') }}</template>
|
||||
<option value="public">{{ $t('_visibility.public') }}</option>
|
||||
<option value="home">{{ $t('_visibility.home') }}</option>
|
||||
<option value="followers">{{ $t('_visibility.followers') }}</option>
|
||||
<option value="specified">{{ $t('_visibility.specified') }}</option>
|
||||
</MkSelect>
|
||||
<MkSwitch v-model:value="defaultNoteLocalOnly" v-if="!rememberNoteVisibility">{{ $t('_visibility.localOnly') }}</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faLockOpen } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkSelect from '@/components/ui/select.vue';
|
||||
import MkSwitch from '@/components/ui/switch.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkSelect,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('privacy'),
|
||||
icon: faLockOpen
|
||||
}]
|
||||
},
|
||||
isLocked: false,
|
||||
autoAcceptFollowed: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
defaultNoteVisibility: {
|
||||
get() { return this.$store.state.settings.defaultNoteVisibility; },
|
||||
set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); }
|
||||
},
|
||||
|
||||
defaultNoteLocalOnly: {
|
||||
get() { return this.$store.state.settings.defaultNoteLocalOnly; },
|
||||
set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteLocalOnly', 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;
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
save() {
|
||||
os.api('i/update', {
|
||||
isLocked: !!this.isLocked,
|
||||
autoAcceptFollowed: !!this.autoAcceptFollowed,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
232
src/client/pages/settings/profile.vue
Normal file
232
src/client/pages/settings/profile.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="_section">
|
||||
<div class="llvierxe _card">
|
||||
<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">
|
||||
<div class="header" :style="{ backgroundImage: $store.state.i.bannerUrl ? `url(${ $store.state.i.bannerUrl })` : null }" @click="changeBanner">
|
||||
<MkAvatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true" @click.stop="changeAvatar"/>
|
||||
</div>
|
||||
|
||||
<MkInput v-model:value="name" :max="30">
|
||||
<span>{{ $t('_profile.name') }}</span>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model:value="description" :max="500">
|
||||
<span>{{ $t('_profile.description') }}</span>
|
||||
<template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkInput v-model:value="location">
|
||||
<span>{{ $t('location') }}</span>
|
||||
<template #prefix><Fa :icon="faMapMarkerAlt"/></template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model:value="birthday" type="date">
|
||||
<template #title>{{ $t('birthday') }}</template>
|
||||
<template #prefix><Fa :icon="faBirthdayCake"/></template>
|
||||
</MkInput>
|
||||
|
||||
<details class="fields">
|
||||
<summary>{{ $t('_profile.metadata') }}</summary>
|
||||
<div class="row">
|
||||
<MkInput v-model:value="fieldName0">{{ $t('_profile.metadataLabel') }}</MkInput>
|
||||
<MkInput v-model:value="fieldValue0">{{ $t('_profile.metadataContent') }}</MkInput>
|
||||
</div>
|
||||
<div class="row">
|
||||
<MkInput v-model:value="fieldName1">{{ $t('_profile.metadataLabel') }}</MkInput>
|
||||
<MkInput v-model:value="fieldValue1">{{ $t('_profile.metadataContent') }}</MkInput>
|
||||
</div>
|
||||
<div class="row">
|
||||
<MkInput v-model:value="fieldName2">{{ $t('_profile.metadataLabel') }}</MkInput>
|
||||
<MkInput v-model:value="fieldValue2">{{ $t('_profile.metadataContent') }}</MkInput>
|
||||
</div>
|
||||
<div class="row">
|
||||
<MkInput v-model:value="fieldName3">{{ $t('_profile.metadataLabel') }}</MkInput>
|
||||
<MkInput v-model:value="fieldValue3">{{ $t('_profile.metadataContent') }}</MkInput>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<MkSwitch v-model:value="isBot">{{ $t('flagAsBot') }}</MkSwitch>
|
||||
<MkSwitch v-model:value="isCat">{{ $t('flagAsCat') }}</MkSwitch>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<MkButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faUnlockAlt, faCogs, 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 { host } from '@/config';
|
||||
import { selectFile } from '@/scripts/select-file';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
MkInput,
|
||||
MkTextarea,
|
||||
MkSwitch,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('profile'),
|
||||
icon: faUser
|
||||
}]
|
||||
},
|
||||
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,
|
||||
faSave, faUnlockAlt, faCogs, 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;
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeAvatar(e) {
|
||||
selectFile(e.currentTarget || e.target, this.$t('avatar')).then(file => {
|
||||
os.api('i/update', {
|
||||
avatarId: file.id,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
changeBanner(e) {
|
||||
selectFile(e.currentTarget || e.target, this.$t('banner')).then(file => {
|
||||
os.api('i/update', {
|
||||
bannerId: file.id,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
os.api('i/update', {
|
||||
name: this.name || null,
|
||||
description: this.description || null,
|
||||
location: this.location || null,
|
||||
birthday: this.birthday || null,
|
||||
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) {
|
||||
os.success();
|
||||
}
|
||||
}).catch(err => {
|
||||
this.saving = false;
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: err.id
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.llvierxe {
|
||||
> ._content {
|
||||
> .header {
|
||||
position: relative;
|
||||
height: 150px;
|
||||
overflow: hidden;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 5px;
|
||||
border: solid 1px var(--divider);
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
|
||||
> .avatar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
> .fields {
|
||||
> .row {
|
||||
> * {
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
95
src/client/pages/settings/reaction.vue
Normal file
95
src/client/pages/settings/reaction.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="_section">
|
||||
<div class="_card">
|
||||
<div class="_title"><Fa :icon="faLaugh"/> {{ $t('reaction') }}</div>
|
||||
<div class="_content">
|
||||
<MkInput v-model:value="reactions" style="font-family: 'Segoe UI Emoji', 'Noto Color Emoji', Roboto, HelveticaNeue, Arial, sans-serif">
|
||||
{{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></template>
|
||||
</MkInput>
|
||||
<MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
|
||||
<MkButton inline @click="preview"><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkInput from '@/components/ui/input.vue';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import { emojiRegexWithCustom } from '../../../misc/emoji-regex';
|
||||
import { defaultSettings } from '@/store';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkInput,
|
||||
MkButton,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('reaction'),
|
||||
icon: faLaugh
|
||||
}]
|
||||
},
|
||||
reactions: this.$store.state.settings.reactions.join(''),
|
||||
changed: false,
|
||||
faLaugh, faSave, faEye, faUndo
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
splited(): any {
|
||||
return this.reactions.match(emojiRegexWithCustom);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
reactions: {
|
||||
handler() {
|
||||
this.changed = true;
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
save() {
|
||||
this.$store.dispatch('settings/set', { key: 'reactions', value: this.splited });
|
||||
this.changed = false;
|
||||
},
|
||||
|
||||
async preview(ev) {
|
||||
os.popup(await import('@/components/reaction-picker.vue'), {
|
||||
reactions: this.splited,
|
||||
showFocus: false,
|
||||
src: ev.currentTarget || ev.target,
|
||||
}, {}, 'closed');
|
||||
},
|
||||
|
||||
setDefault() {
|
||||
this.reactions = defaultSettings.reactions.join('');
|
||||
},
|
||||
|
||||
async chooseEmoji(ev) {
|
||||
os.pickEmoji(ev.currentTarget || ev.target).then(emoji => {
|
||||
this.reactions += emoji;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
235
src/client/pages/settings/security.2fa.vue
Normal file
235
src/client/pages/settings/security.2fa.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<section class="_card">
|
||||
<div class="_title"><Fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div>
|
||||
<div class="_content">
|
||||
<MkButton v-if="!data && !$store.state.i.twoFactorEnabled" @click="register">{{ $t('_2fa.registerDevice') }}</MkButton>
|
||||
<template v-if="$store.state.i.twoFactorEnabled">
|
||||
<p>{{ $t('_2fa.alreadyRegistered') }}</p>
|
||||
<MkButton @click="unregister">{{ $t('unregister') }}</MkButton>
|
||||
|
||||
<template v-if="supportsCredentials">
|
||||
<hr class="totp-method-sep">
|
||||
|
||||
<h2 class="heading">{{ $t('securityKey') }}</h2>
|
||||
<p>{{ $t('_2fa.securityKeyInfo') }}</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('lastUsed') }}<MkTime :time="key.lastUsed"/></div>
|
||||
<MkButton @click="unregisterKey(key)">{{ $t('unregister') }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkSwitch v-model:value="usePasswordLessLogin" @update:value="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">{{ $t('passwordLessLogin') }}</MkSwitch>
|
||||
|
||||
<MkInfo warn v-if="registration && registration.error">{{ $t('error') }} {{ registration.error }}</MkInfo>
|
||||
<MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('_2fa.registerKey') }}</MkButton>
|
||||
|
||||
<ol v-if="registration && !registration.error">
|
||||
<li v-if="registration.stage >= 0">
|
||||
{{ $t('tapSecurityKey') }}
|
||||
<Fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
|
||||
</li>
|
||||
<li v-if="registration.stage >= 1">
|
||||
<MkForm :disabled="registration.stage != 1 || registration.saving">
|
||||
<MkInput v-model:value="keyName" :max="30">
|
||||
<span>{{ $t('securityKeyName') }}</span>
|
||||
</MkInput>
|
||||
<MkButton @click="registerKey" :disabled="keyName.length == 0">{{ $t('registerSecurityKey') }}</MkButton>
|
||||
<Fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
|
||||
</MkForm>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="data && !$store.state.i.twoFactorEnabled">
|
||||
<ol style="margin: 0; padding: 0 0 0 1em;">
|
||||
<li>
|
||||
<i18n-t keypath="_2fa.step1" tag="span">
|
||||
<template #a>
|
||||
<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
|
||||
</template>
|
||||
<template #b>
|
||||
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</li>
|
||||
<li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li>
|
||||
<li>{{ $t('_2fa.step3') }}<br>
|
||||
<MkInput v-model:value="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</MkInput>
|
||||
<MkButton primary @click="submit">{{ $t('done') }}</MkButton>
|
||||
</li>
|
||||
</ol>
|
||||
<MkInfo>{{ $t('_2fa.step4') }}</MkInfo>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { hostname } from '@/config';
|
||||
import { byteify, hexify, stringify } from '@/scripts/2fa';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import MkInfo from '@/components/ui/info.vue';
|
||||
import MkInput from '@/components/ui/input.vue';
|
||||
import MkSwitch from '@/components/ui/switch.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton, MkInfo, MkInput, MkSwitch
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
data: null,
|
||||
supportsCredentials: !!navigator.credentials,
|
||||
usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin,
|
||||
registration: null,
|
||||
keyName: '',
|
||||
token: null,
|
||||
faLock
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
register() {
|
||||
os.dialog({
|
||||
title: this.$t('password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api('i/2fa/register', {
|
||||
password: password
|
||||
}).then(data => {
|
||||
this.data = data;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
unregister() {
|
||||
os.dialog({
|
||||
title: this.$t('password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api('i/2fa/unregister', {
|
||||
password: password
|
||||
}).then(() => {
|
||||
this.usePasswordLessLogin = false;
|
||||
this.updatePasswordLessLogin();
|
||||
}).then(() => {
|
||||
os.success();
|
||||
this.$store.state.i.twoFactorEnabled = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
submit() {
|
||||
os.api('i/2fa/done', {
|
||||
token: this.token
|
||||
}).then(() => {
|
||||
os.success();
|
||||
this.$store.state.i.twoFactorEnabled = true;
|
||||
}).catch(e => {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: e
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
registerKey() {
|
||||
this.registration.saving = true;
|
||||
os.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: stringify(this.registration.credential.response.clientDataJSON),
|
||||
attestationObject: hexify(this.registration.credential.response.attestationObject)
|
||||
}).then(key => {
|
||||
this.registration = null;
|
||||
key.lastUsed = new Date();
|
||||
os.success();
|
||||
})
|
||||
},
|
||||
|
||||
unregisterKey(key) {
|
||||
os.dialog({
|
||||
title: this.$t('password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
return os.api('i/2fa/remove-key', {
|
||||
password,
|
||||
credentialId: key.id
|
||||
}).then(() => {
|
||||
this.usePasswordLessLogin = false;
|
||||
this.updatePasswordLessLogin();
|
||||
}).then(() => {
|
||||
os.success();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
addSecurityKey() {
|
||||
os.dialog({
|
||||
title: this.$t('password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api('i/2fa/register-key', {
|
||||
password
|
||||
}).then(registration => {
|
||||
this.registration = {
|
||||
password,
|
||||
challengeId: registration.challengeId,
|
||||
stage: 0,
|
||||
publicKeyOptions: {
|
||||
challenge: byteify(registration.challenge, 'base64'),
|
||||
rp: {
|
||||
id: hostname,
|
||||
name: 'Misskey'
|
||||
},
|
||||
user: {
|
||||
id: byteify(this.$store.state.i.id, 'ascii'),
|
||||
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() {
|
||||
os.api('i/2fa/password-less', {
|
||||
value: !!this.usePasswordLessLogin
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
102
src/client/pages/settings/security.vue
Normal file
102
src/client/pages/settings/security.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="_section">
|
||||
<X2fa/>
|
||||
</div>
|
||||
<div class="_section">
|
||||
<MkButton primary @click="change()" full>{{ $t('changePassword') }}</MkButton>
|
||||
</div>
|
||||
<div class="_section">
|
||||
<MkButton class="_vMargin" primary @click="regenerateToken" full><Fa :icon="faSyncAlt"/> {{ $t('regenerateLoginToken') }}</MkButton>
|
||||
<div class="_caption _vMargin" style="padding: 0 6px;">{{ $t('regenerateLoginTokenDescription') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faLock, faSyncAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import X2fa from './security.2fa.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
X2fa,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('security'),
|
||||
icon: faLock
|
||||
}]
|
||||
},
|
||||
faLock, faSyncAlt
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
async change() {
|
||||
const { canceled: canceled1, result: currentPassword } = await os.dialog({
|
||||
title: this.$t('currentPassword'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
});
|
||||
if (canceled1) return;
|
||||
|
||||
const { canceled: canceled2, result: newPassword } = await os.dialog({
|
||||
title: this.$t('newPassword'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
});
|
||||
if (canceled2) return;
|
||||
|
||||
const { canceled: canceled3, result: newPassword2 } = await os.dialog({
|
||||
title: this.$t('newPasswordRetype'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
});
|
||||
if (canceled3) return;
|
||||
|
||||
if (newPassword !== newPassword2) {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('retypedNotMatch')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
os.apiWithDialog('i/change-password', {
|
||||
currentPassword,
|
||||
newPassword
|
||||
});
|
||||
},
|
||||
|
||||
regenerateToken() {
|
||||
os.dialog({
|
||||
title: this.$t('password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
os.api('i/regenerate_token', {
|
||||
password: password
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
110
src/client/pages/settings/sidebar.vue
Normal file
110
src/client/pages/settings/sidebar.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="_section">
|
||||
<div class="_card">
|
||||
<div class="_content">
|
||||
<MkTextarea v-model:value="items" tall>
|
||||
<span>{{ $t('sidebar') }}</span>
|
||||
<template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template>
|
||||
</MkTextarea>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<div>{{ $t('display') }}</div>
|
||||
<MkRadio v-model="sidebarDisplay" value="full">{{ $t('_sidebar.full') }}</MkRadio>
|
||||
<MkRadio v-model="sidebarDisplay" value="icon">{{ $t('_sidebar.icon') }}</MkRadio>
|
||||
<!-- <MkRadio v-model="sidebarDisplay" value="hide" disabled>{{ $t('_sidebar.hide') }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 -->
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<MkButton inline @click="save()" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
|
||||
<MkButton inline @click="reset()"><Fa :icon="faRedo"/> {{ $t('default') }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faListUl, faSave, faRedo } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import MkTextarea from '@/components/ui/textarea.vue';
|
||||
import MkRadio from '@/components/ui/radio.vue';
|
||||
import { defaultDeviceUserSettings } from '@/store';
|
||||
import * as os from '@/os';
|
||||
import { sidebarDef } from '@/sidebar';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
MkTextarea,
|
||||
MkRadio,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('sidebar'),
|
||||
icon: faListUl
|
||||
}]
|
||||
},
|
||||
menuDef: sidebarDef,
|
||||
items: '',
|
||||
faSave, faRedo
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
splited(): string[] {
|
||||
return this.items.trim().split('\n').filter(x => x.trim() !== '');
|
||||
},
|
||||
|
||||
sidebarDisplay: {
|
||||
get() { return this.$store.state.device.sidebarDisplay; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sidebarDisplay', value }); }
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.items = this.$store.state.deviceUser.menu.join('\n');
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
async addItem() {
|
||||
const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.deviceUser.menu.includes(k));
|
||||
const { canceled, result: item } = await os.dialog({
|
||||
type: null,
|
||||
title: this.$t('addItem'),
|
||||
select: {
|
||||
items: [...menu.map(k => ({
|
||||
value: k, text: this.$t(this.menuDef[k].title)
|
||||
})), ...[{
|
||||
value: '-', text: this.$t('divider')
|
||||
}]]
|
||||
},
|
||||
showCancelButton: true
|
||||
});
|
||||
if (canceled) return;
|
||||
this.items = [...this.splited, item].join('\n');
|
||||
this.save();
|
||||
},
|
||||
|
||||
save() {
|
||||
this.$store.commit('deviceUser/setMenu', this.splited);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.$store.commit('deviceUser/setMenu', defaultDeviceUserSettings.menu);
|
||||
this.items = this.$store.state.deviceUser.menu.join('\n');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
152
src/client/pages/settings/sounds.vue
Normal file
152
src/client/pages/settings/sounds.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="_section">
|
||||
<div class="_card">
|
||||
<div class="_title"><Fa :icon="faMusic"/> {{ $t('sounds') }}</div>
|
||||
<div class="_content">
|
||||
<MkRange v-model:value="sfxVolume" :min="0" :max="1" :step="0.1">
|
||||
<Fa slot="icon" :icon="volumeIcon"/>
|
||||
<span slot="title">{{ $t('volume') }}</span>
|
||||
</MkRange>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<MkSelect v-model:value="sfxNote">
|
||||
<template #label>{{ $t('_sfx.note') }}</template>
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxNote)" v-if="sfxNote"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</MkSelect>
|
||||
<MkSelect v-model:value="sfxNoteMy">
|
||||
<template #label>{{ $t('_sfx.noteMy') }}</template>
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxNoteMy)" v-if="sfxNoteMy"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</MkSelect>
|
||||
<MkSelect v-model:value="sfxNotification">
|
||||
<template #label>{{ $t('_sfx.notification') }}</template>
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxNotification)" v-if="sfxNotification"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</MkSelect>
|
||||
<MkSelect v-model:value="sfxChat">
|
||||
<template #label>{{ $t('_sfx.chat') }}</template>
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxChat)" v-if="sfxChat"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</MkSelect>
|
||||
<MkSelect v-model:value="sfxChatBg">
|
||||
<template #label>{{ $t('_sfx.chatBg') }}</template>
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxChatBg)" v-if="sfxChatBg"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</MkSelect>
|
||||
<MkSelect v-model:value="sfxAntenna">
|
||||
<template #label>{{ $t('_sfx.antenna') }}</template>
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</MkSelect>
|
||||
<MkSelect v-model:value="sfxChannel">
|
||||
<template #label>{{ $t('_sfx.channel') }}</template>
|
||||
<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
|
||||
<template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
|
||||
</MkSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkSelect from '@/components/ui/select.vue';
|
||||
import MkRange from '@/components/ui/range.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
const sounds = [
|
||||
null,
|
||||
'syuilo/up',
|
||||
'syuilo/down',
|
||||
'syuilo/pope1',
|
||||
'syuilo/pope2',
|
||||
'syuilo/waon',
|
||||
'syuilo/popo',
|
||||
'syuilo/triple',
|
||||
'syuilo/poi1',
|
||||
'syuilo/poi2',
|
||||
'syuilo/pirori',
|
||||
'syuilo/pirori-wet',
|
||||
'syuilo/pirori-square-wet',
|
||||
'syuilo/square-pico',
|
||||
'syuilo/reverved',
|
||||
'syuilo/ryukyu',
|
||||
'aisha/1',
|
||||
'aisha/2',
|
||||
'aisha/3',
|
||||
'noizenecio/kick_gaba',
|
||||
'noizenecio/kick_gaba2',
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkSelect,
|
||||
MkRange,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
sounds,
|
||||
faMusic, faPlay, faVolumeUp, faVolumeMute,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
sfxVolume: {
|
||||
get() { return this.$store.state.device.sfxVolume; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); }
|
||||
},
|
||||
|
||||
sfxNote: {
|
||||
get() { return this.$store.state.device.sfxNote; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxNote', value }); }
|
||||
},
|
||||
|
||||
sfxNoteMy: {
|
||||
get() { return this.$store.state.device.sfxNoteMy; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxNoteMy', value }); }
|
||||
},
|
||||
|
||||
sfxNotification: {
|
||||
get() { return this.$store.state.device.sfxNotification; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxNotification', value }); }
|
||||
},
|
||||
|
||||
sfxChat: {
|
||||
get() { return this.$store.state.device.sfxChat; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxChat', value }); }
|
||||
},
|
||||
|
||||
sfxChatBg: {
|
||||
get() { return this.$store.state.device.sfxChatBg; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxChatBg', value }); }
|
||||
},
|
||||
|
||||
sfxAntenna: {
|
||||
get() { return this.$store.state.device.sfxAntenna; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); }
|
||||
},
|
||||
|
||||
sfxChannel: {
|
||||
get() { return this.$store.state.device.sfxChannel; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); }
|
||||
},
|
||||
|
||||
volumeIcon: {
|
||||
get() {
|
||||
return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
listen(sound) {
|
||||
const audio = new Audio(`/assets/sounds/${sound}.mp3`);
|
||||
audio.volume = this.$store.state.device.sfxVolume;
|
||||
audio.play();
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
499
src/client/pages/settings/theme.vue
Normal file
499
src/client/pages/settings/theme.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div class="_section">
|
||||
<div class="rfqxtzch _card _vMargin">
|
||||
<div class="_content">
|
||||
<div class="darkMode" :class="{ disabled: syncDeviceDarkMode }">
|
||||
<div class="toggleWrapper">
|
||||
<input type="checkbox" class="dn" id="dn" v-model="darkMode" :disabled="syncDeviceDarkMode"/>
|
||||
<label for="dn" class="toggle">
|
||||
<span class="before">{{ $t('light') }}</span>
|
||||
<span class="after">{{ $t('dark') }}</span>
|
||||
<span class="toggle__handler">
|
||||
<span class="crater crater--1"></span>
|
||||
<span class="crater crater--2"></span>
|
||||
<span class="crater crater--3"></span>
|
||||
</span>
|
||||
<span class="star star--1"></span>
|
||||
<span class="star star--2"></span>
|
||||
<span class="star star--3"></span>
|
||||
<span class="star star--4"></span>
|
||||
<span class="star star--5"></span>
|
||||
<span class="star star--6"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<MkSwitch v-model:value="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</MkSwitch>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<MkSelect v-model:value="lightTheme">
|
||||
<template #label>{{ $t('themeForLightMode') }}</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>
|
||||
</MkSelect>
|
||||
<MkSelect v-model:value="darkTheme">
|
||||
<template #label>{{ $t('themeForDarkMode') }}</template>
|
||||
<optgroup :label="$t('darkThemes')">
|
||||
<option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="$t('lightThemes')">
|
||||
<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
|
||||
</optgroup>
|
||||
</MkSelect>
|
||||
<a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>・<router-link to="/theme-editor" class="_link">{{ $t('_theme.make') }}</router-link>
|
||||
</div>
|
||||
<div class="_content">
|
||||
<MkButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</MkButton>
|
||||
<MkButton primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_card _vMargin">
|
||||
<div class="_title"><Fa :icon="faDownload"/> {{ $t('_theme.install') }}</div>
|
||||
<div class="_content">
|
||||
<MkTextarea v-model:value="installThemeCode">
|
||||
<span>{{ $t('_theme.code') }}</span>
|
||||
</MkTextarea>
|
||||
<MkButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><Fa :icon="faCheck"/> {{ $t('install') }}</MkButton>
|
||||
<MkButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_card _vMargin">
|
||||
<div class="_title"><Fa :icon="faFolderOpen"/> {{ $t('_theme.manage') }}</div>
|
||||
<div class="_content">
|
||||
<MkSelect v-model:value="selectedThemeId">
|
||||
<option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
|
||||
</MkSelect>
|
||||
<template v-if="selectedTheme">
|
||||
<MkTextarea readonly tall :value="selectedThemeCode">
|
||||
<span>{{ $t('_theme.code') }}</span>
|
||||
<template #desc><button @click="copyThemeCode()" class="_textButton">{{ $t('copy') }}</button></template>
|
||||
</MkTextarea>
|
||||
<MkButton @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
|
||||
import * as JSON5 from 'json5';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import MkSelect from '@/components/ui/select.vue';
|
||||
import MkSwitch from '@/components/ui/switch.vue';
|
||||
import MkTextarea from '@/components/ui/textarea.vue';
|
||||
import { Theme, builtinThemes, applyTheme, validateTheme } from '@/scripts/theme';
|
||||
import { selectFile } from '@/scripts/select-file';
|
||||
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
MkSelect,
|
||||
MkSwitch,
|
||||
MkTextarea,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('theme'),
|
||||
icon: faPalette
|
||||
}]
|
||||
},
|
||||
builtinThemes,
|
||||
installThemeCode: null,
|
||||
selectedThemeId: null,
|
||||
wallpaper: localStorage.getItem('wallpaper'),
|
||||
faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye
|
||||
}
|
||||
},
|
||||
|
||||
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');
|
||||
},
|
||||
|
||||
darkTheme: {
|
||||
get() { return this.$store.state.device.darkTheme; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'darkTheme', value }); }
|
||||
},
|
||||
|
||||
lightTheme: {
|
||||
get() { return this.$store.state.device.lightTheme; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'lightTheme', value }); }
|
||||
},
|
||||
|
||||
darkMode: {
|
||||
get() { return this.$store.state.device.darkMode; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'darkMode', value }); }
|
||||
},
|
||||
|
||||
syncDeviceDarkMode: {
|
||||
get() { return this.$store.state.device.syncDeviceDarkMode; },
|
||||
set(value) { this.$store.commit('device/set', { key: 'syncDeviceDarkMode', value }); }
|
||||
},
|
||||
|
||||
selectedTheme() {
|
||||
if (this.selectedThemeId == null) return null;
|
||||
return this.themes.find(x => x.id === this.selectedThemeId);
|
||||
},
|
||||
|
||||
selectedThemeCode() {
|
||||
if (this.selectedTheme == null) return null;
|
||||
return JSON5.stringify(this.selectedTheme, null, '\t');
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
darkTheme() {
|
||||
if (this.$store.state.device.darkMode) {
|
||||
applyTheme(this.themes.find(x => x.id === this.darkTheme));
|
||||
}
|
||||
},
|
||||
|
||||
lightTheme() {
|
||||
if (!this.$store.state.device.darkMode) {
|
||||
applyTheme(this.themes.find(x => x.id === this.lightTheme));
|
||||
}
|
||||
},
|
||||
|
||||
syncDeviceDarkMode() {
|
||||
if (this.$store.state.device.syncDeviceDarkMode) {
|
||||
this.$store.commit('device/set', { key: 'darkMode', value: isDeviceDarkmode() });
|
||||
}
|
||||
},
|
||||
|
||||
wallpaper() {
|
||||
if (this.wallpaper == null) {
|
||||
localStorage.removeItem('wallpaper');
|
||||
} else {
|
||||
localStorage.setItem('wallpaper', this.wallpaper);
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
setWallpaper(e) {
|
||||
selectFile(e.currentTarget || e.target, null, false).then(file => {
|
||||
this.wallpaper = file.url;
|
||||
});
|
||||
},
|
||||
|
||||
copyThemeCode() {
|
||||
copyToClipboard(this.selectedThemeCode);
|
||||
os.success();
|
||||
},
|
||||
|
||||
parseThemeCode(code) {
|
||||
let theme;
|
||||
|
||||
try {
|
||||
theme = JSON5.parse(code);
|
||||
} catch (e) {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('_theme.invalid')
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (!validateTheme(theme)) {
|
||||
os.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('_theme.invalid')
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (this.$store.state.device.themes.some(t => t.id === theme.id)) {
|
||||
os.dialog({
|
||||
type: 'info',
|
||||
text: this.$t('_theme.alreadyInstalled')
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return theme;
|
||||
},
|
||||
|
||||
preview(code) {
|
||||
const theme = this.parseThemeCode(code);
|
||||
if (theme) applyTheme(theme, false);
|
||||
},
|
||||
|
||||
install(code) {
|
||||
const theme = this.parseThemeCode(code);
|
||||
if (!theme) return;
|
||||
const themes = this.$store.state.device.themes.concat(theme);
|
||||
this.$store.commit('device/set', {
|
||||
key: 'themes', value: themes
|
||||
});
|
||||
os.dialog({
|
||||
type: 'success',
|
||||
text: this.$t('_theme.installed', { name: theme.name })
|
||||
});
|
||||
},
|
||||
|
||||
uninstall() {
|
||||
const theme = this.selectedTheme;
|
||||
const themes = this.$store.state.device.themes.filter(t => t.id != theme.id);
|
||||
this.$store.commit('device/set', {
|
||||
key: 'themes', value: themes
|
||||
});
|
||||
os.success();
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rfqxtzch {
|
||||
> ._content {
|
||||
> .darkMode {
|
||||
position: relative;
|
||||
padding: 32px 0;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
|
||||
&, * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleWrapper {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
overflow: hidden;
|
||||
padding: 0 100px;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
left: -99em;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 90px;
|
||||
height: 50px;
|
||||
background-color: #83D8FF;
|
||||
border-radius: 90px - 6;
|
||||
transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
|
||||
|
||||
> .before, > .after {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
font-size: 18px;
|
||||
transition: color 1s ease;
|
||||
}
|
||||
|
||||
> .before {
|
||||
left: -70px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
> .after {
|
||||
right: -68px;
|
||||
color: var(--fg);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle__handler {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 50px - 6;
|
||||
height: 50px - 6;
|
||||
background-color: #FFCF96;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.3);
|
||||
transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important;
|
||||
transform: rotate(-45deg);
|
||||
|
||||
.crater {
|
||||
position: absolute;
|
||||
background-color: #E8CDA5;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in-out !important;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.crater--1 {
|
||||
top: 18px;
|
||||
left: 10px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.crater--2 {
|
||||
top: 28px;
|
||||
left: 22px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.crater--3 {
|
||||
top: 10px;
|
||||
left: 25px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.star {
|
||||
position: absolute;
|
||||
background-color: #ffffff;
|
||||
transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.star--1 {
|
||||
top: 10px;
|
||||
left: 35px;
|
||||
z-index: 0;
|
||||
width: 30px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.star--2 {
|
||||
top: 18px;
|
||||
left: 28px;
|
||||
z-index: 1;
|
||||
width: 30px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.star--3 {
|
||||
top: 27px;
|
||||
left: 40px;
|
||||
z-index: 0;
|
||||
width: 30px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.star--4,
|
||||
.star--5,
|
||||
.star--6 {
|
||||
opacity: 0;
|
||||
transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
|
||||
}
|
||||
|
||||
.star--4 {
|
||||
top: 16px;
|
||||
left: 11px;
|
||||
z-index: 0;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
transform: translate3d(3px,0,0);
|
||||
}
|
||||
|
||||
.star--5 {
|
||||
top: 32px;
|
||||
left: 17px;
|
||||
z-index: 0;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
transform: translate3d(3px,0,0);
|
||||
}
|
||||
|
||||
.star--6 {
|
||||
top: 36px;
|
||||
left: 28px;
|
||||
z-index: 0;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
transform: translate3d(3px,0,0);
|
||||
}
|
||||
|
||||
input:checked {
|
||||
+ .toggle {
|
||||
background-color: #749DD6;
|
||||
|
||||
> .before {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
> .after {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toggle__handler {
|
||||
background-color: #FFE5B5;
|
||||
transform: translate3d(40px, 0, 0) rotate(0);
|
||||
|
||||
.crater { opacity: 1; }
|
||||
}
|
||||
|
||||
.star--1 {
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.star--2 {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
transform: translate3d(-5px, 0, 0);
|
||||
}
|
||||
|
||||
.star--3 {
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
transform: translate3d(-7px, 0, 0);
|
||||
}
|
||||
|
||||
.star--4,
|
||||
.star--5,
|
||||
.star--6 {
|
||||
opacity: 1;
|
||||
transform: translate3d(0,0,0);
|
||||
}
|
||||
|
||||
.star--4 {
|
||||
transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
|
||||
}
|
||||
|
||||
.star--5 {
|
||||
transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
|
||||
}
|
||||
|
||||
.star--6 {
|
||||
transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
101
src/client/pages/settings/word-mute.vue
Normal file
101
src/client/pages/settings/word-mute.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="_section">
|
||||
<div class="_card">
|
||||
<MkTab v-model:value="tab" :items="[{ label: $t('_wordMute.soft'), value: 'soft' }, { label: $t('_wordMute.hard'), value: 'hard' }]"/>
|
||||
<div class="_content">
|
||||
<div v-show="tab === 'soft'">
|
||||
<MkInfo>{{ $t('_wordMute.softDescription') }}</MkInfo>
|
||||
<MkTextarea v-model:value="softMutedWords">
|
||||
<span>{{ $t('_wordMute.muteWords') }}</span>
|
||||
<template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
|
||||
</MkTextarea>
|
||||
</div>
|
||||
<div v-show="tab === 'hard'">
|
||||
<MkInfo>{{ $t('_wordMute.hardDescription') }}</MkInfo>
|
||||
<MkTextarea v-model:value="hardMutedWords" style="margin-bottom: 16px;">
|
||||
<span>{{ $t('_wordMute.muteWords') }}</span>
|
||||
<template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
|
||||
</MkTextarea>
|
||||
<div v-if="hardWordMutedNotesCount != null" class="_caption">{{ $t('_wordMute.mutedNotes') }}: {{ hardWordMutedNotesCount | number }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_footer">
|
||||
<MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons';
|
||||
import MkButton from '@/components/ui/button.vue';
|
||||
import MkTextarea from '@/components/ui/textarea.vue';
|
||||
import MkTab from '@/components/tab.vue';
|
||||
import MkInfo from '@/components/ui/info.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton,
|
||||
MkTextarea,
|
||||
MkTab,
|
||||
MkInfo,
|
||||
},
|
||||
|
||||
emits: ['info'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
INFO: {
|
||||
header: [{
|
||||
title: this.$t('wordMute'),
|
||||
icon: faCommentSlash
|
||||
}]
|
||||
},
|
||||
tab: 'soft',
|
||||
softMutedWords: '',
|
||||
hardMutedWords: '',
|
||||
hardWordMutedNotesCount: null,
|
||||
changed: false,
|
||||
faSave,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
softMutedWords: {
|
||||
handler() {
|
||||
this.changed = true;
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
hardMutedWords: {
|
||||
handler() {
|
||||
this.changed = true;
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
},
|
||||
|
||||
async created() {
|
||||
this.softMutedWords = this.$store.state.settings.mutedWords.map(x => x.join(' ')).join('\n');
|
||||
this.hardMutedWords = this.$store.state.i.mutedWords.map(x => x.join(' ')).join('\n');
|
||||
|
||||
this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count;
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$emit('info', this.INFO);
|
||||
},
|
||||
|
||||
methods: {
|
||||
async save() {
|
||||
this.$store.dispatch('settings/set', { key: 'mutedWords', value: this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')) });
|
||||
await os.api('i/update', {
|
||||
mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')),
|
||||
});
|
||||
this.changed = false;
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
Reference in New Issue
Block a user