diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-13 12:23:49 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-13 12:23:49 +0900 |
| commit | 2795fe457909c687f668d020ef65d52abc3182fb (patch) | |
| tree | 0a52e4e4d854333496fcc487560c93c3de5d5eb5 /packages/client/src/pages | |
| parent | Merge branch 'develop' (diff) | |
| parent | 12.96.0 (diff) | |
| download | misskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.gz misskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.bz2 misskey-2795fe457909c687f668d020ef65d52abc3182fb.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/client/src/pages')
172 files changed, 25230 insertions, 0 deletions
diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue new file mode 100644 index 0000000000..c549751a27 --- /dev/null +++ b/packages/client/src/pages/_error_.vue @@ -0,0 +1,94 @@ +<template> +<MkLoading v-if="!loaded" /> +<transition :name="$store.state.animation ? 'zoom' : ''" appear> + <div class="mjndxjch" v-show="loaded"> + <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p><b><i class="fas fa-exclamation-triangle"></i> {{ $ts.pageLoadError }}</b></p> + <p v-if="version === meta.version">{{ $ts.pageLoadErrorDescription }}</p> + <p v-else-if="serverIsDead">{{ $ts.serverIsDead }}</p> + <template v-else> + <p>{{ $ts.newVersionOfClientAvailable }}</p> + <p>{{ $ts.youShouldUpgradeClient }}</p> + <MkButton @click="reload" class="button primary">{{ $ts.reload }}</MkButton> + </template> + <p><MkA to="/docs/general/troubleshooting" class="_link">{{ $ts.troubleshooting }}</MkA></p> + <p v-if="error" class="error">ERROR: {{ error }}</p> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import * as symbols from '@/symbols'; +import { version } from '@/config'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; + +export default defineComponent({ + components: { + MkButton, + }, + props: { + error: { + required: false, + } + }, + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.error, + icon: 'fas fa-exclamation-triangle' + }, + loaded: false, + serverIsDead: false, + meta: {} as any, + version, + }; + }, + created() { + os.api('meta', { + detail: false + }).then(meta => { + this.loaded = true; + this.serverIsDead = false; + this.meta = meta; + localStorage.setItem('v', meta.version); + }, () => { + this.loaded = true; + this.serverIsDead = true; + }); + }, + methods: { + reload() { + unisonReload(); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.mjndxjch { + padding: 32px; + text-align: center; + + > p { + margin: 0 0 12px 0; + } + + > .button { + margin: 8px auto; + } + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 24px; + border-radius: 16px; + } + + > .error { + opacity: 0.7; + } +} +</style> diff --git a/packages/client/src/pages/_loading_.vue b/packages/client/src/pages/_loading_.vue new file mode 100644 index 0000000000..05c6af1cd7 --- /dev/null +++ b/packages/client/src/pages/_loading_.vue @@ -0,0 +1,10 @@ +<template> +<MkLoading/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({}); +</script> diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue new file mode 100644 index 0000000000..c428c1ad83 --- /dev/null +++ b/packages/client/src/pages/about-misskey.vue @@ -0,0 +1,238 @@ +<template> +<div style="overflow: clip;"> + <FormBase class="znqjceqz"> + <div id="debug"></div> + <section class="_debobigegoItem about"> + <div class="_debobigegoPanel panel" :class="{ playing: easterEggEngine != null }" ref="about"> + <img src="/client-assets/about-icon.png" alt="" class="icon" @load="iconLoaded" draggable="false" @click="gravity"/> + <div class="misskey">Misskey</div> + <div class="version">v{{ version }}</div> + <span class="emoji" v-for="emoji in easterEggEmojis" :key="emoji.id" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span> + </div> + </section> + <section class="_debobigegoItem" style="text-align: center; padding: 0 16px;"> + {{ $ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ $ts.learnMore }}</a> + </section> + <FormGroup> + <FormLink to="https://github.com/misskey-dev/misskey" external> + <template #icon><i class="fas fa-code"></i></template> + {{ $ts._aboutMisskey.source }} + <template #suffix>GitHub</template> + </FormLink> + <FormLink to="https://crowdin.com/project/misskey" external> + <template #icon><i class="fas fa-language"></i></template> + {{ $ts._aboutMisskey.translation }} + <template #suffix>Crowdin</template> + </FormLink> + <FormLink to="https://www.patreon.com/syuilo" external> + <template #icon><i class="fas fa-hand-holding-medical"></i></template> + {{ $ts._aboutMisskey.donate }} + <template #suffix>Patreon</template> + </FormLink> + </FormGroup> + <FormGroup> + <template #label>{{ $ts._aboutMisskey.contributors }}</template> + <FormLink to="https://github.com/syuilo" external>@syuilo</FormLink> + <FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink> + <FormLink to="https://github.com/mei23" external>@mei23</FormLink> + <FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink> + <FormLink to="https://github.com/tamaina" external>@tamaina</FormLink> + <FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink> + <FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink> + <FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink> + <FormLink to="https://github.com/marihachi" external>@marihachi</FormLink> + <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ $ts._aboutMisskey.allContributors }}</MkLink></template> + </FormGroup> + <FormGroup> + <template #label><Mfm text="[jelly โค]"/> {{ $ts._aboutMisskey.patrons }}</template> + <FormKeyValueView v-for="patron in patrons" :key="patron"><template #key>{{ patron }}</template></FormKeyValueView> + <template #caption>{{ $ts._aboutMisskey.morePatrons }}</template> + </FormGroup> + </FormBase> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { version } from '@/config'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import MkLink from '@/components/link.vue'; +import { physics } from '@/scripts/physics'; +import * as symbols from '@/symbols'; + +const patrons = [ + 'Satsuki Yanagi', + 'noellabo', + 'mametsuko', + 'AureoleArk', + 'Gargron', + 'Nokotaro Takeda', + 'Suji Yan', + 'Hekovic', + 'Gitmo Life Services', + 'nenohi', + 'naga_rus', + 'Melilot', + 'Efertone', + 'oi_yekssim', + 'nanami kan', + 'motcha', + 'dansup', + 'Quinton Macejkovic', + 'YUKIMOCHI', + 'mewl hayabusa', + 'makokunsan', + 'Peter G.', + 'Nesakko', + 'regtan', + '่ฆๅฝใใชใฟ', + 'natalie', + 'Jerry', + 'takimura', + 'sikyosyounin', + 'YuzuRyo61', + 'sheeta.s', + 'osapon', + 'mkatze', + 'CG', + 'nafuchoco', + 'Takumi Sugita', + 'chidori ninokura', + 'mydarkstar', + 'kiritan', + 'kabo2468y', + 'weepjp', + 'Liaizon Wakest', + 'Steffen K9', + 'Roujo', + 'uroco @99', + 'totokoro', + 'public_yusuke', + 'wara', + 'S Y', + 'Denshi', + 'Osushimaru', + 'ๅดๆตฅ', + 'DignifiedSilence', + 't_w', +]; + +export default defineComponent({ + components: { + FormBase, + FormGroup, + FormLink, + FormKeyValueView, + MkLink, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.aboutMisskey, + icon: null + }, + version, + patrons, + easterEggReady: false, + easterEggEmojis: [], + easterEggEngine: null, + } + }, + + beforeUnmount() { + if (this.easterEggEngine) { + this.easterEggEngine.stop(); + } + }, + + methods: { + iconLoaded() { + const emojis = this.$store.state.reactions; + const containerWidth = this.$refs.about.offsetWidth; + for (let i = 0; i < 32; i++) { + this.easterEggEmojis.push({ + id: i.toString(), + top: -(128 + (Math.random() * 256)), + left: (Math.random() * containerWidth), + emoji: emojis[Math.floor(Math.random() * emojis.length)], + }); + } + + this.$nextTick(() => { + this.easterEggReady = true; + }); + }, + + gravity() { + if (!this.easterEggReady) return; + this.easterEggReady = false; + this.easterEggEngine = physics(this.$refs.about); + } + } +}); +</script> + +<style lang="scss" scoped> +.znqjceqz { + max-width: 800px; + box-sizing: border-box; + margin: 0 auto; + + > .about { + > .panel { + position: relative; + text-align: center; + padding: 16px; + + &.playing { + &, * { + user-select: none; + } + + * { + will-change: transform; + } + + > .emoji { + visibility: visible; + } + } + + > .icon { + display: block; + width: 100px; + margin: 0 auto; + border-radius: 16px; + } + + > .misskey { + margin: 0.75em auto 0 auto; + width: max-content; + } + + > .version { + margin: 0 auto; + width: max-content; + opacity: 0.5; + } + + > .emoji { + position: absolute; + top: 0; + left: 0; + visibility: hidden; + + > .emoji { + pointer-events: none; + font-size: 24px; + width: 24px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue new file mode 100644 index 0000000000..dbdf0f6d91 --- /dev/null +++ b/packages/client/src/pages/about.vue @@ -0,0 +1,123 @@ +<template> +<FormBase> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel fwhjspax"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <span class="name">{{ $instance.name || host }}</span> + </div> + </div> + + <FormTextarea readonly :value="$instance.description"> + </FormTextarea> + + <FormGroup> + <FormKeyValueView> + <template #key>Misskey</template> + <template #value>v{{ version }}</template> + </FormKeyValueView> + <FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink> + </FormGroup> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.administrator }}</template> + <template #value>{{ $instance.maintainerName }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.contact }}</template> + <template #value>{{ $instance.maintainerEmail }}</template> + </FormKeyValueView> + </FormGroup> + + <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" external>{{ $ts.tos }}</FormLink> + + <FormSuspense :p="initStats"> + <FormGroup> + <template #label>{{ $ts.statistics }}</template> + <FormKeyValueView> + <template #key>{{ $ts.users }}</template> + <template #value>{{ number(stats.originalUsersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.notes }}</template> + <template #value>{{ number(stats.originalNotesCount) }}</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> + + <FormGroup> + <template #label>Well-known resources</template> + <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink> + <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink> + <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink> + <FormLink :to="`/robots.txt`" external>robots.txt</FormLink> + <FormLink :to="`/manifest.json`" external>manifest.json</FormLink> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { version, instanceName } from '@/config'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import * as symbols from '@/symbols'; +import { host } from '@/config'; + +export default defineComponent({ + components: { + FormBase, + FormGroup, + FormLink, + FormKeyValueView, + FormTextarea, + FormSuspense, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.instanceInfo, + icon: 'fas fa-info-circle' + }, + host, + version, + instanceName, + stats: null, + initStats: () => os.api('stats', { + }).then((stats) => { + this.stats = stats; + }) + } + }, + + methods: { + number + } +}); +</script> + +<style lang="scss" scoped> +.fwhjspax { + padding: 16px; + text-align: center; + + > .icon { + display: block; + margin: auto; + height: 64px; + border-radius: 8px; + } + + > .name { + display: block; + margin-top: 12px; + } +} +</style> diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue new file mode 100644 index 0000000000..ca94737781 --- /dev/null +++ b/packages/client/src/pages/admin/abuses.vue @@ -0,0 +1,170 @@ +<template> +<div class="lcixvhis"> + <div class="_section reports"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model="state" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="unresolved">{{ $ts.unresolved }}</option> + <option value="resolved">{{ $ts.resolved }}</option> + </MkSelect> + <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.reporteeOrigin }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.reporterOrigin }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + </div> + <!-- TODO + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()"> + <span>{{ $ts.username }}</span> + </MkInput> + <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()" :disabled="pagination.params().origin === 'local'"> + <span>{{ $ts.host }}</span> + </MkInput> + </div> + --> + + <MkPagination :pagination="pagination" #default="{items}" ref="reports" style="margin-top: var(--margin);"> + <div class="bcekxzvu _card _gap" v-for="report in items" :key="report.id"> + <div class="_content target"> + <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/> + <div class="info"> + <MkUserName class="name" :user="report.targetUser"/> + <div class="acct">@{{ acct(report.targetUser) }}</div> + </div> + </div> + <div class="_content"> + <div> + <Mfm :text="report.comment"/> + </div> + <hr> + <div>Reporter: <MkAcct :user="report.reporter"/></div> + <div><MkTime :time="report.createdAt"/></div> + </div> + <div class="_footer"> + <div v-if="report.assignee">Assignee: <MkAcct :user="report.assignee"/></div> + <MkButton @click="resolve(report)" primary v-if="!report.resolved">{{ $ts.abuseMarkAsResolved }}</MkButton> + </div> + </div> + </MkPagination> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.abuseReports, + icon: 'fas fa-exclamation-circle', + bg: 'var(--bg)', + }, + searchUsername: '', + searchHost: '', + state: 'unresolved', + reporterOrigin: 'combined', + targetUserOrigin: 'combined', + pagination: { + endpoint: 'admin/abuse-user-reports', + limit: 10, + params: () => ({ + state: this.state, + reporterOrigin: this.reporterOrigin, + targetUserOrigin: this.targetUserOrigin, + }), + }, + } + }, + + watch: { + state() { + this.$refs.reports.reload(); + }, + + reporterOrigin() { + this.$refs.reports.reload(); + }, + + targetUserOrigin() { + this.$refs.reports.reload(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + acct, + + resolve(report) { + os.apiWithDialog('admin/resolve-abuse-user-report', { + reportId: report.id, + }).then(() => { + this.$refs.reports.removeItem(item => item.id === report.id); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.lcixvhis { + margin: var(--margin); +} + +.bcekxzvu { + > .target { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + > .avatar { + width: 42px; + height: 42px; + } + + > .info { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; + + > .name { + font-weight: bold; + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue new file mode 100644 index 0000000000..df6c9d5d00 --- /dev/null +++ b/packages/client/src/pages/admin/ads.vue @@ -0,0 +1,138 @@ +<template> +<div class="uqshojas"> + <section class="_card _gap ads" v-for="ad in ads"> + <div class="_content ad"> + <MkAd v-if="ad.url" :specify="ad"/> + <MkInput v-model="ad.url" type="url"> + <template #label>URL</template> + </MkInput> + <MkInput v-model="ad.imageUrl"> + <template #label>{{ $ts.imageUrl }}</template> + </MkInput> + <div style="margin: 32px 0;"> + <MkRadio v-model="ad.place" value="square">square</MkRadio> + <MkRadio v-model="ad.place" value="horizontal">horizontal</MkRadio> + <MkRadio v-model="ad.place" value="horizontal-big">horizontal-big</MkRadio> + </div> + <!-- + <div style="margin: 32px 0;"> + {{ $ts.priority }} + <MkRadio v-model="ad.priority" value="high">{{ $ts.high }}</MkRadio> + <MkRadio v-model="ad.priority" value="middle">{{ $ts.middle }}</MkRadio> + <MkRadio v-model="ad.priority" value="low">{{ $ts.low }}</MkRadio> + </div> + --> + <MkInput v-model="ad.ratio" type="number"> + <template #label>{{ $ts.ratio }}</template> + </MkInput> + <MkInput v-model="ad.expiresAt" type="date"> + <template #label>{{ $ts.expiration }}</template> + </MkInput> + <MkTextarea v-model="ad.memo"> + <template #label>{{ $ts.memo }}</template> + </MkTextarea> + <div class="buttons"> + <MkButton class="button" inline @click="save(ad)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton class="button" inline @click="remove(ad)" danger><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> + </div> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkRadio from '@/components/form/radio.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkTextarea, + MkRadio, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.ads, + icon: 'fas fa-audio-description', + bg: 'var(--bg)', + actions: [{ + asFullButton: true, + icon: 'fas fa-plus', + text: this.$ts.add, + handler: this.add, + }], + }, + ads: [], + } + }, + + created() { + os.api('admin/ad/list').then(ads => { + this.ads = ads; + }); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + add() { + this.ads.unshift({ + id: null, + memo: '', + place: 'square', + priority: 'middle', + ratio: 1, + url: '', + imageUrl: null, + expiresAt: null, + }); + }, + + remove(ad) { + os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: ad.url }), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + this.ads = this.ads.filter(x => x != ad); + os.apiWithDialog('admin/ad/delete', { + id: ad.id + }); + }); + }, + + save(ad) { + if (ad.id == null) { + os.apiWithDialog('admin/ad/create', { + ...ad, + expiresAt: new Date(ad.expiresAt).getTime() + }); + } else { + os.apiWithDialog('admin/ad/update', { + ...ad, + expiresAt: new Date(ad.expiresAt).getTime() + }); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.uqshojas { + margin: var(--margin); +} +</style> diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue new file mode 100644 index 0000000000..a64008967f --- /dev/null +++ b/packages/client/src/pages/admin/announcements.vue @@ -0,0 +1,125 @@ +<template> +<div class="ztgjmzrw"> + <section class="_card _gap announcements" v-for="announcement in announcements"> + <div class="_content announcement"> + <MkInput v-model="announcement.title"> + <template #label>{{ $ts.title }}</template> + </MkInput> + <MkTextarea v-model="announcement.text"> + <template #label>{{ $ts.text }}</template> + </MkTextarea> + <MkInput v-model="announcement.imageUrl"> + <template #label>{{ $ts.imageUrl }}</template> + </MkInput> + <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> + </div> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkTextarea, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.announcements, + icon: 'fas fa-broadcast-tower', + bg: 'var(--bg)', + actions: [{ + asFullButton: true, + icon: 'fas fa-plus', + text: this.$ts.add, + handler: this.add, + }], + }, + announcements: [], + } + }, + + created() { + os.api('admin/announcements/list').then(announcements => { + this.announcements = announcements; + }); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + add() { + this.announcements.unshift({ + id: null, + title: '', + text: '', + imageUrl: null + }); + }, + + remove(announcement) { + os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: announcement.title }), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + this.announcements = this.announcements.filter(x => x != announcement); + os.api('admin/announcements/delete', announcement); + }); + }, + + save(announcement) { + if (announcement.id == null) { + os.api('admin/announcements/create', announcement).then(() => { + os.dialog({ + type: 'success', + text: this.$ts.saved + }); + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }); + } else { + os.api('admin/announcements/update', announcement).then(() => { + os.dialog({ + type: 'success', + text: this.$ts.saved + }); + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.ztgjmzrw { + margin: var(--margin); +} +</style> diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue new file mode 100644 index 0000000000..8f7873baa3 --- /dev/null +++ b/packages/client/src/pages/admin/bot-protection.vue @@ -0,0 +1,138 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormRadios v-model="provider"> + <template #desc><i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}</template> + <option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option> + <option value="hcaptcha">hCaptcha</option> + <option value="recaptcha">reCAPTCHA</option> + </FormRadios> + + <template v-if="provider === 'hcaptcha'"> + <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">hCaptcha</div> + <div class="main"> + <FormInput v-model="hcaptchaSiteKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.hcaptchaSiteKey }}</span> + </FormInput> + <FormInput v-model="hcaptchaSecretKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.hcaptchaSecretKey }}</span> + </FormInput> + </div> + </div> + <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">{{ $ts.preview }}</div> + <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);"> + <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> + </div> + </div> + </template> + <template v-else-if="provider === 'recaptcha'"> + <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">reCAPTCHA</div> + <div class="main"> + <FormInput v-model="recaptchaSiteKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.recaptchaSiteKey }}</span> + </FormInput> + <FormInput v-model="recaptchaSecretKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.recaptchaSecretKey }}</span> + </FormInput> + </div> + </div> + <div v-if="recaptchaSiteKey" class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">{{ $ts.preview }}</div> + <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);"> + <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/> + </div> + </div> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormRadios, + FormInput, + FormBase, + FormGroup, + FormButton, + FormInfo, + FormSuspense, + MkCaptcha: defineAsyncComponent(() => import('@/components/captcha.vue')), + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.botProtection, + icon: 'fas fa-shield-alt' + }, + provider: null, + enableHcaptcha: false, + hcaptchaSiteKey: null, + hcaptchaSecretKey: null, + enableRecaptcha: false, + recaptchaSiteKey: null, + recaptchaSecretKey: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableHcaptcha = meta.enableHcaptcha; + this.hcaptchaSiteKey = meta.hcaptchaSiteKey; + this.hcaptchaSecretKey = meta.hcaptchaSecretKey; + this.enableRecaptcha = meta.enableRecaptcha; + this.recaptchaSiteKey = meta.recaptchaSiteKey; + this.recaptchaSecretKey = meta.recaptchaSecretKey; + + this.provider = this.enableHcaptcha ? 'hcaptcha' : this.enableRecaptcha ? 'recaptcha' : null; + + this.$watch(() => this.provider, () => { + this.enableHcaptcha = this.provider === 'hcaptcha'; + this.enableRecaptcha = this.provider === 'recaptcha'; + }); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + enableHcaptcha: this.enableHcaptcha, + hcaptchaSiteKey: this.hcaptchaSiteKey, + hcaptchaSecretKey: this.hcaptchaSecretKey, + enableRecaptcha: this.enableRecaptcha, + recaptchaSiteKey: this.recaptchaSiteKey, + recaptchaSecretKey: this.recaptchaSecretKey, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue new file mode 100644 index 0000000000..b550831e02 --- /dev/null +++ b/packages/client/src/pages/admin/database.vue @@ -0,0 +1,61 @@ +<template> +<FormBase> + <FormSuspense :p="databasePromiseFactory" v-slot="{ result: database }"> + <FormGroup v-for="table in database" :key="table[0]"> + <template #label>{{ table[0] }}</template> + <FormKeyValueView> + <template #key>Size</template> + <template #value>{{ bytes(table[1].size) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Records</template> + <template #value>{{ number(table[1].count) }}</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + FormSuspense, + FormKeyValueView, + FormBase, + FormGroup, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.database, + icon: 'fas fa-database', + bg: 'var(--bg)', + }, + databasePromiseFactory: () => os.api('admin/get-table-stats', {}).then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)), + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + bytes, number, + } +}); +</script> diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue new file mode 100644 index 0000000000..3733f53a23 --- /dev/null +++ b/packages/client/src/pages/admin/email-settings.vue @@ -0,0 +1,128 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableEmail">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></FormSwitch> + + <template v-if="enableEmail"> + <FormInput v-model="email" type="email"> + <span>{{ $ts.emailAddress }}</span> + </FormInput> + + <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">{{ $ts.smtpConfig }}</div> + <div class="main"> + <FormInput v-model="smtpHost"> + <span>{{ $ts.smtpHost }}</span> + </FormInput> + <FormInput v-model="smtpPort" type="number"> + <span>{{ $ts.smtpPort }}</span> + </FormInput> + <FormInput v-model="smtpUser"> + <span>{{ $ts.smtpUser }}</span> + </FormInput> + <FormInput v-model="smtpPass" type="password"> + <span>{{ $ts.smtpPass }}</span> + </FormInput> + <FormInfo>{{ $ts.emptyToDisableSmtpAuth }}</FormInfo> + <FormSwitch v-model="smtpSecure">{{ $ts.smtpSecure }}<template #desc>{{ $ts.smtpSecureInfo }}</template></FormSwitch> + </div> + </div> + + <FormButton @click="testEmail">{{ $ts.testEmail }}</FormButton> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.emailServer, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + enableEmail: false, + email: null, + smtpSecure: false, + smtpHost: '', + smtpPort: 0, + smtpUser: '', + smtpPass: '', + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableEmail = meta.enableEmail; + this.email = meta.email; + this.smtpSecure = meta.smtpSecure; + this.smtpHost = meta.smtpHost; + this.smtpPort = meta.smtpPort; + this.smtpUser = meta.smtpUser; + this.smtpPass = meta.smtpPass; + }, + + async testEmail() { + const { canceled, result: destination } = await os.dialog({ + title: this.$ts.destination, + input: { + placeholder: this.$instance.maintainerEmail + } + }); + if (canceled) return; + os.apiWithDialog('admin/send-email', { + to: destination, + subject: 'Test email', + text: 'Yo' + }); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + enableEmail: this.enableEmail, + email: this.email, + smtpSecure: this.smtpSecure, + smtpHost: this.smtpHost, + smtpPort: this.smtpPort, + smtpUser: this.smtpUser, + smtpPass: this.smtpPass, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/emoji-edit-dialog.vue b/packages/client/src/pages/admin/emoji-edit-dialog.vue new file mode 100644 index 0000000000..e612855105 --- /dev/null +++ b/packages/client/src/pages/admin/emoji-edit-dialog.vue @@ -0,0 +1,120 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + :with-ok-button="true" + @close="$refs.dialog.close()" + @closed="$emit('closed')" + @ok="ok()" +> + <template #header>:{{ emoji.name }}:</template> + + <div class="_monolithic_"> + <div class="yigymqpb _section"> + <img :src="emoji.url" class="img"/> + <MkInput class="_formBlock" v-model="name"> + <template #label>{{ $ts.name }}</template> + </MkInput> + <MkInput class="_formBlock" v-model="category" :datalist="categories"> + <template #label>{{ $ts.category }}</template> + </MkInput> + <MkInput class="_formBlock" v-model="aliases"> + <template #label>{{ $ts.tags }}</template> + <template #caption>{{ $ts.setMultipleBySeparatingWithSpace }}</template> + </MkInput> + <MkButton danger @click="del()"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; +import { unique } from '@/scripts/array'; + +export default defineComponent({ + components: { + XModalWindow, + MkButton, + MkInput, + }, + + props: { + emoji: { + required: true, + } + }, + + emits: ['done', 'closed'], + + data() { + return { + name: this.emoji.name, + category: this.emoji.category, + aliases: this.emoji.aliases?.join(' '), + categories: [], + } + }, + + created() { + os.api('meta', { detail: false }).then(({ emojis }) => { + this.categories = unique(emojis.map((x: any) => x.category || '').filter((x: string) => x !== '')); + }); + }, + + methods: { + ok() { + this.update(); + }, + + async update() { + await os.apiWithDialog('admin/emoji/update', { + id: this.emoji.id, + name: this.name, + category: this.category, + aliases: this.aliases.split(' '), + }); + + this.$emit('done', { + updated: { + name: this.name, + category: this.category, + aliases: this.aliases.split(' '), + } + }); + this.$refs.dialog.close(); + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.emoji.name }), + showCancelButton: true + }); + if (canceled) return; + + os.api('admin/emoji/remove', { + id: this.emoji.id + }).then(() => { + this.$emit('done', { + deleted: true + }); + this.$refs.dialog.close(); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.yigymqpb { + > .img { + display: block; + height: 64px; + margin: 0 auto; + } +} +</style> diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue new file mode 100644 index 0000000000..c9ba193dd1 --- /dev/null +++ b/packages/client/src/pages/admin/emojis.vue @@ -0,0 +1,263 @@ +<template> +<div class="ogwlenmc"> + <div class="local" v-if="tab === 'local'"> + <MkInput v-model="query" :debounce="true" type="search" style="margin: var(--margin);"> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.search }}</template> + </MkInput> + <MkPagination :pagination="pagination" ref="emojis"> + <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + </div> + </template> + </MkPagination> + </div> + + <div class="remote" v-else-if="tab === 'remote'"> + <MkInput v-model="queryRemote" :debounce="true" type="search" style="margin: var(--margin);"> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.search }}</template> + </MkInput> + <MkInput v-model="host" :debounce="true" style="margin: var(--margin);"> + <template #label>{{ $ts.host }}</template> + </MkInput> + <MkPagination :pagination="remotePagination" ref="remoteEmojis"> + <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.host }}</div> + </div> + </div> + </div> + </template> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, toRef } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkTab from '@/components/tab.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkTab, + MkButton, + MkInput, + MkPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.customEmojis, + icon: 'fas fa-laugh', + bg: 'var(--bg)', + actions: [{ + asFullButton: true, + icon: 'fas fa-plus', + text: this.$ts.addEmoji, + handler: this.add, + }], + tabs: [{ + active: this.tab === 'local', + title: this.$ts.local, + onClick: () => { this.tab = 'local'; }, + }, { + active: this.tab === 'remote', + title: this.$ts.remote, + onClick: () => { this.tab = 'remote'; }, + },] + })), + tab: 'local', + query: null, + queryRemote: null, + host: '', + pagination: { + endpoint: 'admin/emoji/list', + limit: 30, + params: computed(() => ({ + query: (this.query && this.query !== '') ? this.query : null + })) + }, + remotePagination: { + endpoint: 'admin/emoji/list-remote', + limit: 30, + params: computed(() => ({ + query: (this.queryRemote && this.queryRemote !== '') ? this.queryRemote : null, + host: (this.host && this.host !== '') ? this.host : null + })) + }, + } + }, + + async mounted() { + this.$emit('info', toRef(this, symbols.PAGE_INFO)); + }, + + methods: { + async add(e) { + const files = await selectFile(e.currentTarget || e.target, null, true); + + const promise = Promise.all(files.map(file => os.api('admin/emoji/add', { + fileId: file.id, + }))); + promise.then(() => { + this.$refs.emojis.reload(); + }); + os.promiseDialog(promise); + }, + + edit(emoji) { + os.popup(import('./emoji-edit-dialog.vue'), { + emoji: emoji + }, { + done: result => { + if (result.updated) { + this.$refs.emojis.replaceItem(item => item.id === emoji.id, { + ...emoji, + ...result.updated + }); + } else if (result.deleted) { + this.$refs.emojis.removeItem(item => item.id === emoji.id); + } + }, + }, 'closed'); + }, + + im(emoji) { + os.apiWithDialog('admin/emoji/copy', { + emojiId: emoji.id, + }); + }, + + remoteMenu(emoji, ev) { + os.popupMenu([{ + type: 'label', + text: ':' + emoji.name + ':', + }, { + text: this.$ts.import, + icon: 'fas fa-plus', + action: () => { this.im(emoji) } + }], ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.ogwlenmc { + > .local { + .empty { + margin: var(--margin); + } + + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin); + + > .emoji { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + + &:hover { + color: var(--accent); + } + + > .img { + width: 42px; + height: 42px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + } + } + + > .remote { + .empty { + margin: var(--margin); + } + + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin); + + > .emoji { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + + &:hover { + color: var(--accent); + } + + > .img { + width: 32px; + height: 32px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + font-size: 90%; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/file-dialog.vue b/packages/client/src/pages/admin/file-dialog.vue new file mode 100644 index 0000000000..016a012ea5 --- /dev/null +++ b/packages/client/src/pages/admin/file-dialog.vue @@ -0,0 +1,129 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header v-if="file">{{ file.name }}</template> + <div class="cxqhhsmd" v-if="file"> + <div class="_section"> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <div class="info"> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + <MkTime :time="file.createdAt" mode="detail" style="display: block;"/> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkSwitch @update:modelValue="toggleIsSensitive" v-model="isSensitive">NSFW</MkSwitch> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton> + <MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> + </div> + </div> + <div class="_section" v-if="info"> + <details class="_content rawdata"> + <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre> + </details> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import Progress from '@/scripts/loading'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + XModalWindow, + MkDriveFileThumbnail, + }, + + props: { + fileId: { + required: true, + } + }, + + emits: ['closed'], + + data() { + return { + file: null, + info: null, + isSensitive: false, + }; + }, + + created() { + this.fetch(); + }, + + methods: { + async fetch() { + Progress.start(); + this.file = await os.api('drive/files/show', { fileId: this.fileId }); + this.info = await os.api('admin/drive/show-file', { fileId: this.fileId }); + this.isSensitive = this.file.isSensitive; + Progress.done(); + }, + + showUser() { + os.pageWindow(`/user-info/${this.file.userId}`); + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.file.name }), + showCancelButton: true + }); + if (canceled) return; + + os.apiWithDialog('drive/files/delete', { + fileId: this.file.id + }); + }, + + async toggleIsSensitive(v) { + await os.api('drive/files/update', { fileId: this.fileId, isSensitive: v }); + this.isSensitive = v; + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> +.cxqhhsmd { + > ._section { + > .thumbnail { + height: 150px; + max-width: 100%; + } + + > .info { + text-align: center; + margin-top: 8px; + } + + > .rawdata { + overflow: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/admin/files-settings.vue b/packages/client/src/pages/admin/files-settings.vue new file mode 100644 index 0000000000..03d8f3de1f --- /dev/null +++ b/packages/client/src/pages/admin/files-settings.vue @@ -0,0 +1,93 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="cacheRemoteFiles"> + {{ $ts.cacheRemoteFiles }} + <template #desc>{{ $ts.cacheRemoteFilesDescription }}</template> + </FormSwitch> + + <FormSwitch v-model="proxyRemoteFiles"> + {{ $ts.proxyRemoteFiles }} + <template #desc>{{ $ts.proxyRemoteFilesDescription }}</template> + </FormSwitch> + + <FormInput v-model="localDriveCapacityMb" type="number"> + <span>{{ $ts.driveCapacityPerLocalAccount }}</span> + <template #suffix>MB</template> + <template #desc>{{ $ts.inMb }}</template> + </FormInput> + + <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles"> + <span>{{ $ts.driveCapacityPerRemoteAccount }}</span> + <template #suffix>MB</template> + <template #desc>{{ $ts.inMb }}</template> + </FormInput> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.files, + icon: 'fas fa-cloud', + bg: 'var(--bg)', + }, + cacheRemoteFiles: false, + proxyRemoteFiles: false, + localDriveCapacityMb: 0, + remoteDriveCapacityMb: 0, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.cacheRemoteFiles = meta.cacheRemoteFiles; + this.proxyRemoteFiles = meta.proxyRemoteFiles; + this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb; + this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb; + }, + save() { + os.apiWithDialog('admin/update-meta', { + cacheRemoteFiles: this.cacheRemoteFiles, + proxyRemoteFiles: this.proxyRemoteFiles, + localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10), + remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10), + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue new file mode 100644 index 0000000000..e291d97bbc --- /dev/null +++ b/packages/client/src/pages/admin/files.vue @@ -0,0 +1,209 @@ +<template> +<div class="xrmjdkdw"> + <MkContainer :foldable="true" class="lookup"> + <template #header><i class="fas fa-search"></i> {{ $ts.lookup }}</template> + <div class="xrmjdkdw-lookup"> + <MkInput class="item" v-model="q" type="text" @enter="find()"> + <template #label>{{ $ts.fileIdOrUrl }}</template> + </MkInput> + <MkButton @click="find()" primary><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton> + </div> + </MkContainer> + + <div class="_section"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model="origin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.instance }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params().origin === 'local'"> + <template #label>{{ $ts.host }}</template> + </MkInput> + </div> + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>MIME type</template> + </MkInput> + </div> + <MkPagination :pagination="pagination" #default="{items}" class="urempief" ref="files"> + <button class="file _panel _button _gap" v-for="file in items" :key="file.id" @click="show(file, $event)"> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <div class="body"> + <div> + <small style="opacity: 0.7;">{{ file.name }}</small> + </div> + <div> + <MkAcct v-if="file.user" :user="file.user"/> + <div v-else>{{ $ts.system }}</div> + </div> + <div> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + </div> + <div> + <span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> + </div> + </div> + </button> + </MkPagination> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + MkContainer, + MkDriveFileThumbnail, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.files, + icon: 'fas fa-cloud', + bg: 'var(--bg)', + actions: [{ + text: this.$ts.clearCachedFiles, + icon: 'fas fa-trash-alt', + handler: this.clear + }] + }, + q: null, + origin: 'local', + type: null, + searchHost: '', + pagination: { + endpoint: 'admin/drive/files', + limit: 10, + params: () => ({ + type: (this.type && this.type !== '') ? this.type : null, + origin: this.origin, + hostname: (this.hostname && this.hostname !== '') ? this.hostname : null, + }), + }, + } + }, + + watch: { + type() { + this.$refs.files.reload(); + }, + origin() { + this.$refs.files.reload(); + }, + searchHost() { + this.$refs.files.reload(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + clear() { + os.dialog({ + type: 'warning', + text: this.$ts.clearCachedFilesConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/drive/clean-remote-files', {}); + }); + }, + + show(file, ev) { + os.popup(import('./file-dialog.vue'), { + fileId: file.id + }, {}, 'closed'); + }, + + find() { + os.api('admin/drive/show-file', this.q.startsWith('http://') || this.q.startsWith('https://') ? { url: this.q.trim() } : { fileId: this.q.trim() }).then(file => { + this.show(file); + }).catch(e => { + if (e.code === 'NO_SUCH_FILE') { + os.dialog({ + type: 'error', + text: this.$ts.notFound + }); + } + }); + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> +.xrmjdkdw { + margin: var(--margin); + + > .lookup { + margin-bottom: 16px; + } + + .urempief { + margin-top: var(--margin); + + > .file { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + &:hover { + color: var(--accent); + } + + > .thumbnail { + width: 128px; + height: 128px; + } + + > .body { + margin-left: 0.3em; + padding: 8px; + flex: 1; + + @media (max-width: 500px) { + font-size: 14px; + } + } + } + } +} + +.xrmjdkdw-lookup { + padding: 16px; + + > .item { + margin-bottom: 16px; + } +} +</style> diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue new file mode 100644 index 0000000000..d3f9406db7 --- /dev/null +++ b/packages/client/src/pages/admin/index.vue @@ -0,0 +1,388 @@ +<template> +<div class="hiyeyicy" :class="{ wide: !narrow }" ref="el"> + <div class="nav" v-if="!narrow || page == null"> + <MkHeader :info="header"></MkHeader> + + <MkSpacer :content-max="700"> + <div class="lxpfedzu"> + <div class="banner"> + <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> + </div> + + <MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/bot-protection" class="_link">{{ $ts.configure }}</MkA></MkInfo> + + <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu> + </div> + </MkSpacer> + </div> + <div class="main"> + <MkStickyContainer> + <template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template> + <component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/> + </MkStickyContainer> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent, isRef, nextTick, onMounted, reactive, ref, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkSuperMenu from '@/components/ui/super-menu.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import MkInfo from '@/components/ui/info.vue'; +import { scroll } from '@/scripts/scroll'; +import { instance } from '@/instance'; +import * as symbols from '@/symbols'; +import * as os from '@/os'; +import { lookupUser } from '@/scripts/lookup-user'; + +export default defineComponent({ + components: { + FormBase, + MkSuperMenu, + FormGroup, + FormButton, + MkInfo, + }, + + provide: { + shouldOmitHeaderTitle: false, + }, + + props: { + initialPage: { + type: String, + required: false + } + }, + + setup(props, context) { + const indexInfo = { + title: i18n.locale.controlPanel, + icon: 'fas fa-cog', + bg: 'var(--bg)', + hideHeader: true, + }; + const INFO = ref(indexInfo); + const childInfo = ref(null); + const page = ref(props.initialPage); + const narrow = ref(false); + const view = ref(null); + const el = ref(null); + const onInfo = (viewInfo) => { + if (isRef(viewInfo)) { + watch(viewInfo, () => { + childInfo.value = viewInfo.value; + }, { immediate: true }); + } else { + childInfo.value = viewInfo; + } + }; + const pageProps = ref({}); + + const isEmpty = (x: any) => x == null || x == ''; + + const noMaintainerInformation = ref(false); + const noBotProtection = ref(false); + + os.api('meta', { detail: true }).then(meta => { + // TODO: ่จญๅฎใๅฎไบใใฆใๆฎใฃใใพใพใซใชใใฎใงใในใใชใผใใณใฐใงmetaๆดๆฐใคใใณใใๅใๅใฃใฆใใใชใซๆดๆฐใใ + noMaintainerInformation.value = isEmpty(meta.maintainerName) || isEmpty(meta.maintainerEmail); + noBotProtection.value = !meta.enableHcaptcha && !meta.enableRecaptcha; + }); + + const menuDef = computed(() => [{ + title: i18n.locale.quickAction, + items: [{ + type: 'button', + icon: 'fas fa-search', + text: i18n.locale.lookup, + action: lookup, + }, ...(instance.disableRegistration ? [{ + type: 'button', + icon: 'fas fa-user', + text: i18n.locale.invite, + action: invite, + }] : [])], + }, { + title: i18n.locale.administration, + items: [{ + icon: 'fas fa-tachometer-alt', + text: i18n.locale.dashboard, + to: '/admin/overview', + active: page.value === 'overview', + }, { + icon: 'fas fa-users', + text: i18n.locale.users, + to: '/admin/users', + active: page.value === 'users', + }, { + icon: 'fas fa-laugh', + text: i18n.locale.customEmojis, + to: '/admin/emojis', + active: page.value === 'emojis', + }, { + icon: 'fas fa-globe', + text: i18n.locale.federation, + to: '/admin/federation', + active: page.value === 'federation', + }, { + icon: 'fas fa-clipboard-list', + text: i18n.locale.jobQueue, + to: '/admin/queue', + active: page.value === 'queue', + }, { + icon: 'fas fa-cloud', + text: i18n.locale.files, + to: '/admin/files', + active: page.value === 'files', + }, { + icon: 'fas fa-broadcast-tower', + text: i18n.locale.announcements, + to: '/admin/announcements', + active: page.value === 'announcements', + }, { + icon: 'fas fa-audio-description', + text: i18n.locale.ads, + to: '/admin/ads', + active: page.value === 'ads', + }, { + icon: 'fas fa-exclamation-circle', + text: i18n.locale.abuseReports, + to: '/admin/abuses', + active: page.value === 'abuses', + }], + }, { + title: i18n.locale.settings, + items: [{ + icon: 'fas fa-cog', + text: i18n.locale.general, + to: '/admin/settings', + active: page.value === 'settings', + }, { + icon: 'fas fa-cloud', + text: i18n.locale.files, + to: '/admin/files-settings', + active: page.value === 'files-settings', + }, { + icon: 'fas fa-envelope', + text: i18n.locale.emailServer, + to: '/admin/email-settings', + active: page.value === 'email-settings', + }, { + icon: 'fas fa-cloud', + text: i18n.locale.objectStorage, + to: '/admin/object-storage', + active: page.value === 'object-storage', + }, { + icon: 'fas fa-lock', + text: i18n.locale.security, + to: '/admin/security', + active: page.value === 'security', + }, { + icon: 'fas fa-bolt', + text: 'ServiceWorker', + to: '/admin/service-worker', + active: page.value === 'service-worker', + }, { + icon: 'fas fa-globe', + text: i18n.locale.relays, + to: '/admin/relays', + active: page.value === 'relays', + }, { + icon: 'fas fa-share-alt', + text: i18n.locale.integration, + to: '/admin/integrations', + active: page.value === 'integrations', + }, { + icon: 'fas fa-ban', + text: i18n.locale.instanceBlocking, + to: '/admin/instance-block', + active: page.value === 'instance-block', + }, { + icon: 'fas fa-ghost', + text: i18n.locale.proxyAccount, + to: '/admin/proxy-account', + active: page.value === 'proxy-account', + }, { + icon: 'fas fa-cogs', + text: i18n.locale.other, + to: '/admin/other-settings', + active: page.value === 'other-settings', + }], + }, { + title: i18n.locale.info, + items: [{ + icon: 'fas fa-database', + text: i18n.locale.database, + to: '/admin/database', + active: page.value === 'database', + }], + }]); + const component = computed(() => { + if (page.value == null) return null; + switch (page.value) { + case 'overview': return defineAsyncComponent(() => import('./overview.vue')); + case 'users': return defineAsyncComponent(() => import('./users.vue')); + case 'emojis': return defineAsyncComponent(() => import('./emojis.vue')); + case 'federation': return defineAsyncComponent(() => import('../federation.vue')); + case 'queue': return defineAsyncComponent(() => import('./queue.vue')); + case 'files': return defineAsyncComponent(() => import('./files.vue')); + case 'announcements': return defineAsyncComponent(() => import('./announcements.vue')); + case 'ads': return defineAsyncComponent(() => import('./ads.vue')); + case 'database': return defineAsyncComponent(() => import('./database.vue')); + case 'abuses': return defineAsyncComponent(() => import('./abuses.vue')); + case 'settings': return defineAsyncComponent(() => import('./settings.vue')); + case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue')); + case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue')); + case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue')); + case 'security': return defineAsyncComponent(() => import('./security.vue')); + case 'bot-protection': return defineAsyncComponent(() => import('./bot-protection.vue')); + case 'service-worker': return defineAsyncComponent(() => import('./service-worker.vue')); + case 'relays': return defineAsyncComponent(() => import('./relays.vue')); + case 'integrations': return defineAsyncComponent(() => import('./integrations.vue')); + case 'integrations/twitter': return defineAsyncComponent(() => import('./integrations-twitter.vue')); + case 'integrations/github': return defineAsyncComponent(() => import('./integrations-github.vue')); + case 'integrations/discord': return defineAsyncComponent(() => import('./integrations-discord.vue')); + case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue')); + case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue')); + case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue')); + } + }); + + watch(component, () => { + pageProps.value = {}; + + nextTick(() => { + scroll(el.value, { top: 0 }); + }); + }, { immediate: true }); + + watch(() => props.initialPage, () => { + if (props.initialPage == null && !narrow.value) { + page.value = 'overview'; + } else { + page.value = props.initialPage; + if (props.initialPage == null) { + INFO.value = indexInfo; + } + } + }); + + onMounted(() => { + narrow.value = el.value.offsetWidth < 800; + if (!narrow.value) { + page.value = 'overview'; + } + }); + + const invite = () => { + os.api('admin/invite').then(x => { + os.dialog({ + type: 'info', + text: x.code + }); + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }); + }; + + const lookup = (ev) => { + os.popupMenu([{ + text: i18n.locale.user, + icon: 'fas fa-user', + action: () => { + lookupUser(); + } + }, { + text: i18n.locale.note, + icon: 'fas fa-pencil-alt', + action: () => { + alert('TODO'); + } + }, { + text: i18n.locale.file, + icon: 'fas fa-cloud', + action: () => { + alert('TODO'); + } + }, { + text: i18n.locale.instance, + icon: 'fas fa-globe', + action: () => { + alert('TODO'); + } + }], ev.currentTarget || ev.target); + }; + + return { + [symbols.PAGE_INFO]: INFO, + menuDef, + header: { + title: i18n.locale.controlPanel, + }, + noMaintainerInformation, + noBotProtection, + page, + narrow, + view, + el, + onInfo, + childInfo, + pageProps, + component, + invite, + lookup, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.hiyeyicy { + &.wide { + display: flex; + margin: 0 auto; + height: 100%; + + > .nav { + width: 32%; + max-width: 280px; + box-sizing: border-box; + border-right: solid 0.5px var(--divider); + overflow: auto; + height: 100%; + } + + > .main { + flex: 1; + min-width: 0; + } + } + + > .nav { + .lxpfedzu { + > .info { + margin: 16px 0; + } + + > .banner { + margin: 16px; + + > .icon { + display: block; + margin: auto; + height: 42px; + border-radius: 8px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue new file mode 100644 index 0000000000..f5b249698d --- /dev/null +++ b/packages/client/src/pages/admin/instance-block.vue @@ -0,0 +1,72 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormTextarea v-model="blockedHosts"> + <span>{{ $ts.blockedInstances }}</span> + <template #desc>{{ $ts.blockedInstancesDescription }}</template> + </FormTextarea> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.instanceBlocking, + icon: 'fas fa-ban', + bg: 'var(--bg)', + }, + blockedHosts: '', + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.blockedHosts = meta.blockedHosts.join('\n'); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + blockedHosts: this.blockedHosts.split('\n') || [], + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/instance.vue b/packages/client/src/pages/admin/instance.vue new file mode 100644 index 0000000000..26eefe243f --- /dev/null +++ b/packages/client/src/pages/admin/instance.vue @@ -0,0 +1,291 @@ +<template> +<XModalWindow ref="dialog" + :width="520" + :height="500" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ instance.host }}</template> + <div class="mk-instance-info"> + <div class="_table section"> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.software }}</div> + <div class="_data">{{ instance.softwareName || '?' }}</div> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.version }}</div> + <div class="_data">{{ instance.softwareVersion || '?' }}</div> + </div> + </div> + </div> + <div class="_table data section"> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.registeredAt }}</div> + <div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<MkTime :time="instance.caughtAt"/>)</div> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.following }}</div> + <button class="_data _textButton" @click="showFollowing()">{{ number(instance.followingCount) }}</button> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.followers }}</div> + <button class="_data _textButton" @click="showFollowers()">{{ number(instance.followersCount) }}</button> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.users }}</div> + <button class="_data _textButton" @click="showUsers()">{{ number(instance.usersCount) }}</button> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.notes }}</div> + <div class="_data">{{ number(instance.notesCount) }}</div> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.files }}</div> + <div class="_data">{{ number(instance.driveFiles) }}</div> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.storageUsage }}</div> + <div class="_data">{{ bytes(instance.driveUsage) }}</div> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.latestRequestSentAt }}</div> + <div class="_data"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.latestStatus }}</div> + <div class="_data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.latestRequestReceivedAt }}</div> + <div class="_data"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> + </div> + </div> + </div> + <div class="chart"> + <div class="header"> + <span class="label">{{ $ts.charts }}</span> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ $ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + </div> + <div class="chart"> + <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart> + </div> + </div> + <div class="operations section"> + <span class="label">{{ $ts.operations }}</span> + <MkSwitch v-model="isSuspended" class="switch">{{ $ts.stopActivityDelivery }}</MkSwitch> + <MkSwitch :model-value="isBlocked" class="switch" @update:modelValue="changeBlock">{{ $ts.blockThisInstance }}</MkSwitch> + <details> + <summary>{{ $ts.deleteAllFiles }}</summary> + <MkButton @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-trash-alt"></i> {{ $ts.deleteAllFiles }}</MkButton> + </details> + <details> + <summary>{{ $ts.removeAllFollowing }}</summary> + <MkButton @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-minus-circle"></i> {{ $ts.removeAllFollowing }}</MkButton> + <MkInfo warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</MkInfo> + </details> + </div> + <details class="metadata section"> + <summary class="label">{{ $ts.metadata }}</summary> + <pre><code>{{ JSON.stringify(instance, null, 2) }}</code></pre> + </details> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkInfo from '@/components/ui/info.vue'; +import MkChart from '@/components/chart.vue'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XModalWindow, + MkSelect, + MkButton, + MkSwitch, + MkInfo, + MkChart, + }, + + props: { + instance: { + type: Object, + required: true + } + }, + + emits: ['closed'], + + data() { + return { + isSuspended: this.instance.isSuspended, + chartSrc: 'requests', + chartSpan: 'hour', + }; + }, + + computed: { + meta() { + return this.$instance; + }, + + isBlocked() { + return this.meta && this.meta.blockedHosts && this.meta.blockedHosts.includes(this.instance.host); + } + }, + + watch: { + isSuspended() { + os.api('admin/federation/update-instance', { + host: this.instance.host, + isSuspended: this.isSuspended + }); + }, + }, + + methods: { + changeBlock(e) { + os.api('admin/update-meta', { + blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host) + }); + }, + + removeAllFollowing() { + os.apiWithDialog('admin/federation/remove-all-following', { + host: this.instance.host + }); + }, + + deleteAllFiles() { + os.apiWithDialog('admin/federation/delete-all-files', { + host: this.instance.host + }); + }, + + showFollowing() { + // TODO: ใใผใธ้ท็งป + }, + + showFollowers() { + // TODO: ใใผใธ้ท็งป + }, + + showUsers() { + // TODO: ใใผใธ้ท็งป + }, + + bytes, + + number + } +}); +</script> + +<style lang="scss" scoped> +.mk-instance-info { + overflow: auto; + + > .section { + padding: 16px 32px; + + @media (max-width: 500px) { + padding: 8px 16px; + } + + &:not(:first-child) { + border-top: solid 0.5px var(--divider); + } + } + + > .chart { + border-top: solid 0.5px var(--divider); + padding: 16px 0 12px 0; + + > .header { + padding: 0 32px; + + @media (max-width: 500px) { + padding: 0 16px; + } + + > .label { + font-size: 80%; + opacity: 0.7; + } + + > .selects { + display: flex; + } + } + + > .chart { + padding: 0 16px; + + @media (max-width: 500px) { + padding: 0; + } + } + } + + > .operations { + > .label { + font-size: 80%; + opacity: 0.7; + } + + > .switch { + margin: 16px 0; + } + } + + > .metadata { + > .label { + font-size: 80%; + opacity: 0.7; + } + + > pre > code { + display: block; + max-height: 200px; + overflow: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/admin/integrations-discord.vue b/packages/client/src/pages/admin/integrations-discord.vue new file mode 100644 index 0000000000..81e47499c6 --- /dev/null +++ b/packages/client/src/pages/admin/integrations-discord.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableDiscordIntegration"> + {{ $ts.enable }} + </FormSwitch> + + <template v-if="enableDiscordIntegration"> + <FormInfo>Callback URL: {{ `${url}/api/dc/cb` }}</FormInfo> + + <FormInput v-model="discordClientId"> + <template #prefix><i class="fas fa-key"></i></template> + Client ID + </FormInput> + + <FormInput v-model="discordClientSecret"> + <template #prefix><i class="fas fa-key"></i></template> + Client Secret + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormInfo, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Discord', + icon: 'fab fa-discord' + }, + enableDiscordIntegration: false, + discordClientId: null, + discordClientSecret: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableDiscordIntegration = meta.enableDiscordIntegration; + this.discordClientId = meta.discordClientId; + this.discordClientSecret = meta.discordClientSecret; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableDiscordIntegration: this.enableDiscordIntegration, + discordClientId: this.discordClientId, + discordClientSecret: this.discordClientSecret, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/integrations-github.vue b/packages/client/src/pages/admin/integrations-github.vue new file mode 100644 index 0000000000..2bbc3ae9a1 --- /dev/null +++ b/packages/client/src/pages/admin/integrations-github.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableGithubIntegration"> + {{ $ts.enable }} + </FormSwitch> + + <template v-if="enableGithubIntegration"> + <FormInfo>Callback URL: {{ `${url}/api/gh/cb` }}</FormInfo> + + <FormInput v-model="githubClientId"> + <template #prefix><i class="fas fa-key"></i></template> + Client ID + </FormInput> + + <FormInput v-model="githubClientSecret"> + <template #prefix><i class="fas fa-key"></i></template> + Client Secret + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormInfo, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'GitHub', + icon: 'fab fa-github' + }, + enableGithubIntegration: false, + githubClientId: null, + githubClientSecret: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableGithubIntegration = meta.enableGithubIntegration; + this.githubClientId = meta.githubClientId; + this.githubClientSecret = meta.githubClientSecret; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableGithubIntegration: this.enableGithubIntegration, + githubClientId: this.githubClientId, + githubClientSecret: this.githubClientSecret, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/integrations-twitter.vue b/packages/client/src/pages/admin/integrations-twitter.vue new file mode 100644 index 0000000000..19ed216ab9 --- /dev/null +++ b/packages/client/src/pages/admin/integrations-twitter.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableTwitterIntegration"> + {{ $ts.enable }} + </FormSwitch> + + <template v-if="enableTwitterIntegration"> + <FormInfo>Callback URL: {{ `${url}/api/tw/cb` }}</FormInfo> + + <FormInput v-model="twitterConsumerKey"> + <template #prefix><i class="fas fa-key"></i></template> + Consumer Key + </FormInput> + + <FormInput v-model="twitterConsumerSecret"> + <template #prefix><i class="fas fa-key"></i></template> + Consumer Secret + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormInfo, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Twitter', + icon: 'fab fa-twitter' + }, + enableTwitterIntegration: false, + twitterConsumerKey: null, + twitterConsumerSecret: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableTwitterIntegration = meta.enableTwitterIntegration; + this.twitterConsumerKey = meta.twitterConsumerKey; + this.twitterConsumerSecret = meta.twitterConsumerSecret; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableTwitterIntegration: this.enableTwitterIntegration, + twitterConsumerKey: this.twitterConsumerKey, + twitterConsumerSecret: this.twitterConsumerSecret, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue new file mode 100644 index 0000000000..c21eebc1c6 --- /dev/null +++ b/packages/client/src/pages/admin/integrations.vue @@ -0,0 +1,74 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormLink to="/admin/integrations/twitter"> + <i class="fab fa-twitter"></i> Twitter + <template #suffix>{{ enableTwitterIntegration ? $ts.enabled : $ts.disabled }}</template> + </FormLink> + <FormLink to="/admin/integrations/github"> + <i class="fab fa-github"></i> GitHub + <template #suffix>{{ enableGithubIntegration ? $ts.enabled : $ts.disabled }}</template> + </FormLink> + <FormLink to="/admin/integrations/discord"> + <i class="fab fa-discord"></i> Discord + <template #suffix>{{ enableDiscordIntegration ? $ts.enabled : $ts.disabled }}</template> + </FormLink> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormLink, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.integration, + icon: 'fas fa-share-alt', + bg: 'var(--bg)', + }, + enableTwitterIntegration: false, + enableGithubIntegration: false, + enableDiscordIntegration: false, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableTwitterIntegration = meta.enableTwitterIntegration; + this.enableGithubIntegration = meta.enableGithubIntegration; + this.enableDiscordIntegration = meta.enableDiscordIntegration; + }, + } +}); +</script> diff --git a/packages/client/src/pages/admin/metrics.vue b/packages/client/src/pages/admin/metrics.vue new file mode 100644 index 0000000000..05b64b235c --- /dev/null +++ b/packages/client/src/pages/admin/metrics.vue @@ -0,0 +1,472 @@ +<template> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="cpumem"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div> + <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </div> +</div> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="disk"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div> + <div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </div> +</div> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="net"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle +} from 'chart.js'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkwFederation from '../../widgets/federation.vue'; +import { version, url } from '@/config'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import MkInstanceInfo from './instance.vue'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle +); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSelect, + MkInput, + MkContainer, + MkFolder, + MkwFederation, + }, + + data() { + return { + version, + url, + stats: null, + serverInfo: null, + connection: null, + queueConnection: markRaw(os.stream.useChannel('queueStats')), + memUsage: 0, + chartCpuMem: null, + chartNet: null, + jobs: [], + logs: [], + logLevel: 'all', + logDomain: '', + modLogs: [], + dbInfo: null, + overviewHeight: '1fr', + queueHeight: '1fr', + paused: false, + } + }, + + computed: { + gridColor() { + // TODO: var(--panel)ใฎ่ฒใๆใใๆใใใใงๅคๅฎใใ + return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + }, + }, + + mounted() { + this.fetchJobs(); + + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + os.api('admin/server-info', {}).then(res => { + this.serverInfo = res; + + this.connection = markRaw(os.stream.useChannel('serverStats')); + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 150 + }); + + this.$nextTick(() => { + this.queueConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + this.connection.dispose(); + } + this.queueConnection.dispose(); + }, + + methods: { + cpumem(el) { + if (this.chartCpuMem != null) return; + this.chartCpuMem = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'CPU', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#86b300', + backgroundColor: alpha('#86b300', 0.1), + data: [] + }, { + label: 'MEM (active)', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#935dbf', + backgroundColor: alpha('#935dbf', 0.02), + data: [] + }, { + label: 'MEM (used)', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#935dbf', + borderDash: [5, 5], + fill: false, + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + max: 100 + } + } + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + net(el) { + if (this.chartNet != null) return; + this.chartNet = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'In', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [] + }, { + label: 'Out', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false + } + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + } + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + disk(el) { + if (this.chartDisk != null) return; + this.chartDisk = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Read', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [] + }, { + label: 'Write', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false + } + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + } + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + fetchJobs() { + os.api('admin/queue/deliver-delayed', {}).then(jobs => { + this.jobs = jobs; + }); + }, + + onStats(stats) { + if (this.paused) return; + + const cpu = (stats.cpu * 100).toFixed(0); + const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0); + const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0); + this.memUsage = stats.mem.active; + + this.chartCpuMem.data.labels.push(''); + this.chartCpuMem.data.datasets[0].data.push(cpu); + this.chartCpuMem.data.datasets[1].data.push(memActive); + this.chartCpuMem.data.datasets[2].data.push(memUsed); + this.chartNet.data.labels.push(''); + this.chartNet.data.datasets[0].data.push(stats.net.rx); + this.chartNet.data.datasets[1].data.push(stats.net.tx); + this.chartDisk.data.labels.push(''); + this.chartDisk.data.datasets[0].data.push(stats.fs.r); + this.chartDisk.data.datasets[1].data.push(stats.fs.w); + if (this.chartCpuMem.data.datasets[0].data.length > 150) { + this.chartCpuMem.data.labels.shift(); + this.chartCpuMem.data.datasets[0].data.shift(); + this.chartCpuMem.data.datasets[1].data.shift(); + this.chartCpuMem.data.datasets[2].data.shift(); + this.chartNet.data.labels.shift(); + this.chartNet.data.datasets[0].data.shift(); + this.chartNet.data.datasets[1].data.shift(); + this.chartDisk.data.labels.shift(); + this.chartDisk.data.datasets[0].data.shift(); + this.chartDisk.data.datasets[1].data.shift(); + } + this.chartCpuMem.update(); + this.chartNet.update(); + this.chartDisk.update(); + }, + + onStatsLog(statsLog) { + for (const stats of [...statsLog].reverse()) { + this.onStats(stats); + } + }, + + bytes, + + number, + + pause() { + this.paused = true; + }, + + resume() { + this.paused = false; + }, + } +}); +</script> + +<style lang="scss" scoped> +.xhexznfu { + > div:nth-child(2) { + padding: 16px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue new file mode 100644 index 0000000000..0f1431c258 --- /dev/null +++ b/packages/client/src/pages/admin/object-storage.vue @@ -0,0 +1,155 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="useObjectStorage">{{ $ts.useObjectStorage }}</FormSwitch> + + <template v-if="useObjectStorage"> + <FormInput v-model="objectStorageBaseUrl"> + <span>{{ $ts.objectStorageBaseUrl }}</span> + <template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageBucket"> + <span>{{ $ts.objectStorageBucket }}</span> + <template #desc>{{ $ts.objectStorageBucketDesc }}</template> + </FormInput> + + <FormInput v-model="objectStoragePrefix"> + <span>{{ $ts.objectStoragePrefix }}</span> + <template #desc>{{ $ts.objectStoragePrefixDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageEndpoint"> + <span>{{ $ts.objectStorageEndpoint }}</span> + <template #desc>{{ $ts.objectStorageEndpointDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageRegion"> + <span>{{ $ts.objectStorageRegion }}</span> + <template #desc>{{ $ts.objectStorageRegionDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageAccessKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>Access key</span> + </FormInput> + + <FormInput v-model="objectStorageSecretKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>Secret key</span> + </FormInput> + + <FormSwitch v-model="objectStorageUseSSL"> + {{ $ts.objectStorageUseSSL }} + <template #desc>{{ $ts.objectStorageUseSSLDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageUseProxy"> + {{ $ts.objectStorageUseProxy }} + <template #desc>{{ $ts.objectStorageUseProxyDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageSetPublicRead"> + {{ $ts.objectStorageSetPublicRead }} + </FormSwitch> + + <FormSwitch v-model="objectStorageS3ForcePathStyle"> + s3ForcePathStyle + </FormSwitch> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.objectStorage, + icon: 'fas fa-cloud', + bg: 'var(--bg)', + }, + useObjectStorage: false, + objectStorageBaseUrl: null, + objectStorageBucket: null, + objectStoragePrefix: null, + objectStorageEndpoint: null, + objectStorageRegion: null, + objectStoragePort: null, + objectStorageAccessKey: null, + objectStorageSecretKey: null, + objectStorageUseSSL: false, + objectStorageUseProxy: false, + objectStorageSetPublicRead: false, + objectStorageS3ForcePathStyle: true, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.useObjectStorage = meta.useObjectStorage; + this.objectStorageBaseUrl = meta.objectStorageBaseUrl; + this.objectStorageBucket = meta.objectStorageBucket; + this.objectStoragePrefix = meta.objectStoragePrefix; + this.objectStorageEndpoint = meta.objectStorageEndpoint; + this.objectStorageRegion = meta.objectStorageRegion; + this.objectStoragePort = meta.objectStoragePort; + this.objectStorageAccessKey = meta.objectStorageAccessKey; + this.objectStorageSecretKey = meta.objectStorageSecretKey; + this.objectStorageUseSSL = meta.objectStorageUseSSL; + this.objectStorageUseProxy = meta.objectStorageUseProxy; + this.objectStorageSetPublicRead = meta.objectStorageSetPublicRead; + this.objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle; + }, + save() { + os.apiWithDialog('admin/update-meta', { + useObjectStorage: this.useObjectStorage, + objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null, + objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null, + objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null, + objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null, + objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null, + objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null, + objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null, + objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null, + objectStorageUseSSL: this.objectStorageUseSSL, + objectStorageUseProxy: this.objectStorageUseProxy, + objectStorageSetPublicRead: this.objectStorageSetPublicRead, + objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue new file mode 100644 index 0000000000..e8f872bf0a --- /dev/null +++ b/packages/client/src/pages/admin/other-settings.vue @@ -0,0 +1,83 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormGroup> + <FormInput v-model="summalyProxy"> + <template #prefix><i class="fas fa-link"></i></template> + Summaly Proxy URL + </FormInput> + </FormGroup> + <FormGroup> + <FormInput v-model="deeplAuthKey"> + <template #prefix><i class="fas fa-key"></i></template> + DeepL Auth Key + </FormInput> + <FormSwitch v-model="deeplIsPro"> + Pro account + </FormSwitch> + </FormGroup> + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.other, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + summalyProxy: '', + deeplAuthKey: '', + deeplIsPro: false, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.summalyProxy = meta.summalyProxy; + this.deeplAuthKey = meta.deeplAuthKey; + this.deeplIsPro = meta.deeplIsPro; + }, + save() { + os.apiWithDialog('admin/update-meta', { + summalyProxy: this.summalyProxy, + deeplAuthKey: this.deeplAuthKey, + deeplIsPro: this.deeplIsPro, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue new file mode 100644 index 0000000000..e1352945a1 --- /dev/null +++ b/packages/client/src/pages/admin/overview.vue @@ -0,0 +1,236 @@ +<template> +<div class="edbbcaef" v-size="{ max: [740] }"> + <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)"> + <div class="number _panel"> + <div class="label">Users</div> + <div class="value _monospace"> + {{ number(stats.originalUsersCount) }} + <MkNumberDiff v-if="usersComparedToThePrevDay != null" class="diff" :value="usersComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + <div class="number _panel"> + <div class="label">Notes</div> + <div class="value _monospace"> + {{ number(stats.originalNotesCount) }} + <MkNumberDiff v-if="notesComparedToThePrevDay != null" class="diff" :value="notesComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + </div> + + <MkContainer :foldable="true" class="charts"> + <template #header><i class="fas fa-chart-bar"></i>{{ $ts.charts }}</template> + <div style="padding-top: 12px;"> + <MkInstanceStats :chart-limit="500" :detailed="true"/> + </div> + </MkContainer> + + <div class="queue"> + <MkContainer :foldable="true" :thin="true" class="deliver"> + <template #header>Queue: deliver</template> + <MkQueueChart :connection="queueStatsConnection" domain="deliver"/> + </MkContainer> + <MkContainer :foldable="true" :thin="true" class="inbox"> + <template #header>Queue: inbox</template> + <MkQueueChart :connection="queueStatsConnection" domain="inbox"/> + </MkContainer> + </div> + + <!--<XMetrics/>--> + + <MkFolder style="margin: var(--margin)"> + <template #header><i class="fas fa-info-circle"></i> {{ $ts.info }}</template> + <div class="cfcdecdf"> + <div class="number _panel"> + <div class="label">Misskey</div> + <div class="value _monospace">{{ version }}</div> + </div> + <div class="number _panel" v-if="serverInfo"> + <div class="label">Node.js</div> + <div class="value _monospace">{{ serverInfo.node }}</div> + </div> + <div class="number _panel" v-if="serverInfo"> + <div class="label">PostgreSQL</div> + <div class="value _monospace">{{ serverInfo.psql }}</div> + </div> + <div class="number _panel" v-if="serverInfo"> + <div class="label">Redis</div> + <div class="value _monospace">{{ serverInfo.redis }}</div> + </div> + <div class="number _panel"> + <div class="label">Vue</div> + <div class="value _monospace">{{ vueVersion }}</div> + </div> + </div> + </MkFolder> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw, version as vueVersion } from 'vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import MkInstanceStats from '@/components/instance-stats.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkNumberDiff from '@/components/number-diff.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkQueueChart from '@/components/queue-chart.vue'; +import { version, url } from '@/config'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import MkInstanceInfo from './instance.vue'; +import XMetrics from './metrics.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkNumberDiff, + FormKeyValueView, + MkInstanceStats, + MkContainer, + MkFolder, + MkQueueChart, + XMetrics, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.dashboard, + icon: 'fas fa-tachometer-alt', + bg: 'var(--bg)', + }, + version, + vueVersion, + url, + stats: null, + meta: null, + serverInfo: null, + usersComparedToThePrevDay: null, + notesComparedToThePrevDay: null, + fetchJobs: () => os.api('admin/queue/deliver-delayed', {}), + fetchModLogs: () => os.api('admin/show-moderation-logs', {}), + queueStatsConnection: markRaw(os.stream.useChannel('queueStats')), + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats', {}).then(stats => { + this.stats = stats; + + os.api('charts/users', { limit: 2, span: 'day' }).then(chart => { + this.usersComparedToThePrevDay = this.stats.originalUsersCount - chart.local.total[1]; + }); + + os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => { + this.notesComparedToThePrevDay = this.stats.originalNotesCount - chart.local.total[1]; + }); + }); + + os.api('admin/server-info', {}).then(serverInfo => { + this.serverInfo = serverInfo; + }); + + this.$nextTick(() => { + this.queueStatsConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }, + + beforeUnmount() { + this.queueStatsConnection.dispose(); + }, + + methods: { + async showInstanceInfo(q) { + let instance = q; + if (typeof q === 'string') { + instance = await os.api('federation/show-instance', { + host: q + }); + } + os.popup(MkInstanceInfo, { + instance: instance + }, {}, 'closed'); + }, + + bytes, + + number, + } +}); +</script> + +<style lang="scss" scoped> +.edbbcaef { + .cfcdecdf { + display: grid; + grid-gap: 8px; + grid-template-columns: repeat(auto-fill,minmax(150px,1fr)); + + > .number { + padding: 12px 16px; + + > .label { + opacity: 0.7; + font-size: 0.8em; + } + + > .value { + font-weight: bold; + font-size: 1.2em; + + > .diff { + font-size: 0.8em; + } + } + } + } + + > .charts { + margin: var(--margin); + } + + > .queue { + margin: var(--margin); + display: flex; + + > .deliver, + > .inbox { + flex: 1; + width: 50%; + + &:not(:first-child) { + margin-left: var(--margin); + } + } + } + + &.max-width_740px { + > .queue { + display: block; + + > .deliver, + > .inbox { + width: 100%; + + &:not(:first-child) { + margin-top: var(--margin); + margin-left: 0; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue new file mode 100644 index 0000000000..5852c6a20d --- /dev/null +++ b/packages/client/src/pages/admin/proxy-account.vue @@ -0,0 +1,87 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.proxyAccount }}</template> + <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : $ts.none }}</template> + </FormKeyValueView> + <template #caption>{{ $ts.proxyAccountDescription }}</template> + </FormGroup> + + <FormButton @click="chooseProxyAccount" primary>{{ $ts.selectAccount }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormKeyValueView, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.proxyAccount, + icon: 'fas fa-ghost', + bg: 'var(--bg)', + }, + proxyAccount: null, + proxyAccountId: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.proxyAccountId = meta.proxyAccountId; + if (this.proxyAccountId) { + this.proxyAccount = await os.api('users/show', { userId: this.proxyAccountId }); + } + }, + + chooseProxyAccount() { + os.selectUser().then(user => { + this.proxyAccount = user; + this.proxyAccountId = user.id; + this.save(); + }); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + proxyAccountId: this.proxyAccountId, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue new file mode 100644 index 0000000000..136fb63bb6 --- /dev/null +++ b/packages/client/src/pages/admin/queue.chart.vue @@ -0,0 +1,102 @@ +<template> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><slot name="title"></slot></div> + <div class="_debobigegoPanel pumxzjhg"> + <div class="_table status"> + <div class="_row"> + <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class=""> + <MkQueueChart :domain="domain" :connection="connection"/> + </div> + <div class="jobs"> + <div v-if="jobs.length > 0"> + <div v-for="job in jobs" :key="job[0]"> + <span>{{ job[0] }}</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> + </div> + </div> + <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw, onMounted, onUnmounted, ref } from 'vue'; +import number from '@/filters/number'; +import MkQueueChart from '@/components/queue-chart.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkQueueChart + }, + + props: { + domain: { + type: String, + required: true, + }, + connection: { + required: true, + }, + }, + + setup(props) { + const activeSincePrevTick = ref(0); + const active = ref(0); + const waiting = ref(0); + const delayed = ref(0); + const jobs = ref([]); + + onMounted(() => { + os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => { + jobs.value = result; + }); + + const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + waiting.value = stats[props.domain].waiting; + delayed.value = stats[props.domain].delayed; + }; + + props.connection.on('stats', onStats); + + onUnmounted(() => { + props.connection.off('stats', onStats); + }); + }); + + return { + jobs, + activeSincePrevTick, + active, + waiting, + delayed, + number, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.pumxzjhg { + > .status { + padding: 16px; + border-bottom: solid 0.5px var(--divider); + } + + > .jobs { + padding: 16px; + border-top: solid 0.5px var(--divider); + max-height: 180px; + overflow: auto; + } +} +</style> diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue new file mode 100644 index 0000000000..896298840c --- /dev/null +++ b/packages/client/src/pages/admin/queue.vue @@ -0,0 +1,73 @@ +<template> +<FormBase> + <XQueue :connection="connection" domain="inbox"> + <template #title>In</template> + </XQueue> + <XQueue :connection="connection" domain="deliver"> + <template #title>Out</template> + </XQueue> + <FormButton @click="clear()" danger><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import XQueue from './queue.chart.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + MkButton, + XQueue, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.jobQueue, + icon: 'fas fa-clipboard-list', + bg: 'var(--bg)', + }, + connection: markRaw(os.stream.useChannel('queueStats')), + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + this.$nextTick(() => { + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + clear() { + os.dialog({ + type: 'warning', + title: this.$ts.clearQueueConfirmTitle, + text: this.$ts.clearQueueConfirmText, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/queue/clear', {}); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue new file mode 100644 index 0000000000..fd0ce97d57 --- /dev/null +++ b/packages/client/src/pages/admin/relays.vue @@ -0,0 +1,99 @@ +<template> +<FormBase class="relaycxt"> + <FormButton @click="addRelay" primary><i class="fas fa-plus"></i> {{ $ts.addRelay }}</FormButton> + + <div class="_debobigegoItem" v-for="relay in relays" :key="relay.inbox"> + <div class="_debobigegoPanel" style="padding: 16px;"> + <div>{{ relay.inbox }}</div> + <div>{{ $t(`_relayStatus.${relay.status}`) }}</div> + <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> + </div> + </div> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + MkButton, + MkInput, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.relays, + icon: 'fas fa-globe', + bg: 'var(--bg)', + }, + relays: [], + inbox: '', + } + }, + + created() { + this.refresh(); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async addRelay() { + const { canceled, result: inbox } = await os.dialog({ + title: this.$ts.addRelay, + input: { + placeholder: this.$ts.inboxUrl + } + }); + if (canceled) return; + os.api('admin/relays/add', { + inbox + }).then((relay: any) => { + this.refresh(); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message || e + }); + }); + }, + + remove(inbox: string) { + os.api('admin/relays/remove', { + inbox + }).then(() => { + this.refresh(); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message || e + }); + }); + }, + + refresh() { + os.api('admin/relays/list').then((relays: any) => { + this.relays = relays; + }); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue new file mode 100644 index 0000000000..ad53ec4fcf --- /dev/null +++ b/packages/client/src/pages/admin/security.vue @@ -0,0 +1,83 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormLink to="/admin/bot-protection"> + <i class="fas fa-shield-alt"></i> {{ $ts.botProtection }} + <template #suffix v-if="enableHcaptcha">hCaptcha</template> + <template #suffix v-else-if="enableRecaptcha">reCAPTCHA</template> + <template #suffix v-else>{{ $ts.none }} ({{ $ts.notRecommended }})</template> + </FormLink> + + <FormSwitch v-model="enableRegistration">{{ $ts.enableRegistration }}</FormSwitch> + + <FormSwitch v-model="emailRequiredForSignup">{{ $ts.emailRequiredForSignup }}</FormSwitch> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormLink, + FormSwitch, + FormBase, + FormGroup, + FormButton, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.security, + icon: 'fas fa-lock', + bg: 'var(--bg)', + }, + enableHcaptcha: false, + enableRecaptcha: false, + enableRegistration: false, + emailRequiredForSignup: false, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableHcaptcha = meta.enableHcaptcha; + this.enableRecaptcha = meta.enableRecaptcha; + this.enableRegistration = !meta.disableRegistration; + this.emailRequiredForSignup = meta.emailRequiredForSignup; + }, + + save() { + os.apiWithDialog('admin/update-meta', { + disableRegistration: !this.enableRegistration, + emailRequiredForSignup: this.emailRequiredForSignup, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/service-worker.vue b/packages/client/src/pages/admin/service-worker.vue new file mode 100644 index 0000000000..9e91d6d64f --- /dev/null +++ b/packages/client/src/pages/admin/service-worker.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableServiceWorker"> + {{ $ts.enableServiceworker }} + <template #desc>{{ $ts.serviceworkerInfo }}</template> + </FormSwitch> + + <template v-if="enableServiceWorker"> + <FormInput v-model="swPublicKey"> + <template #prefix><i class="fas fa-key"></i></template> + Public key + </FormInput> + + <FormInput v-model="swPrivateKey"> + <template #prefix><i class="fas fa-key"></i></template> + Private key + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'ServiceWorker', + icon: 'fas fa-bolt', + bg: 'var(--bg)', + }, + enableServiceWorker: false, + swPublicKey: null, + swPrivateKey: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableServiceWorker = meta.enableServiceWorker; + this.swPublicKey = meta.swPublickey; + this.swPrivateKey = meta.swPrivateKey; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableServiceWorker: this.enableServiceWorker, + swPublicKey: this.swPublicKey, + swPrivateKey: this.swPrivateKey, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue new file mode 100644 index 0000000000..66aa3e21db --- /dev/null +++ b/packages/client/src/pages/admin/settings.vue @@ -0,0 +1,151 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormInput v-model="name"> + <span>{{ $ts.instanceName }}</span> + </FormInput> + + <FormTextarea v-model="description"> + <span>{{ $ts.instanceDescription }}</span> + </FormTextarea> + + <FormInput v-model="iconUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.iconUrl }}</span> + </FormInput> + + <FormInput v-model="bannerUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.bannerUrl }}</span> + </FormInput> + + <FormInput v-model="backgroundImageUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.backgroundImageUrl }}</span> + </FormInput> + + <FormInput v-model="tosUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.tosUrl }}</span> + </FormInput> + + <FormInput v-model="maintainerName"> + <span>{{ $ts.maintainerName }}</span> + </FormInput> + + <FormInput v-model="maintainerEmail" type="email"> + <template #prefix><i class="fas fa-envelope"></i></template> + <span>{{ $ts.maintainerEmail }}</span> + </FormInput> + + <FormTextarea v-model="pinnedUsers"> + <span>{{ $ts.pinnedUsers }}</span> + <template #desc>{{ $ts.pinnedUsersDescription }}</template> + </FormTextarea> + + <FormInput v-model="maxNoteTextLength" type="number"> + <template #prefix><i class="fas fa-pencil-alt"></i></template> + <span>{{ $ts.maxNoteTextLength }}</span> + </FormInput> + + <FormSwitch v-model="enableLocalTimeline">{{ $ts.enableLocalTimeline }}</FormSwitch> + <FormSwitch v-model="enableGlobalTimeline">{{ $ts.enableGlobalTimeline }}</FormSwitch> + <FormInfo>{{ $ts.disablingTimelinesInfo }}</FormInfo> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.general, + icon: 'fas fa-cog', + bg: 'var(--bg)', + }, + name: null, + description: null, + tosUrl: null as string | null, + maintainerName: null, + maintainerEmail: null, + iconUrl: null, + bannerUrl: null, + backgroundImageUrl: null, + maxNoteTextLength: 0, + enableLocalTimeline: false, + enableGlobalTimeline: false, + pinnedUsers: '', + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.name = meta.name; + this.description = meta.description; + this.tosUrl = meta.tosUrl; + this.iconUrl = meta.iconUrl; + this.bannerUrl = meta.bannerUrl; + this.backgroundImageUrl = meta.backgroundImageUrl; + this.maintainerName = meta.maintainerName; + this.maintainerEmail = meta.maintainerEmail; + this.maxNoteTextLength = meta.maxNoteTextLength; + this.enableLocalTimeline = !meta.disableLocalTimeline; + this.enableGlobalTimeline = !meta.disableGlobalTimeline; + this.pinnedUsers = meta.pinnedUsers.join('\n'); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + name: this.name, + description: this.description, + tosUrl: this.tosUrl, + iconUrl: this.iconUrl, + bannerUrl: this.bannerUrl, + backgroundImageUrl: this.backgroundImageUrl, + maintainerName: this.maintainerName, + maintainerEmail: this.maintainerEmail, + maxNoteTextLength: this.maxNoteTextLength, + disableLocalTimeline: !this.enableLocalTimeline, + disableGlobalTimeline: !this.enableGlobalTimeline, + pinnedUsers: this.pinnedUsers.split('\n'), + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue new file mode 100644 index 0000000000..f4a2ffa6d2 --- /dev/null +++ b/packages/client/src/pages/admin/users.vue @@ -0,0 +1,254 @@ +<template> +<div class="lknzcolw"> + <div class="users"> + <div class="inputs"> + <MkSelect v-model="sort" style="flex: 1;"> + <template #label>{{ $ts.sort }}</template> + <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option> + <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option> + <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option> + <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option> + </MkSelect> + <MkSelect v-model="state" style="flex: 1;"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="available">{{ $ts.normal }}</option> + <option value="admin">{{ $ts.administrator }}</option> + <option value="moderator">{{ $ts.moderator }}</option> + <option value="silenced">{{ $ts.silence }}</option> + <option value="suspended">{{ $ts.suspend }}</option> + </MkSelect> + <MkSelect v-model="origin" style="flex: 1;"> + <template #label>{{ $ts.instance }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + </div> + <div class="inputs"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ $ts.username }}</template> + </MkInput> + <MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()" :disabled="pagination.params().origin === 'local'"> + <template #prefix>@</template> + <template #label>{{ $ts.host }}</template> + </MkInput> + </div> + + <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users"> + <button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)"> + <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <header> + <MkUserName class="name" :user="user"/> + <span class="acct">@{{ acct(user) }}</span> + <span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span> + <span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span> + <span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span> + <span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span> + </header> + <div> + <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span> + </div> + <div> + <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span> + </div> + </div> + </button> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { lookupUser } from '@/scripts/lookup-user'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.users, + icon: 'fas fa-users', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-search', + text: this.$ts.search, + handler: this.searchUser + }, { + asFullButton: true, + icon: 'fas fa-plus', + text: this.$ts.addUser, + handler: this.addUser + }, { + asFullButton: true, + icon: 'fas fa-search', + text: this.$ts.lookup, + handler: this.lookupUser + }], + }, + sort: '+createdAt', + state: 'all', + origin: 'local', + searchUsername: '', + searchHost: '', + pagination: { + endpoint: 'admin/show-users', + limit: 10, + params: () => ({ + sort: this.sort, + state: this.state, + origin: this.origin, + username: this.searchUsername, + hostname: this.searchHost, + }), + offsetMode: true + }, + } + }, + + watch: { + sort() { + this.$refs.users.reload(); + }, + state() { + this.$refs.users.reload(); + }, + origin() { + this.$refs.users.reload(); + }, + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + lookupUser, + + searchUser() { + os.selectUser().then(user => { + this.show(user); + }); + }, + + async addUser() { + const { canceled: canceled1, result: username } = await os.dialog({ + title: this.$ts.username, + input: true + }); + if (canceled1) return; + + const { canceled: canceled2, result: password } = await os.dialog({ + title: this.$ts.password, + input: { type: 'password' } + }); + if (canceled2) return; + + os.apiWithDialog('admin/accounts/create', { + username: username, + password: password, + }).then(res => { + this.$refs.users.reload(); + }); + }, + + show(user) { + os.pageWindow(`/user-info/${user.id}`); + }, + + acct + } +}); +</script> + +<style lang="scss" scoped> +.lknzcolw { + > .users { + margin: var(--margin); + + > .inputs { + display: flex; + margin-bottom: 16px; + + > * { + margin-right: 16px; + + &:last-child { + margin-right: 0; + } + } + } + + > .users { + margin-top: var(--margin); + + > .user { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + padding: 16px; + + &:hover { + color: var(--accent); + } + + > .avatar { + width: 60px; + height: 60px; + } + + > .body { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; + + @media (max-width: 500px) { + font-size: 14px; + } + + > header { + > .name { + font-weight: bold; + } + + > .acct { + margin-left: 8px; + opacity: 0.7; + } + + > .staff { + margin-left: 0.5em; + color: var(--badge); + } + + > .punished { + margin-left: 0.5em; + color: #4dabf7; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/advanced-theme-editor.vue b/packages/client/src/pages/advanced-theme-editor.vue new file mode 100644 index 0000000000..eebfc21b3f --- /dev/null +++ b/packages/client/src/pages/advanced-theme-editor.vue @@ -0,0 +1,352 @@ +<template> +<div class="t9makv94"> + <section class="_section"> + <div class="_content"> + <details> + <summary>{{ $ts.import }}</summary> + <MkTextarea v-model="themeToImport"> + {{ $ts._theme.importInfo }} + </MkTextarea> + <MkButton :disabled="!themeToImport.trim()" @click="importTheme">{{ $ts.import }}</MkButton> + </details> + </div> + </section> + <section class="_section"> + <div class="_content _card _gap"> + <div class="_content"> + <MkInput v-model="name" required><span>{{ $ts.name }}</span></MkInput> + <MkInput v-model="author" required><span>{{ $ts.author }}</span></MkInput> + <MkTextarea v-model="description"><span>{{ $ts.description }}</span></MkTextarea> + <div class="_inputs"> + <div v-text="$ts._theme.base" /> + <MkRadio v-model="baseTheme" value="light">{{ $ts.light }}</MkRadio> + <MkRadio v-model="baseTheme" value="dark">{{ $ts.dark }}</MkRadio> + </div> + </div> + </div> + <div class="_content _card _gap"> + <div class="list-view _content"> + <div class="item" v-for="([ k, v ], i) in theme" :key="k"> + <div class="_inputs"> + <div> + {{ k.startsWith('$') ? `${k} (${$ts._theme.constant})` : $t('_theme.keys.' + k) }} + <button v-if="k.startsWith('$')" class="_button _link" @click="del(i)" v-text="$ts.delete" /> + </div> + <div> + <div class="type" @click="chooseType($event, i)"> + {{ getTypeOf(v) }} <i class="fas fa-chevron-down"></i> + </div> + <!-- default --> + <div v-if="v === null" v-text="baseProps[k]" class="default-value" /> + <!-- color --> + <div v-else-if="typeof v === 'string'" class="color"> + <input type="color" :value="v" @input="colorChanged($event.target.value, i)"/> + <MkInput class="select" :value="v" @update:modelValue="colorChanged($event, i)"/> + </div> + <!-- ref const --> + <MkInput v-else-if="v.type === 'refConst'" v-model="v.key"> + <template #prefix>$</template> + <span>{{ $ts.name }}</span> + </MkInput> + <!-- ref props --> + <MkSelect class="select" v-else-if="v.type === 'refProp'" v-model="v.key"> + <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option> + </MkSelect> + <!-- func --> + <template v-else-if="v.type === 'func'"> + <MkSelect class="select" v-model="v.name"> + <template #label>{{ $ts._theme.funcKind }}</template> + <option v-for="n in ['alpha', 'darken', 'lighten']" :value="n" :key="n">{{ $t('_theme.' + n) }}</option> + </MkSelect> + <MkInput type="number" v-model="v.arg"><span>{{ $ts._theme.argument }}</span></MkInput> + <MkSelect class="select" v-model="v.value"> + <template #label>{{ $ts._theme.basedProp }}</template> + <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option> + </MkSelect> + </template> + <!-- CSS --> + <MkInput v-else-if="v.type === 'css'" v-model="v.value"> + <span>CSS</span> + </MkInput> + </div> + </div> + </div> + <MkButton primary @click="addConst">{{ $ts._theme.addConstant }}</MkButton> + </div> + </div> + </section> + <section class="_section"> + <details class="_content"> + <summary>{{ $ts.sample }}</summary> + <MkSample/> + </details> + </section> + <section class="_section"> + <div class="_content"> + <MkButton inline @click="preview">{{ $ts.preview }}</MkButton> + <MkButton inline primary :disabled="!name || !author" @click="save">{{ $ts.save }}</MkButton> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import { toUnicode } from 'punycode/'; + +import MkRadio from '@/components/form/radio.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkSample from '@/components/sample.vue'; + +import { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '@/scripts/theme-editor'; +import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '@/scripts/theme'; +import { host } from '@/config'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { addTheme } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkRadio, + MkButton, + MkInput, + MkTextarea, + MkSelect, + MkSample, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.themeEditor, + icon: 'fas fa-palette', + }, + theme: [] as ThemeViewModel, + name: '', + description: '', + baseTheme: 'light' as 'dark' | 'light', + author: `@${this.$i.username}@${toUnicode(host)}`, + themeToImport: '', + changed: false, + lightTheme, darkTheme, themeProps, + } + }, + + computed: { + baseProps() { + return this.baseTheme === 'light' ? this.lightTheme.props : this.darkTheme.props; + }, + }, + + beforeUnmount() { + window.removeEventListener('beforeunload', this.beforeunload); + }, + + async beforeRouteLeave(to, from, next) { + if (this.changed && !(await this.confirm())) { + next(false); + } else { + next(); + } + }, + + mounted() { + this.init(); + window.addEventListener('beforeunload', this.beforeunload); + const changed = () => this.changed = true; + this.$watch('name', changed); + this.$watch('description', changed); + this.$watch('baseTheme', changed); + this.$watch('author', changed); + this.$watch('theme', changed); + }, + + methods: { + beforeunload(e: BeforeUnloadEvent) { + if (this.changed) { + e.preventDefault(); + e.returnValue = ''; + } + }, + + async confirm(): Promise<boolean> { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.leaveConfirm, + showCancelButton: true + }); + return !canceled; + }, + + init() { + const t: ThemeViewModel = []; + for (const key of themeProps) { + t.push([ key, null ]); + } + this.theme = t; + }, + + async del(i: number) { + const { canceled } = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$t('_theme.deleteConstantConfirm', { const: this.theme[i][0] }), + }); + if (canceled) return; + Vue.delete(this.theme, i); + }, + + async addConst() { + const { canceled, result } = await os.dialog({ + title: this.$ts._theme.inputConstantName, + input: true + }); + if (canceled) return; + this.theme.push([ '$' + result, '#000000']); + }, + + save() { + const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme); + addTheme(theme); + os.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: theme.name }) + }); + this.changed = false; + }, + + preview() { + const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme); + try { + applyTheme(theme, false); + } catch (e) { + os.dialog({ + type: 'error', + text: e.message + }); + } + }, + + async importTheme() { + if (this.changed && (!await this.confirm())) return; + + try { + const theme = JSON5.parse(this.themeToImport) as Theme; + if (!validateTheme(theme)) throw new Error(this.$ts._theme.invalid); + + this.name = theme.name; + this.description = theme.desc || ''; + this.author = theme.author; + this.baseTheme = theme.base || 'light'; + this.theme = convertToViewModel(theme); + this.themeToImport = ''; + } catch (e) { + os.dialog({ + type: 'error', + text: e.message + }); + } + }, + + colorChanged(color: string, i: number) { + this.theme[i] = [this.theme[i][0], color]; + }, + + getTypeOf(v: ThemeValue) { + return v === null + ? this.$ts._theme.defaultValue + : typeof v === 'string' + ? this.$ts._theme.color + : this.$t('_theme.' + v.type); + }, + + async chooseType(e: MouseEvent, i: number) { + const newValue = await this.showTypeMenu(e); + this.theme[i] = [ this.theme[i][0], newValue ]; + }, + + showTypeMenu(e: MouseEvent) { + return new Promise<ThemeValue>((resolve) => { + os.popupMenu([{ + text: this.$ts._theme.defaultValue, + action: () => resolve(null), + }, { + text: this.$ts._theme.color, + action: () => resolve('#000000'), + }, { + text: this.$ts._theme.func, + action: () => resolve({ + type: 'func', name: 'alpha', arg: 1, value: 'accent' + }), + }, { + text: this.$ts._theme.refProp, + action: () => resolve({ + type: 'refProp', key: 'accent', + }), + }, { + text: this.$ts._theme.refConst, + action: () => resolve({ + type: 'refConst', key: '', + }), + }, { + text: 'CSS', + action: () => resolve({ + type: 'css', value: '', + }), + }], e.currentTarget || e.target); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.t9makv94 { + > ._section { + > ._content { + > .list-view { + > .item { + min-height: 48px; + word-break: break-all; + + &:not(:last-child) { + margin-bottom: 8px; + } + + .select { + margin: 24px 0; + } + + .type { + cursor: pointer; + } + + .default-value { + opacity: 0.6; + pointer-events: none; + user-select: none; + } + + .color { + > input { + display: inline-block; + width: 1.5em; + height: 1.5em; + } + + > div { + margin-left: 8px; + display: inline-block; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue new file mode 100644 index 0000000000..946b368733 --- /dev/null +++ b/packages/client/src/pages/announcements.vue @@ -0,0 +1,74 @@ +<template> +<MkSpacer :content-max="800"> + <MkPagination :pagination="pagination" #default="{items}" class="ruryvtyk _content"> + <section class="_card announcement" v-for="(announcement, i) in items" :key="announcement.id"> + <div class="_title"><span v-if="$i && !announcement.isRead">๐ </span>{{ announcement.title }}</div> + <div class="_content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + </div> + <div class="_footer" v-if="$i && !announcement.isRead"> + <MkButton @click="read(items, announcement, i)" primary><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton> + </div> + </section> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.announcements, + icon: 'fas fa-broadcast-tower', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'announcements', + limit: 10, + }, + }; + }, + + methods: { + // TODO: ใใใฏๅฎ่ณช็ใซ่ฆชใณใณใใผใใณใใใๅญใณใณใใผใใณใใฎใใญใใใฃใๅคๆดใใฆใใฎใงใชใใจใใใใ + read(items, announcement, i) { + items[i] = { + ...announcement, + isRead: true, + }; + os.api('i/read-announcement', { announcementId: announcement.id }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.ruryvtyk { + > .announcement { + &:not(:last-child) { + margin-bottom: var(--margin); + } + + > ._content { + > img { + display: block; + max-height: 300px; + max-width: 100%; + } + } + } +} +</style> diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue new file mode 100644 index 0000000000..f7f6990fa8 --- /dev/null +++ b/packages/client/src/pages/antenna-timeline.vue @@ -0,0 +1,147 @@ +<template> +<div class="tqmomfks" v-hotkey.global="keymap" v-size="{ min: [800] }"> + <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline ref="tl" class="tl" + :key="antennaId" + src="antenna" + :antenna="antennaId" + :sound="true" + @before="before()" + @after="after()" + @queue="queueUpdated" + /> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import Progress from '@/scripts/loading'; +import XTimeline from '@/components/timeline.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XTimeline, + }, + + props: { + antennaId: { + type: String, + required: true + } + }, + + data() { + return { + antenna: null, + queue: 0, + [symbols.PAGE_INFO]: computed(() => this.antenna ? { + title: this.antenna.name, + icon: 'fas fa-satellite', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }, { + icon: 'fas fa-cog', + text: this.$ts.settings, + handler: this.settings + }], + } : null), + }; + }, + + computed: { + keymap(): any { + return { + 't': this.focus + }; + }, + }, + + watch: { + antennaId: { + async handler() { + this.antenna = await os.api('antennas/show', { + antennaId: this.antennaId + }); + }, + immediate: true + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + }, + + queueUpdated(q) { + this.queue = q; + }, + + top() { + scroll(this.$el, { top: 0 }); + }, + + async timetravel() { + const { canceled, result: date } = await os.dialog({ + title: this.$ts.date, + input: { + type: 'date' + } + }); + if (canceled) return; + + this.$refs.tl.timetravel(new Date(date)); + }, + + settings() { + this.$router.push(`/my/antennas/${this.antennaId}`); + }, + + focus() { + (this.$refs.tl as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.tqmomfks { + padding: var(--margin); + + > .new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + + > button { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; + } + } + + > .tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; + } + + &.min-width_800px { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue new file mode 100644 index 0000000000..48495df3c2 --- /dev/null +++ b/packages/client/src/pages/api-console.vue @@ -0,0 +1,93 @@ +<template> +<div class="_root"> + <div class="_block" style="padding: 24px;"> + <MkInput v-model="endpoint" :datalist="endpoints" @update:modelValue="onEndpointChange()" class=""> + <template #label>Endpoint</template> + </MkInput> + <MkTextarea v-model="body" code> + <template #label>Params (JSON or JSON5)</template> + </MkTextarea> + <MkSwitch v-model="withCredential"> + With credential + </MkSwitch> + <MkButton primary full @click="send" :disabled="sending"> + <template v-if="sending"><MkEllipsis/></template> + <template v-else><i class="fas fa-paper-plane"></i> Send</template> + </MkButton> + </div> + <div v-if="res" class="_block" style="padding: 24px;"> + <MkTextarea v-model="res" code readonly tall> + <template #label>Response</template> + </MkTextarea> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, MkInput, MkTextarea, MkSwitch, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'API console', + icon: 'fas fa-terminal' + }, + + endpoint: '', + body: '{}', + res: null, + sending: false, + endpoints: [], + withCredential: true, + + }; + }, + + created() { + os.api('endpoints').then(endpoints => { + this.endpoints = endpoints; + }); + }, + + methods: { + send() { + this.sending = true; + os.api(this.endpoint, JSON5.parse(this.body)).then(res => { + this.sending = false; + this.res = JSON5.stringify(res, null, 2); + }, err => { + this.sending = false; + this.res = JSON5.stringify(err, null, 2); + }); + }, + + onEndpointChange() { + os.api('endpoint', { endpoint: this.endpoint }, this.withCredential ? undefined : null).then(endpoint => { + const body = {}; + for (const p of endpoint.params) { + body[p.name] = + p.type === 'String' ? '' : + p.type === 'Number' ? 0 : + p.type === 'Boolean' ? false : + p.type === 'Array' ? [] : + p.type === 'Object' ? {} : + null; + } + this.body = JSON5.stringify(body, null, 2); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/auth.form.vue b/packages/client/src/pages/auth.form.vue new file mode 100644 index 0000000000..8b2adc3e07 --- /dev/null +++ b/packages/client/src/pages/auth.form.vue @@ -0,0 +1,60 @@ +<template> +<section class="_section"> + <div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div> + <div class="_content"> + <h2>{{ app.name }}</h2> + <p class="id">{{ app.id }}</p> + <p class="description">{{ app.description }}</p> + </div> + <div class="_content"> + <h2>{{ $ts._auth.permissionAsk }}</h2> + <ul> + <li v-for="p in app.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </div> + <div class="_footer"> + <MkButton @click="cancel" inline>{{ $ts.cancel }}</MkButton> + <MkButton @click="accept" inline primary>{{ $ts.accept }}</MkButton> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton + }, + props: ['session'], + computed: { + name(): string { + const el = document.createElement('div'); + el.textContent = this.app.name + return el.innerHTML; + }, + app(): any { + return this.session.app; + } + }, + methods: { + cancel() { + os.api('auth/deny', { + token: this.session.token + }).then(() => { + this.$emit('denied'); + }); + }, + + accept() { + os.api('auth/accept', { + token: this.session.token + }).then(() => { + this.$emit('accepted'); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue new file mode 100644 index 0000000000..522bd4cdf8 --- /dev/null +++ b/packages/client/src/pages/auth.vue @@ -0,0 +1,95 @@ +<template> +<div class="" v-if="$i && fetching"> + <MkLoading/> +</div> +<div v-else-if="$i"> + <XForm + class="form" + ref="form" + v-if="state == 'waiting'" + :session="session" + @denied="state = 'denied'" + @accepted="accepted" + /> + <div class="denied" v-if="state == 'denied'"> + <h1>{{ $ts._auth.denied }}</h1> + </div> + <div class="accepted" v-if="state == 'accepted'"> + <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$ts.allowed }}</h1> + <p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p> + <p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p> + </div> + <div class="error" v-if="state == 'fetch-session-error'"> + <p>{{ $ts.somethingHappened }}</p> + </div> +</div> +<div class="signin" v-else> + <MkSignin @login="onLogin"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XForm from './auth.form.vue'; +import MkSignin from '@/components/signin.vue'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + XForm, + MkSignin, + }, + data() { + return { + state: null, + session: null, + fetching: true + }; + }, + computed: { + token(): string { + return this.$route.params.token; + } + }, + mounted() { + if (!this.$i) return; + + // Fetch session + os.api('auth/session/show', { + token: this.token + }).then(session => { + this.session = session; + this.fetching = false; + + // ๆขใซ้ฃๆบใใฆใใๅ ดๅ + if (this.session.app.isAuthorized) { + os.api('auth/accept', { + token: this.session.token + }).then(() => { + this.accepted(); + }); + } else { + this.state = 'waiting'; + } + }).catch(error => { + this.state = 'fetch-session-error'; + this.fetching = false; + }); + }, + methods: { + accepted() { + this.state = 'accepted'; + if (this.session.app.callbackUrl) { + location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`; + } + }, onLogin(res) { + login(res.i); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue new file mode 100644 index 0000000000..e2cf8b9f00 --- /dev/null +++ b/packages/client/src/pages/channel-editor.vue @@ -0,0 +1,129 @@ +<template> +<div> + <div class="_section"> + <div class="_content"> + <MkInput v-model="name"> + <template #label>{{ $ts.name }}</template> + </MkInput> + + <MkTextarea v-model="description"> + <template #label>{{ $ts.description }}</template> + </MkTextarea> + + <div class="banner"> + <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton> + <div v-else-if="bannerUrl"> + <img :src="bannerUrl" style="width: 100%;"/> + <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton> + </div> + </div> + </div> + <div class="_footer"> + <MkButton @click="save()" primary><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkTextarea, MkButton, MkInput, + }, + + props: { + channelId: { + type: String, + required: false + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.channelId ? { + title: this.$ts._channel.edit, + icon: 'fas fa-satellite-dish', + } : { + title: this.$ts._channel.create, + icon: 'fas fa-satellite-dish', + }), + channel: null, + name: null, + description: null, + bannerUrl: null, + bannerId: null, + }; + }, + + watch: { + async bannerId() { + if (this.bannerId == null) { + this.bannerUrl = null; + } else { + this.bannerUrl = (await os.api('drive/files/show', { + fileId: this.bannerId, + })).url; + } + }, + }, + + async created() { + if (this.channelId) { + this.channel = await os.api('channels/show', { + channelId: this.channelId, + }); + + this.name = this.channel.name; + this.description = this.channel.description; + this.bannerId = this.channel.bannerId; + this.bannerUrl = this.channel.bannerUrl; + } + }, + + methods: { + save() { + const params = { + name: this.name, + description: this.description, + bannerId: this.bannerId, + }; + + if (this.channelId) { + params.channelId = this.channelId; + os.api('channels/update', params) + .then(channel => { + os.success(); + }); + } else { + os.api('channels/create', params) + .then(channel => { + os.success(); + this.$router.push(`/channels/${channel.id}`); + }); + } + }, + + setBannerImage(e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + this.bannerId = file.id; + }); + }, + + removeBannerImage() { + this.bannerId = null; + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue new file mode 100644 index 0000000000..f9a9ca29e9 --- /dev/null +++ b/packages/client/src/pages/channel.vue @@ -0,0 +1,186 @@ +<template> +<div v-if="channel" class="_section"> + <div class="wpgynlbz _content _panel _gap" :class="{ hide: !showBanner }"> + <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/> + <button class="_button toggle" @click="() => showBanner = !showBanner"> + <template v-if="showBanner"><i class="fas fa-angle-up"></i></template> + <template v-else><i class="fas fa-angle-down"></i></template> + </button> + <div class="hideOverlay" v-if="!showBanner"> + </div> + <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner"> + <div class="status"> + <div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> + <div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> + </div> + <div class="fade"></div> + </div> + <div class="description" v-if="channel.description"> + <Mfm :text="channel.description" :is-note="false" :i="$i"/> + </div> + </div> + + <XPostForm :channel="channel" class="post-form _content _panel _gap" fixed v-if="$i"/> + + <XTimeline class="_content _gap" src="channel" :key="channelId" :channel="channelId" @before="before" @after="after"/> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import XPostForm from '@/components/post-form.vue'; +import XTimeline from '@/components/timeline.vue'; +import XChannelFollowButton from '@/components/channel-follow-button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkContainer, + XPostForm, + XTimeline, + XChannelFollowButton + }, + + props: { + channelId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.channel ? { + title: this.channel.name, + icon: 'fas fa-satellite-dish', + } : null), + channel: null, + showBanner: true, + pagination: { + endpoint: 'channels/timeline', + limit: 10, + params: () => ({ + channelId: this.channelId, + }) + }, + }; + }, + + watch: { + channelId: { + async handler() { + this.channel = await os.api('channels/show', { + channelId: this.channelId, + }); + }, + immediate: true + } + }, + + created() { + + }, +}); +</script> + +<style lang="scss" scoped> +.wpgynlbz { + position: relative; + + > .subscribe { + position: absolute; + z-index: 1; + top: 16px; + left: 16px; + } + + > .toggle { + position: absolute; + z-index: 2; + top: 8px; + right: 8px; + font-size: 1.2em; + width: 48px; + height: 48px; + color: #fff; + background: rgba(0, 0, 0, 0.5); + border-radius: 100%; + + > i { + vertical-align: middle; + } + } + + > .banner { + position: relative; + height: 200px; + background-position: center; + background-size: cover; + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } + + > .status { + position: absolute; + z-index: 1; + bottom: 16px; + right: 16px; + padding: 8px 12px; + font-size: 80%; + background: rgba(0, 0, 0, 0.7); + border-radius: 6px; + color: #fff; + } + } + + > .description { + padding: 16px; + } + + > .hideOverlay { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + -webkit-backdrop-filter: var(--blur, blur(16px)); + backdrop-filter: var(--blur, blur(16px)); + background: rgba(0, 0, 0, 0.3); + } + + &.hide { + > .subscribe { + display: none; + } + + > .toggle { + top: 0; + right: 0; + height: 100%; + background: transparent; + } + + > .banner { + height: 42px; + filter: blur(8px); + + > * { + display: none; + } + } + + > .description { + display: none; + } + } +} +</style> diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue new file mode 100644 index 0000000000..09e136ac00 --- /dev/null +++ b/packages/client/src/pages/channels.vue @@ -0,0 +1,77 @@ +<template> +<div> + <div class="_section" style="padding: 0;" v-if="$i"> + <MkTab class="_content" v-model="tab"> + <option value="featured"><i class="fas fa-fire-alt"></i> {{ $ts._channel.featured }}</option> + <option value="following"><i class="fas fa-heart"></i> {{ $ts._channel.following }}</option> + <option value="owned"><i class="fas fa-edit"></i> {{ $ts._channel.owned }}</option> + </MkTab> + </div> + + <div class="_section"> + <div class="_content grwlizim featured" v-if="tab === 'featured'"> + <MkPagination :pagination="featuredPagination" #default="{items}"> + <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/> + </MkPagination> + </div> + + <div class="_content grwlizim following" v-if="tab === 'following'"> + <MkPagination :pagination="followingPagination" #default="{items}"> + <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/> + </MkPagination> + </div> + + <div class="_content grwlizim owned" v-if="tab === 'owned'"> + <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton> + <MkPagination :pagination="ownedPagination" #default="{items}"> + <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/> + </MkPagination> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkChannelPreview from '@/components/channel-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTab from '@/components/tab.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkChannelPreview, MkPagination, MkButton, MkTab + }, + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.channel, + icon: 'fas fa-satellite-dish', + action: { + icon: 'fas fa-plus', + handler: this.create + } + }, + tab: 'featured', + featuredPagination: { + endpoint: 'channels/featured', + noPaging: true, + }, + followingPagination: { + endpoint: 'channels/followed', + limit: 5, + }, + ownedPagination: { + endpoint: 'channels/owned', + limit: 5, + }, + }; + }, + methods: { + create() { + this.$router.push(`/channels/new`); + } + } +}); +</script> diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue new file mode 100644 index 0000000000..510a73ce68 --- /dev/null +++ b/packages/client/src/pages/clip.vue @@ -0,0 +1,154 @@ +<template> +<div v-if="clip" class="_section"> + <div class="okzinsic _content _panel _gap"> + <div class="description" v-if="clip.description"> + <Mfm :text="clip.description" :is-note="false" :i="$i"/> + </div> + <div class="user"> + <MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/> + </div> + </div> + + <XNotes class="_content _gap" :pagination="pagination" :detail="true"/> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import XPostForm from '@/components/post-form.vue'; +import XNotes from '@/components/notes.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkContainer, + XPostForm, + XNotes, + }, + + props: { + clipId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.clip ? { + title: this.clip.name, + icon: 'fas fa-paperclip', + action: { + icon: 'fas fa-ellipsis-h', + handler: this.menu + } + } : null), + clip: null, + pagination: { + endpoint: 'clips/notes', + limit: 10, + params: () => ({ + clipId: this.clipId, + }) + }, + }; + }, + + computed: { + isOwned(): boolean { + return this.$i && this.clip && (this.$i.id === this.clip.userId); + } + }, + + watch: { + clipId: { + async handler() { + this.clip = await os.api('clips/show', { + clipId: this.clipId, + }); + }, + immediate: true + } + }, + + created() { + + }, + + methods: { + menu(ev) { + os.popupMenu([this.isOwned ? { + icon: 'fas fa-pencil-alt', + text: this.$ts.edit, + action: async () => { + const { canceled, result } = await os.form(this.clip.name, { + name: { + type: 'string', + label: this.$ts.name, + default: this.clip.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description, + default: this.clip.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: this.clip.isPublic + } + }); + if (canceled) return; + + os.apiWithDialog('clips/update', { + clipId: this.clip.id, + ...result + }); + } + } : undefined, this.isOwned ? { + icon: 'fas fa-trash-alt', + text: this.$ts.delete, + danger: true, + action: async () => { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('deleteAreYouSure', { x: this.clip.name }), + showCancelButton: true + }); + if (canceled) return; + + await os.apiWithDialog('clips/delete', { + clipId: this.clip.id, + }); + } + } : undefined], ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.okzinsic { + position: relative; + + > .description { + padding: 16px; + } + + > .user { + $height: 32px; + padding: 16px; + border-top: solid 0.5px var(--divider); + line-height: $height; + + > .avatar { + width: $height; + height: $height; + } + } +} +</style> diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue new file mode 100644 index 0000000000..5d7d3b2f5a --- /dev/null +++ b/packages/client/src/pages/drive.vue @@ -0,0 +1,28 @@ +<template> +<div> + <XDrive ref="drive" @cd="x => folder = x"/> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XDrive from '@/components/drive.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XDrive + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: computed(() => this.folder ? this.folder.name : this.$ts.drive), + icon: 'fas fa-cloud', + }, + folder: null, + }; + }, +}); +</script> diff --git a/packages/client/src/pages/emojis.category.vue b/packages/client/src/pages/emojis.category.vue new file mode 100644 index 0000000000..327cbce7e8 --- /dev/null +++ b/packages/client/src/pages/emojis.category.vue @@ -0,0 +1,135 @@ +<template> +<div class="driuhtrh"> + <div class="query"> + <MkInput v-model="q" class="" :placeholder="$ts.search"> + <template #prefix><i class="fas fa-search"></i></template> + </MkInput> + + <!-- ใใใใใใใจ้ช้ญ + <div class="tags"> + <span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> + </div> + --> + </div> + + <MkFolder class="emojis" v-if="searchEmojis"> + <template #header>{{ $ts.searchResult }}</template> + <div class="zuvgdzyt"> + <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/> + </div> + </MkFolder> + + <MkFolder class="emojis" v-for="category in customEmojiCategories" :key="category"> + <template #header>{{ category || $ts.other }}</template> + <div class="zuvgdzyt"> + <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/> + </div> + </MkFolder> +</div> +</template> + +<script lang="ts"> +import { defineComponent, computed } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { emojiCategories, emojiTags } from '@/instance'; +import XEmoji from './emojis.emoji.vue'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkFolder, + MkTab, + XEmoji, + }, + + data() { + return { + q: '', + customEmojiCategories: emojiCategories, + customEmojis: this.$instance.emojis, + tags: emojiTags, + selectedTags: new Set(), + searchEmojis: null, + } + }, + + watch: { + q() { this.search(); }, + selectedTags: { + handler() { + this.search(); + }, + deep: true + }, + }, + + methods: { + search() { + if ((this.q === '' || this.q == null) && this.selectedTags.size === 0) { + this.searchEmojis = null; + return; + } + + if (this.selectedTags.size === 0) { + this.searchEmojis = this.customEmojis.filter(e => e.name.includes(this.q) || e.aliases.includes(this.q)); + } else { + this.searchEmojis = this.customEmojis.filter(e => (e.name.includes(this.q) || e.aliases.includes(this.q)) && [...this.selectedTags].every(t => e.aliases.includes(t))); + } + }, + + toggleTag(tag) { + if (this.selectedTags.has(tag)) { + this.selectedTags.delete(tag); + } else { + this.selectedTags.add(tag); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.driuhtrh { + background: var(--bg); + + > .query { + background: var(--bg); + padding: 16px; + + > .tags { + > .tag { + display: inline-block; + margin: 8px 8px 0 0; + padding: 4px 8px; + font-size: 0.9em; + background: var(--accentedBg); + border-radius: 5px; + + &.active { + background: var(--accent); + color: var(--fgOnAccent); + } + } + } + } + + > .emojis { + --x-padding: 0 16px; + + .zuvgdzyt { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: 0 var(--margin) var(--margin) var(--margin); + } + } +} +</style> diff --git a/packages/client/src/pages/emojis.emoji.vue b/packages/client/src/pages/emojis.emoji.vue new file mode 100644 index 0000000000..4ca7c15742 --- /dev/null +++ b/packages/client/src/pages/emojis.emoji.vue @@ -0,0 +1,94 @@ +<template> +<button class="zuvgdzyu _button" @click="menu"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.aliases.join(' ') }}</div> + </div> +</button> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import VanillaTilt from 'vanilla-tilt'; + +export default defineComponent({ + props: { + emoji: { + type: Object, + required: true, + } + }, + + mounted() { + if (this.$store.animation) { + VanillaTilt.init(this.$el, { + reverse: true, + gyroscope: false, + scale: 1.1, + speed: 500, + }); + } + }, + + methods: { + menu(ev) { + os.popupMenu([{ + type: 'label', + text: ':' + this.emoji.name + ':', + }, { + text: this.$ts.copy, + icon: 'fas fa-copy', + action: () => { + copyToClipboard(`:${this.emoji.name}:`); + os.success(); + } + }], ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.zuvgdzyu { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + background: var(--panel); + border-radius: 8px; + transform-style: preserve-3d; + transform: perspective(1000px); + + &:hover { + border-color: var(--accent); + } + + > .img { + width: 42px; + height: 42px; + transform: translateZ(20px); + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + transform: translateZ(10px); + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + font-size: 0.9em; + text-overflow: ellipsis; + overflow: hidden; + } + } +} +</style> diff --git a/packages/client/src/pages/emojis.vue b/packages/client/src/pages/emojis.vue new file mode 100644 index 0000000000..ae06fa7938 --- /dev/null +++ b/packages/client/src/pages/emojis.vue @@ -0,0 +1,36 @@ +<template> +<div :class="$style.root"> + <XCategory v-if="tab === 'category'"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, computed } from 'vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import XCategory from './emojis.category.vue'; + +export default defineComponent({ + components: { + XCategory, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.customEmojis, + icon: 'fas fa-laugh', + bg: 'var(--bg)', + })), + tab: 'category', + } + }, +}); +</script> + +<style lang="scss" module> +.root { + max-width: 1000px; + margin: 0 auto; +} +</style> diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue new file mode 100644 index 0000000000..7b1fcd0910 --- /dev/null +++ b/packages/client/src/pages/explore.vue @@ -0,0 +1,261 @@ +<template> +<div> + <MkSpacer :content-max="1200"> + <div class="lznhrdub"> + <div v-if="tab === 'local'"> + <div class="localfedi7 _block _isolated" v-if="meta && stats && tag == null" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }"> + <header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header> + <div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div> + </div> + + <template v-if="tag == null"> + <MkFolder class="_gap" persist-key="explore-pinned-users"> + <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.pinnedUsers }}</template> + <XUserList :pagination="pinnedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-popular-users"> + <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> + <XUserList :pagination="popularUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-updated-users"> + <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-registered-users"> + <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyRegisteredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsers"/> + </MkFolder> + </template> + </div> + <div v-else-if="tab === 'remote'"> + <div class="localfedi7 _block _isolated" v-if="tag == null" :style="{ backgroundImage: `url(/client-assets/fedi.jpg)` }"> + <header><span>{{ $ts.exploreFediverse }}</span></header> + </div> + + <MkFolder :foldable="true" :expanded="false" ref="tags" class="_gap"> + <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template> + + <div class="vxjfqztj"> + <MkA v-for="tag in tagsLocal" :to="`/explore/tags/${tag.tag}`" :key="'local:' + tag.tag" class="local">{{ tag.tag }}</MkA> + <MkA v-for="tag in tagsRemote" :to="`/explore/tags/${tag.tag}`" :key="'remote:' + tag.tag">{{ tag.tag }}</MkA> + </div> + </MkFolder> + + <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap"> + <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> + <XUserList :pagination="tagUsers"/> + </MkFolder> + + <template v-if="tag == null"> + <MkFolder class="_gap"> + <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> + <XUserList :pagination="popularUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyDiscoveredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsersF"/> + </MkFolder> + </template> + </div> + <div v-else-if="tab === 'search'"> + <div class="_isolated"> + <MkInput v-model="searchQuery" :debounce="true" type="search"> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.searchUser }}</template> + </MkInput> + <MkRadios v-model="searchOrigin"> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + <option value="both">{{ $ts.all }}</option> + </MkRadios> + </div> + + <XUserList v-if="searchQuery" class="_gap" :pagination="searchPagination" ref="search"/> + </div> + </div> + </MkSpacer> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XUserList from '@/components/user-list.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkInput from '@/components/form/input.vue'; +import MkRadios from '@/components/form/radios.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XUserList, + MkFolder, + MkInput, + MkRadios, + }, + + props: { + tag: { + type: String, + required: false + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.explore, + icon: 'fas fa-hashtag', + bg: 'var(--bg)', + tabs: [{ + active: this.tab === 'local', + title: this.$ts.local, + onClick: () => { this.tab = 'local'; }, + }, { + active: this.tab === 'remote', + title: this.$ts.remote, + onClick: () => { this.tab = 'remote'; }, + }, { + active: this.tab === 'search', + title: this.$ts.search, + onClick: () => { this.tab = 'search'; }, + },] + })), + tab: 'local', + pinnedUsers: { endpoint: 'pinned-users' }, + popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'local', + sort: '+follower', + } }, + recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + sort: '+updatedAt', + } }, + recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + state: 'alive', + sort: '+createdAt', + } }, + popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'remote', + sort: '+follower', + } }, + recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+updatedAt', + } }, + recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+createdAt', + } }, + searchPagination: { + endpoint: 'users/search', + limit: 10, + params: computed(() => (this.searchQuery && this.searchQuery !== '') ? { + query: this.searchQuery, + origin: this.searchOrigin, + } : null) + }, + tagsLocal: [], + tagsRemote: [], + stats: null, + searchQuery: null, + searchOrigin: 'combined', + num: number, + }; + }, + + computed: { + meta() { + return this.$instance; + }, + tagUsers(): any { + return { + endpoint: 'hashtags/users', + limit: 30, + params: { + tag: this.tag, + origin: 'combined', + sort: '+follower', + } + }; + }, + }, + + watch: { + tag() { + if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); + }, + }, + + created() { + os.api('hashtags/list', { + sort: '+attachedLocalUsers', + attachedToLocalUserOnly: true, + limit: 30 + }).then(tags => { + this.tagsLocal = tags; + }); + os.api('hashtags/list', { + sort: '+attachedRemoteUsers', + attachedToRemoteUserOnly: true, + limit: 30 + }).then(tags => { + this.tagsRemote = tags; + }); + os.api('stats').then(stats => { + this.stats = stats; + }); + }, +}); +</script> + +<style lang="scss" scoped> +.localfedi7 { + color: #fff; + padding: 16px; + height: 80px; + background-position: 50%; + background-size: cover; + margin-bottom: var(--margin); + + > * { + &:not(:last-child) { + margin-bottom: 8px; + } + + > span { + display: inline-block; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.7); + } + } + + > header { + font-size: 20px; + font-weight: bold; + } + + > div { + font-size: 14px; + opacity: 0.8; + } +} + +.vxjfqztj { + > * { + margin-right: 16px; + + &.local { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue new file mode 100644 index 0000000000..980d59835f --- /dev/null +++ b/packages/client/src/pages/favorites.vue @@ -0,0 +1,60 @@ +<template> +<div class="jmelgwjh"> + <div class="body"> + <XNotes class="notes" :pagination="pagination" :detail="true" :prop="'note'" @before="before()" @after="after()"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.favorites, + icon: 'fas fa-star', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'i/favorites', + limit: 10, + params: () => ({ + }) + }, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> + +<style lang="scss" scoped> +.jmelgwjh { + background: var(--bg); + + > .body { + box-sizing: border-box; + max-width: 800px; + margin: 0 auto; + padding: 16px; + } +} +</style> diff --git a/packages/client/src/pages/featured.vue b/packages/client/src/pages/featured.vue new file mode 100644 index 0000000000..f5edf25594 --- /dev/null +++ b/packages/client/src/pages/featured.vue @@ -0,0 +1,43 @@ +<template> +<MkSpacer :content-max="800"> + <XNotes ref="notes" :pagination="pagination" @before="before" @after="after"/> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.featured, + icon: 'fas fa-fire-alt', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'notes/featured', + limit: 10, + offsetMode: true, + }, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/federation.vue b/packages/client/src/pages/federation.vue new file mode 100644 index 0000000000..1bd5da58e3 --- /dev/null +++ b/packages/client/src/pages/federation.vue @@ -0,0 +1,265 @@ +<template> +<div class="taeiyria"> + <div class="query"> + <MkInput v-model="host" :debounce="true" class=""> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.host }}</template> + </MkInput> + <div class="_inputSplit"> + <MkSelect v-model="state"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="federating">{{ $ts.federating }}</option> + <option value="subscribing">{{ $ts.subscribing }}</option> + <option value="publishing">{{ $ts.publishing }}</option> + <option value="suspended">{{ $ts.suspended }}</option> + <option value="blocked">{{ $ts.blocked }}</option> + <option value="notResponding">{{ $ts.notResponding }}</option> + </MkSelect> + <MkSelect v-model="sort"> + <template #label>{{ $ts.sort }}</template> + <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option> + <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option> + <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option> + <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option> + <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option> + <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option> + <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option> + <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option> + <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option> + <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option> + <option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option> + <option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option> + <option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option> + <option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option> + <option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option> + <option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option> + <option value="+driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.descendingOrder }})</option> + <option value="-driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.ascendingOrder }})</option> + </MkSelect> + </div> + </div> + + <MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state"> + <div class="dqokceoi"> + <MkA class="instance" v-for="instance in items" :key="instance.id" :to="`/instance-info/${instance.host}`"> + <div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div> + <div class="table"> + <div class="cell"> + <div class="key">{{ $ts.registeredAt }}</div> + <div class="value"><MkTime :time="instance.caughtAt"/></div> + </div> + <div class="cell"> + <div class="key">{{ $ts.software }}</div> + <div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div> + </div> + <div class="cell"> + <div class="key">{{ $ts.version }}</div> + <div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div> + </div> + <div class="cell"> + <div class="key">{{ $ts.users }}</div> + <div class="value">{{ instance.usersCount }}</div> + </div> + <div class="cell"> + <div class="key">{{ $ts.notes }}</div> + <div class="value">{{ instance.notesCount }}</div> + </div> + <div class="cell"> + <div class="key">{{ $ts.sent }}</div> + <div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div> + </div> + <div class="cell"> + <div class="key">{{ $ts.received }}</div> + <div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> + </div> + </div> + <div class="footer"> + <span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span> + <span class="pubSub"> + <span class="sub" v-if="instance.followersCount > 0"><i class="fas fa-caret-down icon"></i>Sub</span> + <span class="sub" v-else><i class="fas fa-caret-down icon"></i>-</span> + <span class="pub" v-if="instance.followingCount > 0"><i class="fas fa-caret-up icon"></i>Pub</span> + <span class="pub" v-else><i class="fas fa-caret-up icon"></i>-</span> + </span> + <span class="right"> + <span class="latestStatus">{{ instance.latestStatus || '-' }}</span> + <span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span> + </span> + </div> + </MkA> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.federation, + icon: 'fas fa-globe', + bg: 'var(--bg)', + }, + host: '', + state: 'federating', + sort: '+pubSub', + pagination: { + endpoint: 'federation/instances', + limit: 10, + offsetMode: true, + params: () => ({ + sort: this.sort, + host: this.host != '' ? this.host : null, + ...( + this.state === 'federating' ? { federating: true } : + this.state === 'subscribing' ? { subscribing: true } : + this.state === 'publishing' ? { publishing: true } : + this.state === 'suspended' ? { suspended: true } : + this.state === 'blocked' ? { blocked: true } : + this.state === 'notResponding' ? { notResponding: true } : + {}) + }) + }, + } + }, + + watch: { + host() { + this.$refs.instances.reload(); + }, + state() { + this.$refs.instances.reload(); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + getStatus(instance) { + if (instance.isSuspended) return 'suspended'; + if (instance.isNotResponding) return 'error'; + return 'alive'; + }, + } +}); +</script> + +<style lang="scss" scoped> +.taeiyria { + > .query { + background: var(--bg); + padding: 16px; + } +} + +.dqokceoi { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; + padding: 16px; + + > .instance { + padding: 16px; + border: solid 1px var(--divider); + border-radius: 6px; + + &:hover { + border: solid 1px var(--accent); + text-decoration: none; + } + + > .host { + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > img { + width: 18px; + height: 18px; + margin-right: 6px; + vertical-align: middle; + } + } + + > .table { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); + grid-gap: 6px; + margin: 6px 0; + font-size: 70%; + + > .cell { + > .key, > .value { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .key { + opacity: 0.7; + } + + > .value { + } + } + } + + > .footer { + display: flex; + align-items: center; + font-size: 0.9em; + + > .status { + &.suspended { + opacity: 0.5; + } + + &.error { + color: var(--error); + } + + &.alive { + color: var(--success); + } + } + + > .pubSub { + margin-left: 8px; + } + + > .right { + margin-left: auto; + + > .latestStatus { + border: solid 1px var(--divider); + border-radius: 4px; + margin: 0 8px; + padding: 0 4px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue new file mode 100644 index 0000000000..d8967dc9d9 --- /dev/null +++ b/packages/client/src/pages/follow-requests.vue @@ -0,0 +1,153 @@ +<template> +<div> + <MkPagination :pagination="pagination" class="mk-follow-requests" ref="list"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noFollowRequests }}</div> + </div> + </template> + <template #default="{items}"> + <div class="user _panel" v-for="req in items" :key="req.id"> + <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> + <div class="body"> + <div class="name"> + <MkA class="name" :to="userPage(req.follower)" v-user-preview="req.follower.id"><MkUserName :user="req.follower"/></MkA> + <p class="acct">@{{ acct(req.follower) }}</p> + </div> + <div class="description" v-if="req.follower.description" :title="req.follower.description"> + <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> + </div> + <div class="actions"> + <button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button> + <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button> + </div> + </div> + </div> + </template> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { userPage, acct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.followRequests, + icon: 'fas fa-user-clock', + }, + pagination: { + endpoint: 'following/requests/list', + limit: 10, + }, + }; + }, + + methods: { + accept(user) { + os.api('following/requests/accept', { userId: user.id }).then(() => { + this.$refs.list.reload(); + }); + }, + reject(user) { + os.api('following/requests/reject', { userId: user.id }).then(() => { + this.$refs.list.reload(); + }); + }, + userPage, + acct + } +}); +</script> + +<style lang="scss" scoped> +.mk-follow-requests { + > .user { + display: flex; + padding: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 42px; + height: 42px; + border-radius: 8px; + } + + > .body { + display: flex; + width: calc(100% - 54px); + position: relative; + + > .name { + width: 45%; + + @media (max-width: 500px) { + width: 100%; + } + + > .name, + > .acct { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + margin: 0; + } + + > .name { + font-size: 16px; + line-height: 24px; + } + + > .acct { + font-size: 15px; + line-height: 16px; + opacity: 0.7; + } + } + + > .description { + width: 55%; + line-height: 42px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; + font-size: 14px; + padding-right: 40px; + padding-left: 8px; + box-sizing: border-box; + + @media (max-width: 500px) { + display: none; + } + } + + > .actions { + position: absolute; + top: 0; + bottom: 0; + right: 0; + margin: auto 0; + + > button { + padding: 12px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue new file mode 100644 index 0000000000..e8eaad73bf --- /dev/null +++ b/packages/client/src/pages/follow.vue @@ -0,0 +1,65 @@ +<template> +<div class="mk-follow-page"> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import * as Acct from 'misskey-js/built/acct'; + +export default defineComponent({ + created() { + const acct = new URL(location.href).searchParams.get('acct'); + if (acct == null) return; + + let promise; + + if (acct.startsWith('https://')) { + promise = os.api('ap/show', { + uri: acct + }); + promise.then(res => { + if (res.type == 'User') { + this.follow(res.object); + } else if (res.type === 'Note') { + this.$router.push(`/notes/${res.object.id}`); + } else { + os.dialog({ + type: 'error', + text: 'Not a user' + }).then(() => { + window.close(); + }); + } + }); + } else { + promise = os.api('users/show', Acct.parse(acct)); + promise.then(user => { + this.follow(user); + }); + } + + os.promiseDialog(promise, null, null, this.$ts.fetchingAsApObject); + }, + + methods: { + async follow(user) { + const { canceled } = await os.dialog({ + type: 'question', + text: this.$t('followConfirm', { name: user.name || user.username }), + showCancelButton: true + }); + + if (canceled) { + window.close(); + return; + } + + os.apiWithDialog('following/create', { + userId: user.id + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue new file mode 100644 index 0000000000..1ee3a9390b --- /dev/null +++ b/packages/client/src/pages/gallery/edit.vue @@ -0,0 +1,168 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormInput v-model="title"> + <span>{{ $ts.title }}</span> + </FormInput> + + <FormTextarea v-model="description" :max="500"> + <span>{{ $ts.description }}</span> + </FormTextarea> + + <FormGroup> + <div v-for="file in files" :key="file.id" class="_debobigegoItem _debobigegoPanel wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> + <div class="name">{{ file.name }}</div> + <button class="remove _button" @click="remove(file)" v-tooltip="$ts.remove"><i class="fas fa-times"></i></button> + </div> + <FormButton @click="selectFile" primary><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton> + </FormGroup> + + <FormSwitch v-model="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch> + + <FormButton v-if="postId" @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + <FormButton v-else @click="save" primary><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton> + + <FormButton v-if="postId" @click="del" danger><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormTuple from '@/components/debobigego/tuple.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormButton, + FormInput, + FormTextarea, + FormSwitch, + FormBase, + FormGroup, + FormSuspense, + }, + + props: { + postId: { + type: String, + required: false, + default: null, + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.postId ? { + title: this.$ts.edit, + icon: 'fas fa-pencil-alt' + } : { + title: this.$ts.postToGallery, + icon: 'fas fa-pencil-alt' + }), + init: null, + files: [], + description: null, + title: null, + isSensitive: false, + } + }, + + watch: { + postId: { + handler() { + this.init = () => this.postId ? os.api('gallery/posts/show', { + postId: this.postId + }).then(post => { + this.files = post.files; + this.title = post.title; + this.description = post.description; + this.isSensitive = post.isSensitive; + }) : Promise.resolve(null); + }, + immediate: true, + } + }, + + methods: { + selectFile(e) { + selectFile(e.currentTarget || e.target, null, true).then(files => { + this.files = this.files.concat(files); + }); + }, + + remove(file) { + this.files = this.files.filter(f => f.id !== file.id); + }, + + async save() { + if (this.postId) { + await os.apiWithDialog('gallery/posts/update', { + postId: this.postId, + title: this.title, + description: this.description, + fileIds: this.files.map(file => file.id), + isSensitive: this.isSensitive, + }); + this.$router.push(`/gallery/${this.postId}`); + } else { + const post = await os.apiWithDialog('gallery/posts/create', { + title: this.title, + description: this.description, + fileIds: this.files.map(file => file.id), + isSensitive: this.isSensitive, + }); + this.$router.push(`/gallery/${post.id}`); + } + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.deleteConfirm, + showCancelButton: true + }); + if (canceled) return; + await os.apiWithDialog('gallery/posts/delete', { + postId: this.postId, + }); + this.$router.push(`/gallery`); + } + } +}); +</script> + +<style lang="scss" scoped> +.wqugxsfx { + height: 200px; + background-size: contain; + background-position: center; + background-repeat: no-repeat; + position: relative; + + > .name { + position: absolute; + top: 8px; + left: 9px; + padding: 8px; + background: var(--panel); + } + + > .remove { + position: absolute; + top: 8px; + right: 9px; + padding: 8px; + background: var(--panel); + } +} +</style> diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue new file mode 100644 index 0000000000..dfcd59349e --- /dev/null +++ b/packages/client/src/pages/gallery/index.vue @@ -0,0 +1,152 @@ +<template> +<div class="xprsixdl _root"> + <MkTab v-model="tab" v-if="$i"> + <option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option> + <option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option> + <option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option> + </MkTab> + + <div v-if="tab === 'explore'"> + <MkFolder class="_gap"> + <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template> + <MkPagination :pagination="recentPostsPagination" #default="{items}" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template> + <MkPagination :pagination="popularPostsPagination" #default="{items}" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </MkFolder> + </div> + <div v-else-if="tab === 'liked'"> + <MkPagination :pagination="likedPostsPagination" #default="{items}"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="like in items" :post="like.post" :key="like.id" class="post"/> + </div> + </MkPagination> + </div> + <div v-else-if="tab === 'my'"> + <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA> + <MkPagination :pagination="myPostsPagination" #default="{items}"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XUserList from '@/components/user-list.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkInput from '@/components/form/input.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTab from '@/components/tab.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XUserList, + MkFolder, + MkInput, + MkButton, + MkTab, + MkPagination, + MkGalleryPostPreview, + }, + + props: { + tag: { + type: String, + required: false + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.gallery, + icon: 'fas fa-icons' + }, + tab: 'explore', + recentPostsPagination: { + endpoint: 'gallery/posts', + limit: 6, + }, + popularPostsPagination: { + endpoint: 'gallery/featured', + limit: 5, + }, + myPostsPagination: { + endpoint: 'i/gallery/posts', + limit: 5, + }, + likedPostsPagination: { + endpoint: 'i/gallery/likes', + limit: 5, + }, + tags: [], + }; + }, + + computed: { + meta() { + return this.$instance; + }, + tagUsers(): any { + return { + endpoint: 'hashtags/users', + limit: 30, + params: { + tag: this.tag, + origin: 'combined', + sort: '+follower', + } + }; + }, + }, + + watch: { + tag() { + if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); + }, + }, + + created() { + + }, + + methods: { + + } +}); +</script> + +<style lang="scss" scoped> +.xprsixdl { + max-width: 1400px; + margin: 0 auto; +} + +.vfpdbgtk { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: 0 var(--margin); + + > .post { + + } +} +</style> diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue new file mode 100644 index 0000000000..255954def0 --- /dev/null +++ b/packages/client/src/pages/gallery/post.vue @@ -0,0 +1,282 @@ +<template> +<div class="_root"> + <transition name="fade" mode="out-in"> + <div v-if="post" class="rkxwuolj"> + <div class="files"> + <div class="file" v-for="file in post.files" :key="file.id"> + <img :src="file.url"/> + </div> + </div> + <div class="body _block"> + <div class="title">{{ post.title }}</div> + <div class="description"><Mfm :text="post.description"/></div> + <div class="info"> + <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton class="button" @click="unlike()" v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton> + <MkButton class="button" @click="like()" v-else v-tooltip="$ts._gallery.like"><i class="far fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button v-if="$i && $i.id === post.user.id" class="_button" @click="edit" v-tooltip="$ts.edit" v-click-anime><i class="fas fa-pencil-alt fa-fw"></i></button> + <button class="_button" @click="shareWithNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button> + <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="post.user" class="avatar"/> + <div class="name"> + <MkUserName :user="post.user" style="display: block;"/> + <MkAcct :user="post.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + </div> + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> + <MkPagination :pagination="otherPostsPagination" #default="{items}"> + <div class="sdrarzaf"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </MkContainer> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import MkContainer from '@/components/ui/container.vue'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; +import MkFollowButton from '@/components/follow-button.vue'; +import { url } from '@/config'; + +export default defineComponent({ + components: { + MkContainer, + ImgWithBlurhash, + MkPagination, + MkGalleryPostPreview, + MkButton, + MkFollowButton, + }, + props: { + postId: { + type: String, + required: true + } + }, + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.post ? { + title: this.post.title, + avatar: this.post.user, + path: `/gallery/${this.post.id}`, + share: { + title: this.post.title, + text: this.post.description, + }, + actions: [{ + icon: 'fas fa-pencil-alt', + text: this.$ts.edit, + handler: this.edit + }] + } : null), + otherPostsPagination: { + endpoint: 'users/gallery/posts', + limit: 6, + params: () => ({ + userId: this.post.user.id + }) + }, + post: null, + error: null, + }; + }, + + watch: { + postId: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.post = null; + os.api('gallery/posts/show', { + postId: this.postId + }).then(post => { + this.post = post; + }).catch(e => { + this.error = e; + }); + }, + + share() { + navigator.share({ + title: this.post.title, + text: this.post.description, + url: `${url}/gallery/${this.post.id}` + }); + }, + + shareWithNote() { + os.post({ + initialText: `${this.post.title} ${url}/gallery/${this.post.id}` + }); + }, + + like() { + os.apiWithDialog('gallery/posts/like', { + postId: this.postId, + }).then(() => { + this.post.isLiked = true; + this.post.likedCount++; + }); + }, + + async unlike() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('gallery/posts/unlike', { + postId: this.postId, + }).then(() => { + this.post.isLiked = false; + this.post.likedCount--; + }); + }, + + edit() { + this.$router.push(`/gallery/${this.post.id}/edit`); + } + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.rkxwuolj { + > .files { + > .file { + > img { + display: block; + max-width: 100%; + max-height: 500px; + margin: 0 auto; + } + + & + .file { + margin-top: 16px; + } + } + } + + > .body { + padding: 32px; + + > .title { + font-weight: bold; + font-size: 1.2em; + margin-bottom: 16px; + } + + > .info { + margin-top: 16px; + font-size: 90%; + opacity: 0.7; + } + + > .actions { + display: flex; + align-items: center; + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + + > .like { + > .button { + --accent: rgb(241 97 132); + --X8: rgb(241 92 128); + --buttonBg: rgb(216 71 106 / 5%); + --buttonHoverBg: rgb(216 71 106 / 10%); + color: #ff002f; + + ::v-deep(.count) { + margin-left: 0.5em; + } + } + } + + > .other { + margin-left: auto; + + > button { + padding: 8px; + margin: 0 8px; + + &:hover { + color: var(--fgHighlighted); + } + } + } + } + + > .user { + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + display: flex; + align-items: center; + + > .avatar { + width: 52px; + height: 52px; + } + + > .name { + margin: 0 0 0 12px; + font-size: 90%; + } + + > .koudoku { + margin-left: auto; + } + } + } +} + +.sdrarzaf { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: var(--margin); + + > .post { + + } +} +</style> diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue new file mode 100644 index 0000000000..586d9d7e52 --- /dev/null +++ b/packages/client/src/pages/instance-info.vue @@ -0,0 +1,238 @@ +<template> +<FormBase> + <FormGroup v-if="instance"> + <template #label>{{ instance.host }}</template> + <FormGroup> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel fnfelxur"> + <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/> + </div> + </div> + <FormKeyValueView> + <template #key>Name</template> + <template #value><span class="_monospace">{{ instance.name || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + </FormGroup> + + <FormButton v-if="$i.isAdmin || $i.isModerator" @click="info" primary>{{ $ts.settings }}</FormButton> + + <FormTextarea readonly :value="instance.description"> + <span>{{ $ts.description }}</span> + </FormTextarea> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.version }}</template> + <template #value><span class="_monospace">{{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.administrator }}</template> + <template #value><span class="_monospace">{{ instance.maintainerName || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.contact }}</template> + <template #value><span class="_monospace">{{ instance.maintainerEmail || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.latestRequestSentAt }}</template> + <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.latestStatus }}</template> + <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.latestRequestReceivedAt }}</template> + <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <FormKeyValueView> + <template #key>Open Registrations</template> + <template #value>{{ instance.openRegistrations ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + </FormGroup> + <div class="_debobigegoItem"> + <div class="_debobigegoLabel">{{ $ts.statistics }}</div> + <div class="_debobigegoPanel cmhjzshl"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ $ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + <div class="chart"> + <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart> + </div> + </div> + </div> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.registeredAt }}</template> + <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> + </FormKeyValueView> + </FormGroup> + <FormObjectView tall :value="instance"> + <span>Raw</span> + </FormObjectView> + <FormGroup> + <template #label>Well-known resources</template> + <FormLink :to="`https://${host}/.well-known/host-meta`" external>host-meta</FormLink> + <FormLink :to="`https://${host}/.well-known/host-meta.json`" external>host-meta.json</FormLink> + <FormLink :to="`https://${host}/.well-known/nodeinfo`" external>nodeinfo</FormLink> + <FormLink :to="`https://${host}/robots.txt`" external>robots.txt</FormLink> + <FormLink :to="`https://${host}/manifest.json`" external>manifest.json</FormLink> + </FormGroup> + <FormSuspense :p="dnsPromiseFactory" v-slot="{ result: dns }"> + <FormGroup> + <template #label>DNS</template> + <FormKeyValueView v-for="record in dns.a" :key="record"> + <template #key>A</template> + <template #value><span class="_monospace">{{ record }}</span></template> + </FormKeyValueView> + <FormKeyValueView v-for="record in dns.aaaa" :key="record"> + <template #key>AAAA</template> + <template #value><span class="_monospace">{{ record }}</span></template> + </FormKeyValueView> + <FormKeyValueView v-for="record in dns.cname" :key="record"> + <template #key>CNAME</template> + <template #value><span class="_monospace">{{ record }}</span></template> + </FormKeyValueView> + <FormKeyValueView v-for="record in dns.txt"> + <template #key>TXT</template> + <template #value><span class="_monospace">{{ record[0] }}</span></template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import MkChart from '@/components/chart.vue'; +import FormObjectView from '@/components/debobigego/object-view.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import MkSelect from '@/components/form/select.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; +import MkInstanceInfo from '@/pages/admin/instance.vue'; + +export default defineComponent({ + components: { + FormBase, + FormTextarea, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + MkSelect, + MkChart, + }, + + props: { + host: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.instanceInfo, + icon: 'fas fa-info-circle', + actions: [{ + text: `https://${this.host}`, + icon: 'fas fa-external-link-alt', + handler: () => { + window.open(`https://${this.host}`, '_blank'); + } + }], + }, + instance: null, + dnsPromiseFactory: () => os.api('federation/dns', { + host: this.host + }), + chartSrc: 'instance-requests', + chartSpan: 'hour', + } + }, + + mounted() { + this.fetch(); + }, + + methods: { + number, + bytes, + + async fetch() { + this.instance = await os.api('federation/show-instance', { + host: this.host + }); + }, + + info() { + os.popup(MkInstanceInfo, { + instance: this.instance + }, {}, 'closed'); + } + } +}); +</script> + +<style lang="scss" scoped> +.fnfelxur { + padding: 16px; + + > .icon { + display: block; + margin: auto; + height: 64px; + border-radius: 8px; + } +} + +.cmhjzshl { + > .selects { + display: flex; + padding: 16px; + } +} +</style> diff --git a/packages/client/src/pages/mentions.vue b/packages/client/src/pages/mentions.vue new file mode 100644 index 0000000000..cd9c6a8fdf --- /dev/null +++ b/packages/client/src/pages/mentions.vue @@ -0,0 +1,42 @@ +<template> +<MkSpacer :content-max="800"> + <XNotes :pagination="pagination" @before="before()" @after="after()"/> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.mentions, + icon: 'fas fa-at', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'notes/mentions', + limit: 10, + }, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/messages.vue b/packages/client/src/pages/messages.vue new file mode 100644 index 0000000000..9fde0bc7d5 --- /dev/null +++ b/packages/client/src/pages/messages.vue @@ -0,0 +1,45 @@ +<template> +<MkSpacer :content-max="800"> + <XNotes :pagination="pagination" @before="before()" @after="after()"/> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.directNotes, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'notes/mentions', + limit: 10, + params: () => ({ + visibility: 'specified' + }) + }, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue new file mode 100644 index 0000000000..896c3927ce --- /dev/null +++ b/packages/client/src/pages/messaging/index.vue @@ -0,0 +1,307 @@ +<template> +<MkSpacer :content-max="800"> + <div class="yweeujhr" v-size="{ max: [400] }"> + <MkButton @click="start" primary class="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton> + + <div class="history" v-if="messages.length > 0"> + <MkA v-for="(message, i) in messages" + class="message _block" + :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }" + :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-index="i" + :key="message.id" + v-anim="i" + > + <div> + <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/> + <header v-if="message.groupId"> + <span class="name">{{ message.group.name }}</span> + <MkTime :time="message.createdAt" class="time"/> + </header> + <header v-else> + <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span> + <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span> + <MkTime :time="message.createdAt" class="time"/> + </header> + <div class="body"> + <p class="text"><span class="me" v-if="isMe(message)">{{ $ts.you }}:</span>{{ message.text }}</p> + </div> + </div> + </MkA> + </div> + <div class="_fullinfo" v-if="!fetching && messages.length == 0"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noHistory }}</div> + </div> + <MkLoading v-if="fetching"/> + </div> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkButton from '@/components/ui/button.vue'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.messaging, + icon: 'fas fa-comments', + bg: 'var(--bg)', + }, + fetching: true, + moreFetching: false, + messages: [], + connection: null, + }; + }, + + mounted() { + this.connection = markRaw(os.stream.useChannel('messagingIndex')); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + + os.api('messaging/history', { group: false }).then(userMessages => { + os.api('messaging/history', { group: true }).then(groupMessages => { + const messages = userMessages.concat(groupMessages); + messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + this.messages = messages; + this.fetching = false; + }); + }); + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + getAcct: Acct.toString, + + isMe(message) { + return message.userId == this.$i.id; + }, + + onMessage(message) { + if (message.recipientId) { + this.messages = this.messages.filter(m => !( + (m.recipientId == message.recipientId && m.userId == message.userId) || + (m.recipientId == message.userId && m.userId == message.recipientId))); + + this.messages.unshift(message); + } else if (message.groupId) { + this.messages = this.messages.filter(m => m.groupId !== message.groupId); + this.messages.unshift(message); + } + }, + + onRead(ids) { + for (const id of ids) { + const found = this.messages.find(m => m.id == id); + if (found) { + if (found.recipientId) { + found.isRead = true; + } else if (found.groupId) { + found.reads.push(this.$i.id); + } + } + } + }, + + start(ev) { + os.popupMenu([{ + text: this.$ts.messagingWithUser, + icon: 'fas fa-user', + action: () => { this.startUser() } + }, { + text: this.$ts.messagingWithGroup, + icon: 'fas fa-users', + action: () => { this.startGroup() } + }], ev.currentTarget || ev.target); + }, + + async startUser() { + os.selectUser().then(user => { + this.$router.push(`/my/messaging/${Acct.toString(user)}`); + }); + }, + + async startGroup() { + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); + if (groups1.length === 0 && groups2.length === 0) { + os.dialog({ + type: 'warning', + title: this.$ts.youHaveNoGroups, + text: this.$ts.joinOrCreateGroup, + }); + return; + } + const { canceled, result: group } = await os.dialog({ + type: null, + title: this.$ts.group, + select: { + items: groups1.concat(groups2).map(group => ({ + value: group, text: group.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + this.$router.push(`/my/messaging/group/${group.id}`); + }, + + acct + } +}); +</script> + +<style lang="scss" scoped> +.yweeujhr { + + > .start { + margin: 0 auto var(--margin) auto; + } + + > .history { + > .message { + display: block; + text-decoration: none; + margin-bottom: var(--margin); + + * { + pointer-events: none; + user-select: none; + } + + &:hover { + .avatar { + filter: saturate(200%); + } + } + + &:active { + } + + &.isRead, + &.isMe { + opacity: 0.8; + } + + &:not(.isMe):not(.isRead) { + > div { + background-image: url("/client-assets/unread.svg"); + background-repeat: no-repeat; + background-position: 0 center; + } + } + + &:after { + content: ""; + display: block; + clear: both; + } + + > div { + padding: 20px 30px; + + &:after { + content: ""; + display: block; + clear: both; + } + + > header { + display: flex; + align-items: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + + > .name { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1em; + font-weight: bold; + transition: all 0.1s ease; + } + + > .username { + margin: 0 8px; + } + + > .time { + margin: 0 0 0 auto; + } + } + + > .avatar { + float: left; + width: 54px; + height: 54px; + margin: 0 16px 0 0; + border-radius: 8px; + transition: all 0.1s ease; + } + + > .body { + + > .text { + display: block; + margin: 0 0 0 0; + padding: 0; + overflow: hidden; + overflow-wrap: break-word; + font-size: 1.1em; + color: var(--faceText); + + .me { + opacity: 0.7; + } + } + + > .image { + display: block; + max-width: 100%; + max-height: 512px; + } + } + } + } + } + + &.max-width_400px { + > .history { + > .message { + &:not(.isMe):not(.isRead) { + > div { + background-image: none; + border-left: solid 4px #3aa2dc; + } + } + + > div { + padding: 16px; + font-size: 0.9em; + + > .avatar { + margin: 0 12px 0 0; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue new file mode 100644 index 0000000000..aafed2632d --- /dev/null +++ b/packages/client/src/pages/messaging/messaging-room.form.vue @@ -0,0 +1,348 @@ +<template> +<div class="pemppnzi _block" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + v-model="text" + ref="text" + @keypress="onKeypress" + @compositionupdate="onCompositionUpdate" + @paste="onPaste" + :placeholder="$ts.inputMessageHere" + ></textarea> + <div class="file" @click="file = null" v-if="file">{{ file.name }}</div> + <button class="send _button" @click="send" :disabled="!canSend || sending" :title="$ts.send"> + <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> + </button> + <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button> + <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> + <input ref="file" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import * as autosize from 'autosize'; +import { formatTimeString } from '@/scripts/format-time-string'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { Autocomplete } from '@/scripts/autocomplete'; +import { throttle } from 'throttle-debounce'; + +export default defineComponent({ + props: { + user: { + type: Object, + requird: false, + }, + group: { + type: Object, + requird: false, + }, + }, + data() { + return { + text: null, + file: null, + sending: false, + typing: throttle(3000, () => { + os.stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id }); + }), + }; + }, + computed: { + draftKey(): string { + return this.user ? 'user:' + this.user.id : 'group:' + this.group.id; + }, + canSend(): boolean { + return (this.text != null && this.text != '') || this.file != null; + }, + room(): any { + return this.$parent; + } + }, + watch: { + text() { + this.saveDraft(); + }, + file() { + this.saveDraft(); + } + }, + mounted() { + autosize(this.$refs.text); + + // TODO: detach when unmount + new Autocomplete(this.$refs.text, this, { model: 'text' }); + + // ๆธใใใใฎๆ็จฟใๅพฉๅ
+ const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey]; + if (draft) { + this.text = draft.data.text; + this.file = draft.data.file; + } + }, + methods: { + async onPaste(e: ClipboardEvent) { + const data = e.clipboardData; + const items = data.items; + + if (items.length == 1) { + if (items[0].kind == 'file') { + const file = items[0].getAsFile(); + const lio = file.name.lastIndexOf('.'); + const ext = lio >= 0 ? file.name.slice(lio) : ''; + const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`; + const name = this.$store.state.pasteDialog + ? await os.dialog({ + title: this.$ts.enterFileName, + input: { + default: formatted + }, + allowEmpty: false + }).then(({ canceled, result }) => canceled ? false : result) + : formatted; + if (name) this.upload(file, name); + } + } else { + if (items[0].kind == 'file') { + os.dialog({ + type: 'error', + text: this.$ts.onlyOneFileCanBeAttached + }); + } + } + }, + + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + e.preventDefault(); + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDrop(e): void { + // ใใกใคใซใ ใฃใใ + if (e.dataTransfer.files.length == 1) { + e.preventDefault(); + this.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + e.preventDefault(); + os.dialog({ + type: 'error', + text: this.$ts.onlyOneFileCanBeAttached + }); + return; + } + + //#region ใใฉใคใใฎใใกใคใซ + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + this.file = JSON.parse(driveFile); + e.preventDefault(); + } + //#endregion + }, + + onKeypress(e) { + this.typing(); + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) { + this.send(); + } + }, + + onCompositionUpdate() { + this.typing(); + }, + + chooseFile(e) { + selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => { + this.file = file; + }); + }, + + onChangeFile() { + this.upload((this.$refs.file as any).files[0]); + }, + + upload(file: File, name?: string) { + os.upload(file, this.$store.state.uploadFolder, name).then(res => { + this.file = res; + }); + }, + + send() { + this.sending = true; + os.api('messaging/messages/create', { + userId: this.user ? this.user.id : undefined, + groupId: this.group ? this.group.id : undefined, + text: this.text ? this.text : undefined, + fileId: this.file ? this.file.id : undefined + }).then(message => { + this.clear(); + }).catch(err => { + console.error(err); + }).then(() => { + this.sending = false; + }); + }, + + clear() { + this.text = ''; + this.file = null; + this.deleteDraft(); + }, + + saveDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + data[this.draftKey] = { + updatedAt: new Date(), + data: { + text: this.text, + file: this.file + } + } + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + delete data[this.draftKey]; + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + + async insertEmoji(ev) { + os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); + } + } +}); +</script> + +<style lang="scss" scoped> +.pemppnzi { + position: relative; + + > textarea { + cursor: auto; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + height: 80px; + margin: 0; + padding: 16px 16px 0 16px; + resize: none; + font-size: 1em; + font-family: inherit; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + box-sizing: border-box; + color: var(--fg); + } + + > .file { + padding: 8px; + color: #444; + background: #eee; + cursor: pointer; + } + + > .send { + position: absolute; + bottom: 0; + right: 0; + margin: 0; + padding: 16px; + font-size: 1em; + transition: color 0.1s ease; + color: var(--accent); + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } + } + + .files { + display: block; + margin: 0; + padding: 0 8px; + list-style: none; + + &:after { + content: ''; + display: block; + clear: both; + } + + > li { + display: block; + float: left; + margin: 4px; + padding: 0; + width: 64px; + height: 64px; + background-color: #eee; + background-repeat: no-repeat; + background-position: center center; + background-size: cover; + cursor: move; + + &:hover { + > .remove { + display: block; + } + } + + > .remove { + display: none; + position: absolute; + right: -6px; + top: -6px; + margin: 0; + padding: 0; + background: transparent; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + cursor: pointer; + } + } + } + + ._button { + margin: 0; + padding: 16px; + font-size: 1em; + font-weight: normal; + text-decoration: none; + transition: color 0.1s ease; + + &:hover { + color: var(--accent); + } + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } + } + + input[type=file] { + display: none; + } +} +</style> diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue new file mode 100644 index 0000000000..432d11add8 --- /dev/null +++ b/packages/client/src/pages/messaging/messaging-room.message.vue @@ -0,0 +1,350 @@ +<template> +<div class="thvuemwp" :class="{ isMe }" v-size="{ max: [400, 500] }"> + <MkAvatar class="avatar" :user="message.user" :show-indicator="true"/> + <div class="content"> + <div class="balloon" :class="{ noText: message.text == null }" > + <button class="delete-button" v-if="isMe" :title="$ts.delete" @click="del"> + <img src="/client-assets/remove.png" alt="Delete"/> + </button> + <div class="content" v-if="!message.isDeleted"> + <Mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$i"/> + <div class="file" v-if="message.file"> + <a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name"> + <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> + <p v-else>{{ message.file.name }}</p> + </a> + </div> + </div> + <div class="content" v-else> + <p class="is-deleted">{{ $ts.deleted }}</p> + </div> + </div> + <div></div> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" style="margin: 8px 0;"/> + <footer> + <template v-if="isGroup"> + <span class="read" v-if="message.reads.length > 0">{{ $ts.messageRead }} {{ message.reads.length }}</span> + </template> + <template v-else> + <span class="read" v-if="isMe && message.isRead">{{ $ts.messageRead }}</span> + </template> + <MkTime :time="message.createdAt"/> + <template v-if="message.is_edited"><i class="fas fa-pencil-alt"></i></template> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as mfm from 'mfm-js'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; +import MkUrlPreview from '@/components/url-preview.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkUrlPreview + }, + props: { + message: { + required: true + }, + isGroup: { + required: false + } + }, + computed: { + isMe(): boolean { + return this.message.userId === this.$i.id; + }, + urls(): string[] { + if (this.message.text) { + return extractUrlFromMfm(mfm.parse(this.message.text)); + } else { + return []; + } + } + }, + methods: { + del() { + os.api('messaging/messages/delete', { + messageId: this.message.id + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.thvuemwp { + $me-balloon-color: var(--accent); + + position: relative; + background-color: transparent; + display: flex; + + > .avatar { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + display: block; + width: 54px; + height: 54px; + transition: all 0.1s ease; + } + + > .content { + min-width: 0; + + > .balloon { + position: relative; + display: inline-flex; + align-items: center; + padding: 0; + min-height: 38px; + border-radius: 16px; + max-width: 100%; + + &:before { + content: ""; + pointer-events: none; + display: block; + position: absolute; + top: 12px; + } + + & + * { + clear: both; + } + + &:hover { + > .delete-button { + display: block; + } + } + + > .delete-button { + display: none; + position: absolute; + z-index: 1; + top: -4px; + right: -4px; + margin: 0; + padding: 0; + cursor: pointer; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + + > img { + vertical-align: bottom; + width: 16px; + height: 16px; + cursor: pointer; + } + } + + > .content { + max-width: 100%; + + > .is-deleted { + display: block; + margin: 0; + padding: 0; + overflow: hidden; + overflow-wrap: break-word; + font-size: 1em; + color: rgba(#000, 0.5); + } + + > .text { + display: block; + margin: 0; + padding: 12px 18px; + overflow: hidden; + overflow-wrap: break-word; + word-break: break-word; + font-size: 1em; + color: rgba(#000, 0.8); + + & + .file { + > a { + border-radius: 0 0 16px 16px; + } + } + } + + > .file { + > a { + display: block; + max-width: 100%; + border-radius: 16px; + overflow: hidden; + text-decoration: none; + + &:hover { + text-decoration: none; + + > p { + background: #ccc; + } + } + + > * { + display: block; + margin: 0; + width: 100%; + max-height: 512px; + object-fit: contain; + box-sizing: border-box; + } + + > p { + padding: 30px; + text-align: center; + color: #555; + background: #ddd; + } + } + } + } + } + + > footer { + display: block; + margin: 2px 0 0 0; + font-size: 0.65em; + + > .read { + margin: 0 8px; + } + + > i { + margin-left: 4px; + } + } + } + + &:not(.isMe) { + padding-left: var(--margin); + + > .content { + padding-left: 16px; + padding-right: 32px; + + > .balloon { + $color: var(--messageBg); + background: $color; + + &.noText { + background: transparent; + } + + &:not(.noText):before { + left: -14px; + border-top: solid 8px transparent; + border-right: solid 8px $color; + border-bottom: solid 8px transparent; + border-left: solid 8px transparent; + } + + > .content { + > .text { + color: var(--fg); + } + } + } + + > footer { + text-align: left; + } + } + } + + &.isMe { + flex-direction: row-reverse; + padding-right: var(--margin); + + > .content { + padding-right: 16px; + padding-left: 32px; + text-align: right; + + > .balloon { + background: $me-balloon-color; + text-align: left; + + ::selection { + color: var(--accent); + background-color: #fff; + } + + &.noText { + background: transparent; + } + + &:not(.noText):before { + right: -14px; + left: auto; + border-top: solid 8px transparent; + border-right: solid 8px transparent; + border-bottom: solid 8px transparent; + border-left: solid 8px $me-balloon-color; + } + + > .content { + + > p.is-deleted { + color: rgba(#fff, 0.5); + } + + > .text { + &, ::v-deep(*) { + color: var(--fgOnAccent) !important; + } + } + } + } + + > footer { + text-align: right; + + > .read { + user-select: none; + } + } + } + } + + &.max-width_400px { + > .avatar { + width: 48px; + height: 48px; + } + + > .content { + > .balloon { + > .content { + > .text { + font-size: 0.9em; + } + } + } + } + } + + &.max-width_500px { + > .content { + > .balloon { + > .content { + > .text { + padding: 8px 16px; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue new file mode 100644 index 0000000000..3a19b12762 --- /dev/null +++ b/packages/client/src/pages/messaging/messaging-room.vue @@ -0,0 +1,470 @@ +<template> +<div class="_section" + @dragover.prevent.stop="onDragover" + @drop.prevent.stop="onDrop" +> + <div class="_content mk-messaging-room"> + <div class="body"> + <MkLoading v-if="fetching"/> + <p class="empty" v-if="!fetching && messages.length == 0"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p> + <p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p> + <button class="more _button" ref="loadMore" :class="{ fetching: fetchingMoreMessages }" v-show="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> + <template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }} + </button> + <XList class="messages" :items="messages" v-slot="{ item: message }" direction="up" reversed> + <XMessage :message="message" :is-group="group != null" :key="message.id"/> + </XList> + </div> + <footer> + <div class="typers" v-if="typers.length > 0"> + <I18n :src="$ts.typingUsers" text-tag="span" class="users"> + <template #users> + <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> + </template> + </I18n> + <MkEllipsis/> + </div> + <transition name="fade"> + <div class="new-message" v-show="showIndicator"> + <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button> + </div> + </transition> + <XForm v-if="!fetching" :user="user" :group="group" ref="form" class="form"/> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw } from 'vue'; +import XList from '@/components/date-separated-list.vue'; +import XMessage from './messaging-room.message.vue'; +import XForm from './messaging-room.form.vue'; +import * as Acct from 'misskey-js/built/acct'; +import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import { popout } from '@/scripts/popout'; +import * as sound from '@/scripts/sound'; +import * as symbols from '@/symbols'; + +const Component = defineComponent({ + components: { + XMessage, + XForm, + XList, + }, + + inject: ['inWindow'], + + props: { + userAcct: { + type: String, + required: false, + }, + groupId: { + type: String, + required: false, + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? { + userName: this.user, + avatar: this.user, + action: { + icon: 'fas fa-ellipsis-h', + handler: this.menu, + }, + } : { + title: this.group.name, + icon: 'fas fa-users', + action: { + icon: 'fas fa-ellipsis-h', + handler: this.menu, + }, + } : null), + fetching: true, + user: null, + group: null, + fetchingMoreMessages: false, + messages: [], + existMoreMessages: false, + connection: null, + showIndicator: false, + timer: null, + typers: [], + ilObserver: new IntersectionObserver( + (entries) => entries.some((entry) => entry.isIntersecting) + && !this.fetching + && !this.fetchingMoreMessages + && this.existMoreMessages + && this.fetchMoreMessages() + ), + }; + }, + + computed: { + form(): any { + return this.$refs.form; + } + }, + + watch: { + userAcct: 'fetch', + groupId: 'fetch', + }, + + mounted() { + this.fetch(); + if (this.$store.state.enableInfiniteScroll) { + this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element)); + } + }, + + beforeUnmount() { + this.connection.dispose(); + + document.removeEventListener('visibilitychange', this.onVisibilitychange); + + this.ilObserver.disconnect(); + }, + + methods: { + async fetch() { + this.fetching = true; + if (this.userAcct) { + const user = await os.api('users/show', Acct.parse(this.userAcct)); + this.user = user; + } else { + const group = await os.api('users/groups/show', { groupId: this.groupId }); + this.group = group; + } + + this.connection = markRaw(os.stream.useChannel('messaging', { + otherparty: this.user ? this.user.id : undefined, + group: this.group ? this.group.id : undefined, + })); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + this.connection.on('deleted', this.onDeleted); + this.connection.on('typers', typers => { + this.typers = typers.filter(u => u.id !== this.$i.id); + }); + + document.addEventListener('visibilitychange', this.onVisibilitychange); + + this.fetchMessages().then(() => { + this.scrollToBottom(); + + // ใใฃใจ่ฆใใฎไบคๅทฎๆค็ฅใ็บ็ซใใใชใใใใซfetchใฏ + // ในใฏใญใผใซใ็ตใใใพใงfalseใซใใฆใใ + // scrollendใฎใใใชใคใใณใใฏใชใใฎใงsetTimeoutใง + setTimeout(() => this.fetching = false, 300); + }); + }, + + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + + if (isFile || isDriveFile) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDrop(e): void { + // ใใกใคใซใ ใฃใใ + if (e.dataTransfer.files.length == 1) { + this.form.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + os.dialog({ + type: 'error', + text: this.$ts.onlyOneFileCanBeAttached + }); + return; + } + + //#region ใใฉใคใใฎใใกใคใซ + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.form.file = file; + } + //#endregion + }, + + fetchMessages() { + return new Promise((resolve, reject) => { + const max = this.existMoreMessages ? 20 : 10; + + os.api('messaging/messages', { + userId: this.user ? this.user.id : undefined, + groupId: this.group ? this.group.id : undefined, + limit: max + 1, + untilId: this.existMoreMessages ? this.messages[0].id : undefined + }).then(messages => { + if (messages.length == max + 1) { + this.existMoreMessages = true; + messages.pop(); + } else { + this.existMoreMessages = false; + } + + this.messages.unshift.apply(this.messages, messages.reverse()); + resolve(); + }); + }); + }, + + fetchMoreMessages() { + this.fetchingMoreMessages = true; + this.fetchMessages().then(() => { + this.fetchingMoreMessages = false; + }); + }, + + onMessage(message) { + sound.play('chat'); + + const _isBottom = isBottom(this.$el, 64); + + this.messages.push(message); + if (message.userId != this.$i.id && !document.hidden) { + this.connection.send('read', { + id: message.id + }); + } + + if (_isBottom) { + // Scroll to bottom + this.$nextTick(() => { + this.scrollToBottom(); + }); + } else if (message.userId != this.$i.id) { + // Notify + this.notifyNewMessage(); + } + }, + + onRead(x) { + if (this.user) { + if (!Array.isArray(x)) x = [x]; + for (const id of x) { + if (this.messages.some(x => x.id == id)) { + const exist = this.messages.map(x => x.id).indexOf(id); + this.messages[exist] = { + ...this.messages[exist], + isRead: true, + }; + } + } + } else if (this.group) { + for (const id of x.ids) { + if (this.messages.some(x => x.id == id)) { + const exist = this.messages.map(x => x.id).indexOf(id); + this.messages[exist] = { + ...this.messages[exist], + reads: [...this.messages[exist].reads, x.userId] + }; + } + } + } + }, + + onDeleted(id) { + const msg = this.messages.find(m => m.id === id); + if (msg) { + this.messages = this.messages.filter(m => m.id !== msg.id); + } + }, + + scrollToBottom() { + scroll(this.$el, { top: this.$el.offsetHeight }); + }, + + onIndicatorClick() { + this.showIndicator = false; + this.scrollToBottom(); + }, + + notifyNewMessage() { + this.showIndicator = true; + + onScrollBottom(this.$el, () => { + this.showIndicator = false; + }); + + if (this.timer) clearTimeout(this.timer); + + this.timer = setTimeout(() => { + this.showIndicator = false; + }, 4000); + }, + + onVisibilitychange() { + if (document.hidden) return; + for (const message of this.messages) { + if (message.userId !== this.$i.id && !message.isRead) { + this.connection.send('read', { + id: message.id + }); + } + } + }, + + menu(ev) { + const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`; + + os.popupMenu([this.inWindow ? undefined : { + text: this.$ts.openInWindow, + icon: 'fas fa-window-maximize', + action: () => { + os.pageWindow(path); + this.$router.back(); + }, + }, this.inWindow ? undefined : { + text: this.$ts.popout, + icon: 'fas fa-external-link-alt', + action: () => { + popout(path); + this.$router.back(); + }, + }], ev.currentTarget || ev.target); + } + } +}); + +export default Component; +</script> + +<style lang="scss" scoped> +.mk-messaging-room { + > .body { + > .empty { + width: 100%; + margin: 0; + padding: 16px 8px 8px 8px; + text-align: center; + font-size: 0.8em; + opacity: 0.5; + + i { + margin-right: 4px; + } + } + + > .no-history { + display: block; + margin: 0; + padding: 16px; + text-align: center; + font-size: 0.8em; + color: var(--messagingRoomInfo); + opacity: 0.5; + + i { + margin-right: 4px; + } + } + + > .more { + display: block; + margin: 16px auto; + padding: 0 12px; + line-height: 24px; + color: #fff; + background: rgba(#000, 0.3); + border-radius: 12px; + + &:hover { + background: rgba(#000, 0.4); + } + + &:active { + background: rgba(#000, 0.5); + } + + &.fetching { + cursor: wait; + } + + > i { + margin-right: 4px; + } + } + + > .messages { + > ::v-deep(*) { + margin-bottom: 16px; + } + } + } + + > footer { + width: 100%; + position: relative; + + > .new-message { + position: absolute; + top: -48px; + width: 100%; + padding: 8px 0; + text-align: center; + + > button { + display: inline-block; + margin: 0; + padding: 0 12px 0 30px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; + + > i { + position: absolute; + top: 0; + left: 10px; + line-height: 32px; + font-size: 16px; + } + } + } + + > .typers { + position: absolute; + bottom: 100%; + padding: 0 8px 0 8px; + font-size: 0.9em; + color: var(--fgTransparentWeak); + + > .users { + > .user + .user:before { + content: ", "; + font-weight: normal; + } + + > .user:last-of-type:after { + content: " "; + } + } + } + + > .form { + border-top: solid 0.5px var(--divider); + } + } +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.1s; +} + +.fade-enter-from, .fade-leave-to { + transition: opacity 0.5s; + opacity: 0; +} +</style> diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue new file mode 100644 index 0000000000..e9a3b6debc --- /dev/null +++ b/packages/client/src/pages/mfm-cheat-sheet.vue @@ -0,0 +1,365 @@ +<template> +<div class="mwysmxbg"> + <div class="_isolated">{{ $ts._mfm.intro }}</div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.mention }}</div> + <div class="content"> + <p>{{ $ts._mfm.mentionDescription }}</p> + <div class="preview"> + <Mfm :text="preview_mention"/> + <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.hashtag }}</div> + <div class="content"> + <p>{{ $ts._mfm.hashtagDescription }}</p> + <div class="preview"> + <Mfm :text="preview_hashtag"/> + <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.url }}</div> + <div class="content"> + <p>{{ $ts._mfm.urlDescription }}</p> + <div class="preview"> + <Mfm :text="preview_url"/> + <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.link }}</div> + <div class="content"> + <p>{{ $ts._mfm.linkDescription }}</p> + <div class="preview"> + <Mfm :text="preview_link"/> + <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.emoji }}</div> + <div class="content"> + <p>{{ $ts._mfm.emojiDescription }}</p> + <div class="preview"> + <Mfm :text="preview_emoji"/> + <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.bold }}</div> + <div class="content"> + <p>{{ $ts._mfm.boldDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bold"/> + <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.small }}</div> + <div class="content"> + <p>{{ $ts._mfm.smallDescription }}</p> + <div class="preview"> + <Mfm :text="preview_small"/> + <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.quote }}</div> + <div class="content"> + <p>{{ $ts._mfm.quoteDescription }}</p> + <div class="preview"> + <Mfm :text="preview_quote"/> + <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.center }}</div> + <div class="content"> + <p>{{ $ts._mfm.centerDescription }}</p> + <div class="preview"> + <Mfm :text="preview_center"/> + <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.inlineCode }}</div> + <div class="content"> + <p>{{ $ts._mfm.inlineCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineCode"/> + <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.blockCode }}</div> + <div class="content"> + <p>{{ $ts._mfm.blockCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blockCode"/> + <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.inlineMath }}</div> + <div class="content"> + <p>{{ $ts._mfm.inlineMathDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineMath"/> + <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.search }}</div> + <div class="content"> + <p>{{ $ts._mfm.searchDescription }}</p> + <div class="preview"> + <Mfm :text="preview_search"/> + <MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.flip }}</div> + <div class="content"> + <p>{{ $ts._mfm.flipDescription }}</p> + <div class="preview"> + <Mfm :text="preview_flip"/> + <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.font }}</div> + <div class="content"> + <p>{{ $ts._mfm.fontDescription }}</p> + <div class="preview"> + <Mfm :text="preview_font"/> + <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.x2 }}</div> + <div class="content"> + <p>{{ $ts._mfm.x2Description }}</p> + <div class="preview"> + <Mfm :text="preview_x2"/> + <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.x3 }}</div> + <div class="content"> + <p>{{ $ts._mfm.x3Description }}</p> + <div class="preview"> + <Mfm :text="preview_x3"/> + <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.x4 }}</div> + <div class="content"> + <p>{{ $ts._mfm.x4Description }}</p> + <div class="preview"> + <Mfm :text="preview_x4"/> + <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.blur }}</div> + <div class="content"> + <p>{{ $ts._mfm.blurDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blur"/> + <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.jelly }}</div> + <div class="content"> + <p>{{ $ts._mfm.jellyDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jelly"/> + <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.tada }}</div> + <div class="content"> + <p>{{ $ts._mfm.tadaDescription }}</p> + <div class="preview"> + <Mfm :text="preview_tada"/> + <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.jump }}</div> + <div class="content"> + <p>{{ $ts._mfm.jumpDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jump"/> + <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.bounce }}</div> + <div class="content"> + <p>{{ $ts._mfm.bounceDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bounce"/> + <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.spin }}</div> + <div class="content"> + <p>{{ $ts._mfm.spinDescription }}</p> + <div class="preview"> + <Mfm :text="preview_spin"/> + <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.shake }}</div> + <div class="content"> + <p>{{ $ts._mfm.shakeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_shake"/> + <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.twitch }}</div> + <div class="content"> + <p>{{ $ts._mfm.twitchDescription }}</p> + <div class="preview"> + <Mfm :text="preview_twitch"/> + <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.rainbow }}</div> + <div class="content"> + <p>{{ $ts._mfm.rainbowDescription }}</p> + <div class="preview"> + <Mfm :text="preview_rainbow"/> + <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.sparkle }}</div> + <div class="content"> + <p>{{ $ts._mfm.sparkleDescription }}</p> + <div class="preview"> + <Mfm :text="preview_sparkle"/> + <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkTextarea + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._mfm.cheatSheet, + icon: 'fas fa-question-circle', + }, + preview_mention: '@example', + preview_hashtag: '#test', + preview_url: `https://example.com`, + preview_link: `[${this.$ts._mfm.dummy}](https://example.com)`, + preview_emoji: this.$instance.emojis.length ? `:${this.$instance.emojis[0].name}:` : `:emojiname:`, + preview_bold: `**${this.$ts._mfm.dummy}**`, + preview_small: `<small>${this.$ts._mfm.dummy}</small>`, + preview_center: `<center>${this.$ts._mfm.dummy}</center>`, + preview_inlineCode: '`<: "Hello, world!"`', + preview_blockCode: '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```', + preview_inlineMath: '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)', + preview_quote: `> ${this.$ts._mfm.dummy}`, + preview_search: `${this.$ts._mfm.dummy} ๆค็ดข`, + preview_jelly: `$[jelly ๐ฎ]`, + preview_tada: `$[tada ๐ฎ]`, + preview_jump: `$[jump ๐ฎ]`, + preview_bounce: `$[bounce ๐ฎ]`, + preview_shake: `$[shake ๐ฎ]`, + preview_twitch: `$[twitch ๐ฎ]`, + preview_spin: `$[spin ๐ฎ] $[spin.left ๐ฎ] $[spin.alternate ๐ฎ]\n$[spin.x ๐ฎ] $[spin.x,left ๐ฎ] $[spin.x,alternate ๐ฎ]\n$[spin.y ๐ฎ] $[spin.y,left ๐ฎ] $[spin.y,alternate ๐ฎ]`, + preview_flip: `$[flip ${this.$ts._mfm.dummy}]\n$[flip.v ${this.$ts._mfm.dummy}]\n$[flip.h,v ${this.$ts._mfm.dummy}]`, + preview_font: `$[font.serif ${this.$ts._mfm.dummy}]\n$[font.monospace ${this.$ts._mfm.dummy}]\n$[font.cursive ${this.$ts._mfm.dummy}]\n$[font.fantasy ${this.$ts._mfm.dummy}]`, + preview_x2: `$[x2 ๐ฎ]`, + preview_x3: `$[x3 ๐ฎ]`, + preview_x4: `$[x4 ๐ฎ]`, + preview_blur: `$[blur ${this.$ts._mfm.dummy}]`, + preview_rainbow: `$[rainbow ๐ฎ]`, + preview_sparkle: `$[sparkle ๐ฎ]`, + } + }, +}); +</script> + +<style lang="scss" scoped> +.mwysmxbg { + background: var(--bg); + + > .section { + > .title { + position: sticky; + z-index: 1; + top: var(--stickyTop, 0px); + padding: 16px; + font-weight: bold; + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); + background-color: var(--X16); + } + + > .content { + > p { + margin: 0; + padding: 16px; + } + + > .preview { + border-top: solid 0.5px var(--divider); + padding: 16px; + } + } + } +} +</style> diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue new file mode 100644 index 0000000000..6430588c46 --- /dev/null +++ b/packages/client/src/pages/miauth.vue @@ -0,0 +1,100 @@ +<template> +<div v-if="$i"> + <div class="waiting _section" v-if="state == 'waiting'"> + <div class="_content"> + <MkLoading/> + </div> + </div> + <div class="denied _section" v-if="state == 'denied'"> + <div class="_content"> + <p>{{ $ts._auth.denied }}</p> + </div> + </div> + <div class="accepted _section" v-else-if="state == 'accepted'"> + <div class="_content"> + <p v-if="callback">{{ $ts._auth.callback }}<MkEllipsis/></p> + <p v-else>{{ $ts._auth.pleaseGoBack }}</p> + </div> + </div> + <div class="_section" v-else> + <div class="_title" v-if="name">{{ $t('_auth.shareAccess', { name: name }) }}</div> + <div class="_title" v-else>{{ $ts._auth.shareAccessAsk }}</div> + <div class="_content"> + <p>{{ $ts._auth.permissionAsk }}</p> + <ul> + <li v-for="p in permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </div> + <div class="_footer"> + <MkButton @click="deny" inline>{{ $ts.cancel }}</MkButton> + <MkButton @click="accept" inline primary>{{ $ts.accept }}</MkButton> + </div> + </div> +</div> +<div class="signin" v-else> + <MkSignin @login="onLogin"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkSignin from '@/components/signin.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + MkSignin, + MkButton, + }, + data() { + return { + state: null + }; + }, + computed: { + session(): string { + return this.$route.params.session; + }, + callback(): string { + return this.$route.query.callback; + }, + name(): string { + return this.$route.query.name; + }, + icon(): string { + return this.$route.query.icon; + }, + permission(): string[] { + return this.$route.query.permission ? this.$route.query.permission.split(',') : []; + }, + }, + methods: { + async accept() { + this.state = 'waiting'; + await os.api('miauth/gen-token', { + session: this.session, + name: this.name, + iconUrl: this.icon, + permission: this.permission, + }); + + this.state = 'accepted'; + if (this.callback) { + location.href = `${this.callback}?session=${this.session}`; + } + }, + deny() { + this.state = 'denied'; + }, + onLogin(res) { + login(res.i); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue new file mode 100644 index 0000000000..173807475a --- /dev/null +++ b/packages/client/src/pages/my-antennas/create.vue @@ -0,0 +1,51 @@ +<template> +<div class="geegznzt"> + <XAntenna :antenna="draft" @created="onAntennaCreated"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import XAntenna from './editor.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + XAntenna, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.manageAntennas, + icon: 'fas fa-satellite', + }, + draft: { + name: '', + src: 'all', + userListId: null, + userGroupId: null, + users: [], + keywords: [], + excludeKeywords: [], + withReplies: false, + caseSensitive: false, + withFile: false, + notify: false + }, + }; + }, + + methods: { + onAntennaCreated() { + this.$router.push('/my/antennas'); + }, + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/my-antennas/edit.vue b/packages/client/src/pages/my-antennas/edit.vue new file mode 100644 index 0000000000..04928c81a3 --- /dev/null +++ b/packages/client/src/pages/my-antennas/edit.vue @@ -0,0 +1,56 @@ +<template> +<div class=""> + <XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import XAntenna from './editor.vue'; +import * as symbols from '@/symbols'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + XAntenna, + }, + + props: { + antennaId: { + type: String, + required: true, + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.manageAntennas, + icon: 'fas fa-satellite', + }, + antenna: null, + }; + }, + + watch: { + antennaId: { + async handler() { + this.antenna = await os.api('antennas/show', { antennaId: this.antennaId }); + }, + immediate: true, + } + }, + + methods: { + onAntennaUpdated() { + this.$router.push('/my/antennas'); + }, + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/my-antennas/editor.vue b/packages/client/src/pages/my-antennas/editor.vue new file mode 100644 index 0000000000..5ad3d50486 --- /dev/null +++ b/packages/client/src/pages/my-antennas/editor.vue @@ -0,0 +1,190 @@ +<template> +<div class="shaynizk"> + <div class="form"> + <MkInput v-model="name" class="_formBlock"> + <template #label>{{ $ts.name }}</template> + </MkInput> + <MkSelect v-model="src" class="_formBlock"> + <template #label>{{ $ts.antennaSource }}</template> + <option value="all">{{ $ts._antennaSources.all }}</option> + <option value="home">{{ $ts._antennaSources.homeTimeline }}</option> + <option value="users">{{ $ts._antennaSources.users }}</option> + <option value="list">{{ $ts._antennaSources.userList }}</option> + <option value="group">{{ $ts._antennaSources.userGroup }}</option> + </MkSelect> + <MkSelect v-model="userListId" v-if="src === 'list'" class="_formBlock"> + <template #label>{{ $ts.userList }}</template> + <option v-for="list in userLists" :value="list.id" :key="list.id">{{ list.name }}</option> + </MkSelect> + <MkSelect v-model="userGroupId" v-else-if="src === 'group'" class="_formBlock"> + <template #label>{{ $ts.userGroup }}</template> + <option v-for="group in userGroups" :value="group.id" :key="group.id">{{ group.name }}</option> + </MkSelect> + <MkTextarea v-model="users" v-else-if="src === 'users'" class="_formBlock"> + <template #label>{{ $ts.users }}</template> + <template #caption>{{ $ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ $ts.addUser }}</button></template> + </MkTextarea> + <MkSwitch v-model="withReplies" class="_formBlock">{{ $ts.withReplies }}</MkSwitch> + <MkTextarea v-model="keywords" class="_formBlock"> + <template #label>{{ $ts.antennaKeywords }}</template> + <template #caption>{{ $ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkTextarea v-model="excludeKeywords" class="_formBlock"> + <template #label>{{ $ts.antennaExcludeKeywords }}</template> + <template #caption>{{ $ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkSwitch v-model="caseSensitive" class="_formBlock">{{ $ts.caseSensitive }}</MkSwitch> + <MkSwitch v-model="withFile" class="_formBlock">{{ $ts.withFileAntenna }}</MkSwitch> + <MkSwitch v-model="notify" class="_formBlock">{{ $ts.notifyAntenna }}</MkSwitch> + </div> + <div class="actions"> + <MkButton inline @click="saveAntenna()" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton inline @click="deleteAntenna()" v-if="antenna.id != null" danger><i class="fas fa-trash"></i> {{ $ts.delete }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, MkInput, MkTextarea, MkSelect, MkSwitch + }, + + props: { + antenna: { + type: Object, + required: true + } + }, + + data() { + return { + name: '', + src: '', + userListId: null, + userGroupId: null, + users: '', + keywords: '', + excludeKeywords: '', + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + userLists: null, + userGroups: null, + }; + }, + + watch: { + async src() { + if (this.src === 'list' && this.userLists === null) { + this.userLists = await os.api('users/lists/list'); + } + + if (this.src === 'group' && this.userGroups === null) { + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); + + this.userGroups = [...groups1, ...groups2]; + } + } + }, + + created() { + this.name = this.antenna.name; + this.src = this.antenna.src; + this.userListId = this.antenna.userListId; + this.userGroupId = this.antenna.userGroupId; + this.users = this.antenna.users.join('\n'); + this.keywords = this.antenna.keywords.map(x => x.join(' ')).join('\n'); + this.excludeKeywords = this.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'); + this.caseSensitive = this.antenna.caseSensitive; + this.withReplies = this.antenna.withReplies; + this.withFile = this.antenna.withFile; + this.notify = this.antenna.notify; + }, + + methods: { + async saveAntenna() { + if (this.antenna.id == null) { + await os.apiWithDialog('antennas/create', { + name: this.name, + src: this.src, + userListId: this.userListId, + userGroupId: this.userGroupId, + withReplies: this.withReplies, + withFile: this.withFile, + notify: this.notify, + caseSensitive: this.caseSensitive, + users: this.users.trim().split('\n').map(x => x.trim()), + keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')), + excludeKeywords: this.excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')), + }); + this.$emit('created'); + } else { + await os.apiWithDialog('antennas/update', { + antennaId: this.antenna.id, + name: this.name, + src: this.src, + userListId: this.userListId, + userGroupId: this.userGroupId, + withReplies: this.withReplies, + withFile: this.withFile, + notify: this.notify, + caseSensitive: this.caseSensitive, + users: this.users.trim().split('\n').map(x => x.trim()), + keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')), + excludeKeywords: this.excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')), + }); + this.$emit('updated'); + } + }, + + async deleteAntenna() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.antenna.name }), + showCancelButton: true + }); + if (canceled) return; + + await os.api('antennas/delete', { + antennaId: this.antenna.id, + }); + + os.success(); + this.$emit('deleted'); + }, + + addUser() { + os.selectUser().then(user => { + this.users = this.users.trim(); + this.users += '\n@' + Acct.toString(user); + this.users = this.users.trim(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.shaynizk { + > .form { + padding: 32px; + } + + > .actions { + padding: 24px 32px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue new file mode 100644 index 0000000000..029f1949d7 --- /dev/null +++ b/packages/client/src/pages/my-antennas/index.vue @@ -0,0 +1,71 @@ +<template> +<div class="ieepwinx _section"> + <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> + + <div class="_content"> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <MkA class="ljoevbzj" v-for="antenna in items" :key="antenna.id" :to="`/my/antennas/${antenna.id}`"> + <div class="name">{{ antenna.name }}</div> + </MkA> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.manageAntennas, + icon: 'fas fa-satellite', + action: { + icon: 'fas fa-plus', + handler: this.create + } + }, + pagination: { + endpoint: 'antennas/list', + limit: 10, + }, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.ieepwinx { + padding: 16px; + + > .add { + margin: 0 auto 16px auto; + } + + .ljoevbzj { + display: block; + padding: 16px; + margin-bottom: 8px; + border: solid 1px var(--divider); + border-radius: 6px; + + &:hover { + border: solid 1px var(--accent); + text-decoration: none; + } + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue new file mode 100644 index 0000000000..cbcdb85fa5 --- /dev/null +++ b/packages/client/src/pages/my-clips/index.vue @@ -0,0 +1,104 @@ +<template> +<div class="_section qtcaoidl"> + <MkButton @click="create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> + + <div class="_content"> + <MkPagination :pagination="pagination" #default="{items}" ref="list" class="list"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + </MkA> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.clip, + icon: 'fas fa-paperclip', + action: { + icon: 'fas fa-plus', + handler: this.create + } + }, + pagination: { + endpoint: 'clips/list', + limit: 10, + }, + draft: null, + }; + }, + + methods: { + async create() { + const { canceled, result } = await os.form(this.$ts.createNewClip, { + name: { + type: 'string', + label: this.$ts.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: false + } + }); + if (canceled) return; + + os.apiWithDialog('clips/create', result); + }, + + onClipCreated() { + this.$refs.list.reload(); + this.draft = null; + }, + + onClipDeleted() { + this.$refs.list.reload(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.qtcaoidl { + > .add { + margin: 0 auto 16px auto; + } + + > ._content { + > .list { + > .item { + display: block; + padding: 16px; + + > .description { + margin-top: 8px; + padding-top: 8px; + border-top: solid 0.5px var(--divider); + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/my-groups/group.vue b/packages/client/src/pages/my-groups/group.vue new file mode 100644 index 0000000000..9548c374d2 --- /dev/null +++ b/packages/client/src/pages/my-groups/group.vue @@ -0,0 +1,184 @@ +<template> +<div class="mk-group-page"> + <transition name="zoom" mode="out-in"> + <div v-if="group" class="_section"> + <div class="_content"> + <MkButton inline @click="invite()">{{ $ts.invite }}</MkButton> + <MkButton inline @click="renameGroup()">{{ $ts.rename }}</MkButton> + <MkButton inline @click="transfer()">{{ $ts.transfer }}</MkButton> + <MkButton inline @click="deleteGroup()">{{ $ts.delete }}</MkButton> + </div> + </div> + </transition> + + <transition name="zoom" mode="out-in"> + <div v-if="group" class="_section members _gap"> + <div class="_title">{{ $ts.members }}</div> + <div class="_content"> + <div class="users"> + <div class="user _panel" v-for="user in users" :key="user.id"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + <div class="action"> + <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button> + </div> + </div> + </div> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + groupId: { + type: String, + required: true, + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.group ? { + title: this.group.name, + icon: 'fas fa-users', + } : null), + group: null, + users: [], + }; + }, + + watch: { + groupId: 'fetch', + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + Progress.start(); + os.api('users/groups/show', { + groupId: this.groupId + }).then(group => { + this.group = group; + os.api('users/show', { + userIds: this.group.userIds + }).then(users => { + this.users = users; + Progress.done(); + }); + }); + }, + + invite() { + os.selectUser().then(user => { + os.apiWithDialog('users/groups/invite', { + groupId: this.group.id, + userId: user.id + }); + }); + }, + + removeUser(user) { + os.api('users/groups/pull', { + groupId: this.group.id, + userId: user.id + }).then(() => { + this.users = this.users.filter(x => x.id !== user.id); + }); + }, + + async renameGroup() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.groupName, + input: { + default: this.group.name + } + }); + if (canceled) return; + + await os.api('users/groups/update', { + groupId: this.group.id, + name: name + }); + + this.group.name = name; + }, + + transfer() { + os.selectUser().then(user => { + os.apiWithDialog('users/groups/transfer', { + groupId: this.group.id, + userId: user.id + }); + }); + }, + + async deleteGroup() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.group.name }), + showCancelButton: true + }); + if (canceled) return; + + await os.apiWithDialog('users/groups/delete', { + groupId: this.group.id + }); + this.$router.push('/my/groups'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-group-page { + > .members { + > ._content { + > .users { + > .user { + display: flex; + align-items: center; + padding: 16px; + + > .avatar { + width: 50px; + height: 50px; + } + + > .body { + flex: 1; + padding: 8px; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/my-groups/index.vue b/packages/client/src/pages/my-groups/index.vue new file mode 100644 index 0000000000..77e7d6088e --- /dev/null +++ b/packages/client/src/pages/my-groups/index.vue @@ -0,0 +1,121 @@ +<template> +<div class=""> + <div class="_section" style="padding: 0;"> + <MkTab v-model="tab"> + <option value="owned">{{ $ts.ownedGroups }}</option> + <option value="joined">{{ $ts.joinedGroups }}</option> + <option value="invites"><i class="fas fa-envelope-open-text"></i> {{ $ts.invites }}</option> + </MkTab> + </div> + + <div class="_section"> + <div class="_content" v-if="tab === 'owned'"> + <MkButton @click="create" primary style="margin: 0 auto var(--margin) auto;"><i class="fas fa-plus"></i> {{ $ts.createGroup }}</MkButton> + + <MkPagination :pagination="ownedPagination" #default="{items}" ref="owned"> + <div class="_card" v-for="group in items" :key="group.id"> + <div class="_title"><MkA :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</MkA></div> + <div class="_content"><MkAvatars :user-ids="group.userIds"/></div> + </div> + </MkPagination> + </div> + + <div class="_content" v-else-if="tab === 'joined'"> + <MkPagination :pagination="joinedPagination" #default="{items}" ref="joined"> + <div class="_card" v-for="group in items" :key="group.id"> + <div class="_title">{{ group.name }}</div> + <div class="_content"><MkAvatars :user-ids="group.userIds"/></div> + </div> + </MkPagination> + </div> + + <div class="_content" v-else-if="tab === 'invites'"> + <MkPagination :pagination="invitationPagination" #default="{items}" ref="invitations"> + <div class="_card" v-for="invitation in items" :key="invitation.id"> + <div class="_title">{{ invitation.group.name }}</div> + <div class="_content"><MkAvatars :user-ids="invitation.group.userIds"/></div> + <div class="_footer"> + <MkButton @click="acceptInvite(invitation)" primary inline><i class="fas fa-check"></i> {{ $ts.accept }}</MkButton> + <MkButton @click="rejectInvite(invitation)" primary inline><i class="fas fa-ban"></i> {{ $ts.reject }}</MkButton> + </div> + </div> + </MkPagination> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkAvatars from '@/components/avatars.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + MkContainer, + MkTab, + MkAvatars, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.groups, + icon: 'fas fa-users' + }, + tab: 'owned', + ownedPagination: { + endpoint: 'users/groups/owned', + limit: 10, + }, + joinedPagination: { + endpoint: 'users/groups/joined', + limit: 10, + }, + invitationPagination: { + endpoint: 'i/user-group-invites', + limit: 10, + }, + }; + }, + + methods: { + async create() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.groupName, + input: true + }); + if (canceled) return; + await os.api('users/groups/create', { name: name }); + this.$refs.owned.reload(); + os.success(); + }, + acceptInvite(invitation) { + os.api('users/groups/invitations/accept', { + invitationId: invitation.id + }).then(() => { + os.success(); + this.$refs.invitations.reload(); + this.$refs.joined.reload(); + }); + }, + rejectInvite(invitation) { + os.api('users/groups/invitations/reject', { + invitationId: invitation.id + }).then(() => { + this.$refs.invitations.reload(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue new file mode 100644 index 0000000000..adb59db665 --- /dev/null +++ b/packages/client/src/pages/my-lists/index.vue @@ -0,0 +1,88 @@ +<template> +<div class="qkcjvfiv"> + <MkButton @click="create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.createList }}</MkButton> + + <MkPagination :pagination="pagination" #default="{items}" class="lists _content" ref="list"> + <MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`"> + <div class="name">{{ list.name }}</div> + <MkAvatars :user-ids="list.userIds"/> + </MkA> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkAvatars from '@/components/avatars.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + MkAvatars, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.manageLists, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', + action: { + icon: 'fas fa-plus', + handler: this.create + }, + }, + pagination: { + endpoint: 'users/lists/list', + limit: 10, + }, + }; + }, + + methods: { + async create() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.enterListName, + input: true + }); + if (canceled) return; + await os.api('users/lists/create', { name: name }); + this.$refs.list.reload(); + os.success(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.qkcjvfiv { + padding: 16px; + + > .add { + margin: 0 auto var(--margin) auto; + } + + > .lists { + > .list { + display: block; + padding: 16px; + border: solid 1px var(--divider); + border-radius: 6px; + + &:hover { + border: solid 1px var(--accent); + text-decoration: none; + } + + > .name { + margin-bottom: 4px; + } + } + } +} +</style> diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue new file mode 100644 index 0000000000..f2a02cadc9 --- /dev/null +++ b/packages/client/src/pages/my-lists/list.vue @@ -0,0 +1,170 @@ +<template> +<div class="mk-list-page"> + <transition name="zoom" mode="out-in"> + <div v-if="list" class="_section"> + <div class="_content"> + <MkButton inline @click="addUser()">{{ $ts.addUser }}</MkButton> + <MkButton inline @click="renameList()">{{ $ts.rename }}</MkButton> + <MkButton inline @click="deleteList()">{{ $ts.delete }}</MkButton> + </div> + </div> + </transition> + + <transition name="zoom" mode="out-in"> + <div v-if="list" class="_section members _gap"> + <div class="_title">{{ $ts.members }}</div> + <div class="_content"> + <div class="users"> + <div class="user _panel" v-for="user in users" :key="user.id"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + <div class="action"> + <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button> + </div> + </div> + </div> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.list ? { + title: this.list.name, + icon: 'fas fa-list-ul', + } : null), + list: null, + users: [], + }; + }, + + watch: { + $route: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + Progress.start(); + os.api('users/lists/show', { + listId: this.$route.params.list + }).then(list => { + this.list = list; + os.api('users/show', { + userIds: this.list.userIds + }).then(users => { + this.users = users; + Progress.done(); + }); + }); + }, + + addUser() { + os.selectUser().then(user => { + os.apiWithDialog('users/lists/push', { + listId: this.list.id, + userId: user.id + }).then(() => { + this.users.push(user); + }); + }); + }, + + removeUser(user) { + os.api('users/lists/pull', { + listId: this.list.id, + userId: user.id + }).then(() => { + this.users = this.users.filter(x => x.id !== user.id); + }); + }, + + async renameList() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.enterListName, + input: { + default: this.list.name + } + }); + if (canceled) return; + + await os.api('users/lists/update', { + listId: this.list.id, + name: name + }); + + this.list.name = name; + }, + + async deleteList() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.list.name }), + showCancelButton: true + }); + if (canceled) return; + + await os.api('users/lists/delete', { + listId: this.list.id + }); + os.success(); + this.$router.push('/my/lists'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-list-page { + > .members { + > ._content { + > .users { + > .user { + display: flex; + align-items: center; + padding: 16px; + + > .avatar { + width: 50px; + height: 50px; + } + + > .body { + flex: 1; + padding: 8px; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue new file mode 100644 index 0000000000..92d3f399f7 --- /dev/null +++ b/packages/client/src/pages/not-found.vue @@ -0,0 +1,25 @@ +<template> +<div class="ipledcug"> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/> + <div>{{ $ts.notFoundDescription }}</div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.notFound, + icon: 'fas fa-exclamation-triangle' + }, + } + }, +}); +</script> diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue new file mode 100644 index 0000000000..ecd391dfbf --- /dev/null +++ b/packages/client/src/pages/note.vue @@ -0,0 +1,209 @@ +<template> +<MkSpacer :content-max="800"> + <div class="fcuexfpr"> + <transition name="fade" mode="out-in"> + <div v-if="note" class="note"> + <div class="_gap" v-if="showNext"> + <XNotes class="_content" :pagination="next" :no-gap="true"/> + </div> + + <div class="main _gap"> + <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton> + <div class="note _gap"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" class="_isolated"/> + <XNoteDetailed v-model:note="note" :key="note.id" class="_isolated note"/> + </div> + <div class="_content clips _gap" v-if="clips && clips.length > 0"> + <div class="title">{{ $ts.clip }}</div> + <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + <div class="user"> + <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/> + </div> + </MkA> + </div> + <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton> + </div> + + <div class="_gap" v-if="showPrev"> + <XNotes class="_content" :pagination="prev" :no-gap="true"/> + </div> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </div> +</MkSpacer> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XNote from '@/components/note.vue'; +import XNoteDetailed from '@/components/note-detailed.vue'; +import XNotes from '@/components/notes.vue'; +import MkRemoteCaution from '@/components/remote-caution.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNote, + XNoteDetailed, + XNotes, + MkRemoteCaution, + MkButton, + }, + props: { + noteId: { + type: String, + required: true + } + }, + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.note ? { + title: this.$ts.note, + subtitle: new Date(this.note.createdAt).toLocaleString(), + avatar: this.note.user, + path: `/notes/${this.note.id}`, + share: { + title: this.$t('noteOf', { user: this.note.user.name }), + text: this.note.text, + }, + bg: 'var(--bg)', + } : null), + note: null, + clips: null, + hasPrev: false, + hasNext: false, + showPrev: false, + showNext: false, + error: null, + prev: { + endpoint: 'users/notes', + limit: 10, + params: init => ({ + userId: this.note.userId, + untilId: this.note.id, + }) + }, + next: { + reversed: true, + endpoint: 'users/notes', + limit: 10, + params: init => ({ + userId: this.note.userId, + sinceId: this.note.id, + }) + }, + }; + }, + watch: { + noteId: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + this.note = null; + os.api('notes/show', { + noteId: this.noteId + }).then(note => { + this.note = note; + Promise.all([ + os.api('notes/clips', { + noteId: note.id, + }), + os.api('users/notes', { + userId: note.userId, + untilId: note.id, + limit: 1, + }), + os.api('users/notes', { + userId: note.userId, + sinceId: note.id, + limit: 1, + }), + ]).then(([clips, prev, next]) => { + this.clips = clips; + this.hasPrev = prev.length !== 0; + this.hasNext = next.length !== 0; + }); + }).catch(e => { + this.error = e; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.fcuexfpr { + background: var(--bg); + + > .note { + > .main { + > .load { + min-width: 0; + margin: 0 auto; + border-radius: 999px; + + &.next { + margin-bottom: var(--margin); + } + + &.prev { + margin-top: var(--margin); + } + } + + > .note { + > .note { + border-radius: var(--radius); + background: var(--panel); + } + } + + > .clips { + > .title { + font-weight: bold; + padding: 12px; + } + + > .item { + display: block; + padding: 16px; + + > .description { + padding: 8px 0; + } + + > .user { + $height: 32px; + padding-top: 16px; + border-top: solid 0.5px var(--divider); + line-height: $height; + + > .avatar { + width: $height; + height: $height; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue new file mode 100644 index 0000000000..f8e610a719 --- /dev/null +++ b/packages/client/src/pages/notifications.vue @@ -0,0 +1,88 @@ +<template> +<MkSpacer :content-max="800"> + <div class="clupoqwt"> + <XNotifications class="notifications" @before="before" @after="after" :include-types="includeTypes" :unread-only="tab === 'unread'"/> + </div> +</MkSpacer> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotifications from '@/components/notifications.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { notificationTypes } from 'misskey-js'; + +export default defineComponent({ + components: { + XNotifications + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.notifications, + icon: 'fas fa-bell', + bg: 'var(--bg)', + actions: [{ + text: this.$ts.filter, + icon: 'fas fa-filter', + highlighted: this.includeTypes != null, + handler: this.setFilter, + }, { + text: this.$ts.markAllAsRead, + icon: 'fas fa-check', + handler: () => { + os.apiWithDialog('notifications/mark-all-as-read'); + }, + }], + tabs: [{ + active: this.tab === 'all', + title: this.$ts.all, + onClick: () => { this.tab = 'all'; }, + }, { + active: this.tab === 'unread', + title: this.$ts.unread, + onClick: () => { this.tab = 'unread'; }, + },] + })), + tab: 'all', + includeTypes: null, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + }, + + setFilter(ev) { + const typeItems = notificationTypes.map(t => ({ + text: this.$t(`_notification._types.${t}`), + active: this.includeTypes && this.includeTypes.includes(t), + action: () => { + this.includeTypes = [t]; + } + })); + const items = this.includeTypes != null ? [{ + icon: 'fas fa-times', + text: this.$ts.clear, + action: () => { + this.includeTypes = null; + } + }, null, ...typeItems] : typeItems; + os.popupMenu(items, ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.clupoqwt { +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue new file mode 100644 index 0000000000..a25a892eaa --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue @@ -0,0 +1,84 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.button }}</template> + + <section class="xfhsjczc"> + <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._button.text }}</template></MkInput> + <MkSwitch v-model="value.primary"><span>{{ $ts._pages.blocks._button.colored }}</span></MkSwitch> + <MkSelect v-model="value.action"> + <template #label>{{ $ts._pages.blocks._button.action }}</template> + <option value="dialog">{{ $ts._pages.blocks._button._action.dialog }}</option> + <option value="resetRandom">{{ $ts._pages.blocks._button._action.resetRandom }}</option> + <option value="pushEvent">{{ $ts._pages.blocks._button._action.pushEvent }}</option> + <option value="callAiScript">{{ $ts._pages.blocks._button._action.callAiScript }}</option> + </MkSelect> + <template v-if="value.action === 'dialog'"> + <MkInput v-model="value.content"><template #label>{{ $ts._pages.blocks._button._action._dialog.content }}</template></MkInput> + </template> + <template v-else-if="value.action === 'pushEvent'"> + <MkInput v-model="value.event"><template #label>{{ $ts._pages.blocks._button._action._pushEvent.event }}</template></MkInput> + <MkInput v-model="value.message"><template #label>{{ $ts._pages.blocks._button._action._pushEvent.message }}</template></MkInput> + <MkSelect v-model="value.var"> + <template #label>{{ $ts._pages.blocks._button._action._pushEvent.variable }}</template> + <option :value="null">{{ $t('_pages.blocks._button._action._pushEvent.no-variable') }}</option> + <option v-for="v in hpml.getVarsByType()" :value="v.name">{{ v.name }}</option> + <optgroup :label="$ts._pages.script.pageVariables"> + <option v-for="v in hpml.getPageVarsByType()" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$ts._pages.script.enviromentVariables"> + <option v-for="v in hpml.getEnvVarsByType()" :value="v">{{ v }}</option> + </optgroup> + </MkSelect> + </template> + <template v-else-if="value.action === 'callAiScript'"> + <MkInput v-model="value.fn"><template #label>{{ $ts._pages.blocks._button._action._callAiScript.functionName }}</template></MkInput> + </template> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkSelect, MkInput, MkSwitch + }, + + props: { + value: { + required: true + }, + hpml: { + required: true, + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.text == null) this.value.text = ''; + if (this.value.action == null) this.value.action = 'dialog'; + if (this.value.content == null) this.value.content = null; + if (this.value.event == null) this.value.event = null; + if (this.value.message == null) this.value.message = null; + if (this.value.primary == null) this.value.primary = false; + if (this.value.var == null) this.value.var = null; + if (this.value.fn == null) this.value.fn = null; + }, +}); +</script> + +<style lang="scss" scoped> +.xfhsjczc { + padding: 0 16px 0 16px; +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue new file mode 100644 index 0000000000..5d009561e2 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue @@ -0,0 +1,50 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-paint-brush"></i> {{ $ts._pages.blocks.canvas }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="value.name"> + <template #prefix><i class="fas fa-magic"></i></template> + <template #label>{{ $ts._pages.blocks._canvas.id }}</template> + </MkInput> + <MkInput v-model="value.width" type="number"> + <template #label>{{ $ts._pages.blocks._canvas.width }}</template> + <template #suffix>px</template> + </MkInput> + <MkInput v-model="value.height" type="number"> + <template #label>{{ $ts._pages.blocks._canvas.height }}</template> + <template #suffix>px</template> + </MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + if (this.value.width == null) this.value.width = 300; + if (this.value.height == null) this.value.height = 200; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue new file mode 100644 index 0000000000..3704c64250 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue @@ -0,0 +1,46 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.counter }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="value.name"> + <template #prefix><i class="fas fa-magic"></i></template> + <template #label>{{ $ts._pages.blocks._counter.name }}</template> + </MkInput> + <MkInput v-model="value.text"> + <template #label>{{ $ts._pages.blocks._counter.text }}</template> + </MkInput> + <MkInput v-model="value.inc" type="number"> + <template #label>{{ $ts._pages.blocks._counter.inc }}</template> + </MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.if.vue b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue new file mode 100644 index 0000000000..f76d59abe3 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue @@ -0,0 +1,84 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-question"></i> {{ $ts._pages.blocks.if }}</template> + <template #func> + <button @click="add()" class="_button"> + <i class="fas fa-plus"></i> + </button> + </template> + + <section class="romcojzs"> + <MkSelect v-model="value.var"> + <template #label>{{ $ts._pages.blocks._if.variable }}</template> + <option v-for="v in hpml.getVarsByType('boolean')" :value="v.name">{{ v.name }}</option> + <optgroup :label="$ts._pages.script.pageVariables"> + <option v-for="v in hpml.getPageVarsByType('boolean')" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$ts._pages.script.enviromentVariables"> + <option v-for="v in hpml.getEnvVarsByType('boolean')" :value="v">{{ v }}</option> + </optgroup> + </MkSelect> + + <XBlocks class="children" v-model="value.children" :hpml="hpml"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XContainer from '../page-editor.container.vue'; +import MkSelect from '@/components/form/select.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkSelect, + XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')), + }, + + inject: ['getPageBlockList'], + + props: { + value: { + required: true + }, + hpml: { + required: true, + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.children == null) this.value.children = []; + if (this.value.var === undefined) this.value.var = null; + }, + + methods: { + async add() { + const { canceled, result: type } = await os.dialog({ + type: null, + title: this.$ts._pages.chooseBlock, + select: { + groupedItems: this.getPageBlockList() + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid(); + this.value.children.push({ id, type }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.romcojzs { + padding: 0 16px 16px 16px; +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue new file mode 100644 index 0000000000..396c83f512 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue @@ -0,0 +1,72 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-image"></i> {{ $ts._pages.blocks.image }}</template> + <template #func> + <button @click="choose()"> + <i class="fas fa-folder-open"></i> + </button> + </template> + + <section class="oyyftmcf"> + <MkDriveFileThumbnail class="preview" v-if="file" :file="file" fit="contain" @click="choose()"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkDriveFileThumbnail + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + file: null, + }; + }, + + created() { + if (this.value.fileId === undefined) this.value.fileId = null; + }, + + mounted() { + if (this.value.fileId == null) { + this.choose(); + } else { + os.api('drive/files/show', { + fileId: this.value.fileId + }).then(file => { + this.file = file; + }); + } + }, + + methods: { + async choose() { + os.selectDriveFile(false).then(file => { + this.file = file; + this.value.fileId = file.id; + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.oyyftmcf { + > .preview { + height: 150px; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue new file mode 100644 index 0000000000..263b60d3e0 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue @@ -0,0 +1,65 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-sticky-note"></i> {{ $ts._pages.blocks.note }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="id"> + <template #label>{{ $ts._pages.blocks._note.id }}</template> + <template #caption>{{ $ts._pages.blocks._note.idDescription }}</template> + </MkInput> + <MkSwitch v-model="value.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch> + + <XNote v-if="note && !value.detailed" v-model:note="note" :key="note.id + ':normal'" style="margin-bottom: 16px;"/> + <XNoteDetailed v-if="note && value.detailed" v-model:note="note" :key="note.id + ':detail'" style="margin-bottom: 16px;"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import XNote from '@/components/note.vue'; +import XNoteDetailed from '@/components/note-detailed.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput, MkSwitch, XNote, XNoteDetailed, + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + id: this.value.note, + note: null, + }; + }, + + watch: { + id: { + async handler() { + if (this.id && (this.id.startsWith('http://') || this.id.startsWith('https://'))) { + this.value.note = this.id.endsWith('/') ? this.id.substr(0, this.id.length - 1).split('/').pop() : this.id.split('/').pop(); + } else { + this.value.note = this.id; + } + + this.note = await os.api('notes/show', { noteId: this.value.note }); + }, + immediate: true + }, + }, + + created() { + if (this.value.note == null) this.value.note = null; + if (this.value.detailed == null) this.value.detailed = false; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue new file mode 100644 index 0000000000..3a2f4a762b --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue @@ -0,0 +1,46 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.numberInput }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="value.name"> + <template #prefix><i class="fas fa-magic"></i></template> + <template #label>{{ $ts._pages.blocks._numberInput.name }}</template> + </MkInput> + <MkInput v-model="value.text"> + <template #label>{{ $ts._pages.blocks._numberInput.text }}</template> + </MkInput> + <MkInput v-model="value.default" type="number"> + <template #label>{{ $ts._pages.blocks._numberInput.default }}</template> + </MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.post.vue b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue new file mode 100644 index 0000000000..780786144e --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue @@ -0,0 +1,43 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-paper-plane"></i> {{ $ts._pages.blocks.post }}</template> + + <section style="padding: 16px;"> + <MkTextarea v-model="value.text"><template #label>{{ $ts._pages.blocks._post.text }}</template></MkTextarea> + <MkSwitch v-model="value.attachCanvasImage"><span>{{ $ts._pages.blocks._post.attachCanvasImage }}</span></MkSwitch> + <MkInput v-if="value.attachCanvasImage" v-model="value.canvasId"><template #label>{{ $ts._pages.blocks._post.canvasId }}</template></MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkTextarea, MkInput, MkSwitch + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.text == null) this.value.text = ''; + if (this.value.attachCanvasImage == null) this.value.attachCanvasImage = false; + if (this.value.canvasId == null) this.value.canvasId = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue new file mode 100644 index 0000000000..f01a47c54a --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue @@ -0,0 +1,50 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.radioButton }}</template> + + <section style="padding: 0 16px 16px 16px;"> + <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._radioButton.name }}</template></MkInput> + <MkInput v-model="value.title"><template #label>{{ $ts._pages.blocks._radioButton.title }}</template></MkInput> + <MkTextarea v-model="values"><template #label>{{ $ts._pages.blocks._radioButton.values }}</template></MkTextarea> + <MkInput v-model="value.default"><template #label>{{ $ts._pages.blocks._radioButton.default }}</template></MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkTextarea, MkInput + }, + props: { + value: { + required: true + }, + }, + data() { + return { + values: '', + }; + }, + watch: { + values: { + handler() { + this.value.values = this.values.split('\n'); + }, + deep: true + } + }, + created() { + if (this.value.name == null) this.value.name = ''; + if (this.value.title == null) this.value.title = ''; + if (this.value.values == null) this.value.values = []; + this.values = this.value.values.join('\n'); + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue new file mode 100644 index 0000000000..16e32d8400 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue @@ -0,0 +1,96 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-sticky-note"></i> {{ value.title }}</template> + <template #func> + <button @click="rename()" class="_button"> + <i class="fas fa-pencil-alt"></i> + </button> + <button @click="add()" class="_button"> + <i class="fas fa-plus"></i> + </button> + </template> + + <section class="ilrvjyvi"> + <XBlocks class="children" v-model="value.children" :hpml="hpml"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, + XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')), + }, + + inject: ['getPageBlockList'], + + props: { + value: { + required: true + }, + hpml: { + required: true, + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.title == null) this.value.title = null; + if (this.value.children == null) this.value.children = []; + }, + + mounted() { + if (this.value.title == null) { + this.rename(); + } + }, + + methods: { + async rename() { + const { canceled, result: title } = await os.dialog({ + title: 'Enter title', + input: { + type: 'text', + default: this.value.title + }, + showCancelButton: true + }); + if (canceled) return; + this.value.title = title; + }, + + async add() { + const { canceled, result: type } = await os.dialog({ + type: null, + title: this.$ts._pages.chooseBlock, + select: { + groupedItems: this.getPageBlockList() + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid(); + this.value.children.push({ id, type }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.ilrvjyvi { + > .children { + padding: 16px; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue new file mode 100644 index 0000000000..e72f7b44d0 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue @@ -0,0 +1,46 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.switch }}</template> + + <section class="kjuadyyj"> + <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._switch.name }}</template></MkInput> + <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._switch.text }}</template></MkInput> + <MkSwitch v-model="value.default"><span>{{ $ts._pages.blocks._switch.default }}</span></MkSwitch> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkSwitch, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> + +<style lang="scss" scoped> +.kjuadyyj { + padding: 0 16px 16px 16px; +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue new file mode 100644 index 0000000000..908862cf07 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue @@ -0,0 +1,39 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.textInput }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._textInput.name }}</template></MkInput> + <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._textInput.text }}</template></MkInput> + <MkInput v-model="value.default" type="text"><template #label>{{ $ts._pages.blocks._textInput.default }}</template></MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue new file mode 100644 index 0000000000..05b1a9c67d --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue @@ -0,0 +1,57 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.text }}</template> + + <section class="vckmsadr"> + <textarea v-model="value.text"></textarea> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.text == null) this.value.text = ''; + }, +}); +</script> + +<style lang="scss" scoped> +.vckmsadr { + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + min-width: 100%; + min-height: 150px; + border: none; + box-shadow: none; + padding: 16px; + background: transparent; + color: var(--fg); + font-size: 14px; + box-sizing: border-box; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue new file mode 100644 index 0000000000..bb37158ecb --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue @@ -0,0 +1,40 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.textareaInput }}</template> + + <section style="padding: 0 16px 16px 16px;"> + <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._textareaInput.name }}</template></MkInput> + <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._textareaInput.text }}</template></MkInput> + <MkTextarea v-model="value.default"><template #label>{{ $ts._pages.blocks._textareaInput.default }}</template></MkTextarea> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkTextarea, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue new file mode 100644 index 0000000000..4ca83da17c --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue @@ -0,0 +1,57 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.textarea }}</template> + + <section class="ihymsbbe"> + <textarea v-model="value.text"></textarea> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.text == null) this.value.text = ''; + }, +}); +</script> + +<style lang="scss" scoped> +.ihymsbbe { + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + min-width: 100%; + min-height: 150px; + border: none; + box-shadow: none; + padding: 16px; + background: transparent; + color: var(--fg); + font-size: 14px; + box-sizing: border-box; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/page-editor.blocks.vue b/packages/client/src/pages/page-editor/page-editor.blocks.vue new file mode 100644 index 0000000000..b91d9abae8 --- /dev/null +++ b/packages/client/src/pages/page-editor/page-editor.blocks.vue @@ -0,0 +1,78 @@ +<template> +<XDraggable tag="div" v-model="blocks" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" animation="150" swap-threshold="0.5"> + <template #item="{element}"> + <component :is="'x-' + element.type" :value="element" @update:value="updateItem" @remove="() => removeItem(element)" :hpml="hpml"/> + </template> +</XDraggable> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XSection from './els/page-editor.el.section.vue'; +import XText from './els/page-editor.el.text.vue'; +import XTextarea from './els/page-editor.el.textarea.vue'; +import XImage from './els/page-editor.el.image.vue'; +import XButton from './els/page-editor.el.button.vue'; +import XTextInput from './els/page-editor.el.text-input.vue'; +import XTextareaInput from './els/page-editor.el.textarea-input.vue'; +import XNumberInput from './els/page-editor.el.number-input.vue'; +import XSwitch from './els/page-editor.el.switch.vue'; +import XIf from './els/page-editor.el.if.vue'; +import XPost from './els/page-editor.el.post.vue'; +import XCounter from './els/page-editor.el.counter.vue'; +import XRadioButton from './els/page-editor.el.radio-button.vue'; +import XCanvas from './els/page-editor.el.canvas.vue'; +import XNote from './els/page-editor.el.note.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton, XCanvas, XNote + }, + + props: { + modelValue: { + type: Array, + required: true + }, + hpml: { + required: true, + }, + }, + + emits: ['update:modelValue'], + + computed: { + blocks: { + get() { + return this.modelValue; + }, + set(value) { + this.$emit('update:modelValue', value); + } + } + }, + + methods: { + updateItem(v) { + const i = this.blocks.findIndex(x => x.id === v.id); + const newValue = [ + ...this.blocks.slice(0, i), + v, + ...this.blocks.slice(i + 1) + ]; + this.$emit('update:modelValue', newValue); + }, + + removeItem(el) { + const i = this.blocks.findIndex(x => x.id === el.id); + const newValue = [ + ...this.blocks.slice(0, i), + ...this.blocks.slice(i + 1) + ]; + this.$emit('update:modelValue', newValue); + }, + } +}); +</script> diff --git a/packages/client/src/pages/page-editor/page-editor.container.vue b/packages/client/src/pages/page-editor/page-editor.container.vue new file mode 100644 index 0000000000..afd261fac7 --- /dev/null +++ b/packages/client/src/pages/page-editor/page-editor.container.vue @@ -0,0 +1,159 @@ +<template> +<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }"> + <header> + <div class="title"><slot name="header"></slot></div> + <div class="buttons"> + <slot name="func"></slot> + <button v-if="removable" @click="remove()" class="_button"> + <i class="fas fa-trash-alt"></i> + </button> + <button v-if="draggable" class="drag-handle _button"> + <i class="fas fa-bars"></i> + </button> + <button @click="toggleContent(!showBody)" class="_button"> + <template v-if="showBody"><i class="fas fa-angle-up"></i></template> + <template v-else><i class="fas fa-angle-down"></i></template> + </button> + </div> + </header> + <p v-show="showBody" class="error" v-if="error != null">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p> + <p v-show="showBody" class="warn" v-if="warn != null">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> + <div v-show="showBody" class="body"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + expanded: { + type: Boolean, + default: true + }, + removable: { + type: Boolean, + default: true + }, + draggable: { + type: Boolean, + default: false + }, + error: { + required: false, + default: null + }, + warn: { + required: false, + default: null + } + }, + emits: ['toggle', 'remove'], + data() { + return { + showBody: this.expanded, + }; + }, + methods: { + toggleContent(show: boolean) { + this.showBody = show; + this.$emit('toggle', show); + }, + remove() { + this.$emit('remove'); + } + } +}); +</script> + +<style lang="scss" scoped> +.cpjygsrt { + position: relative; + overflow: hidden; + background: var(--panel); + border: solid 2px var(--X12); + border-radius: 6px; + + &:hover { + border: solid 2px var(--X13); + } + + &.warn { + border: solid 2px #dec44c; + } + + &.error { + border: solid 2px #f00; + } + + & + .cpjygsrt { + margin-top: 16px; + } + + > header { + > .title { + z-index: 1; + margin: 0; + padding: 0 16px; + line-height: 42px; + font-size: 0.9em; + font-weight: bold; + box-shadow: 0 1px rgba(#000, 0.07); + + > i { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .buttons { + position: absolute; + z-index: 2; + top: 0; + right: 0; + + > button { + padding: 0; + width: 42px; + font-size: 0.9em; + line-height: 42px; + } + + .drag-handle { + cursor: move; + } + } + } + + > .warn { + color: #b19e49; + margin: 0; + padding: 16px 16px 0 16px; + font-size: 14px; + } + + > .error { + color: #f00; + margin: 0; + padding: 16px 16px 0 16px; + font-size: 14px; + } + + > .body { + ::v-deep(.juejbjww), ::v-deep(.eiipwacr) { + &:not(.inline):first-child { + margin-top: 28px; + } + + &:not(.inline):last-child { + margin-bottom: 20px; + } + } + } +} +</style> diff --git a/packages/client/src/pages/page-editor/page-editor.script-block.vue b/packages/client/src/pages/page-editor/page-editor.script-block.vue new file mode 100644 index 0000000000..07958c902b --- /dev/null +++ b/packages/client/src/pages/page-editor/page-editor.script-block.vue @@ -0,0 +1,281 @@ +<template> +<XContainer :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn" :draggable="draggable"> + <template #header><i v-if="icon" :class="icon"></i> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template> + <template #func> + <button @click="changeType()" class="_button"> + <i class="fas fa-pencil-alt"></i> + </button> + </template> + + <section v-if="modelValue.type === null" class="pbglfege" @click="changeType()"> + {{ $ts._pages.script.emptySlot }} + </section> + <section v-else-if="modelValue.type === 'text'" class="tbwccoaw"> + <input v-model="modelValue.value"/> + </section> + <section v-else-if="modelValue.type === 'multiLineText'" class="tbwccoaw"> + <textarea v-model="modelValue.value"></textarea> + </section> + <section v-else-if="modelValue.type === 'textList'" class="tbwccoaw"> + <textarea v-model="modelValue.value" :placeholder="$ts._pages.script.blocks._textList.info"></textarea> + </section> + <section v-else-if="modelValue.type === 'number'" class="tbwccoaw"> + <input v-model="modelValue.value" type="number"/> + </section> + <section v-else-if="modelValue.type === 'ref'" class="hpdwcrvs"> + <select v-model="modelValue.value"> + <option v-for="v in hpml.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option> + <optgroup :label="$ts._pages.script.argVariables"> + <option v-for="v in fnSlots" :value="v.name">{{ v.name }}</option> + </optgroup> + <optgroup :label="$ts._pages.script.pageVariables"> + <option v-for="v in hpml.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$ts._pages.script.enviromentVariables"> + <option v-for="v in hpml.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> + </optgroup> + </select> + </section> + <section v-else-if="modelValue.type === 'aiScriptVar'" class="tbwccoaw"> + <input v-model="modelValue.value"/> + </section> + <section v-else-if="modelValue.type === 'fn'" class="" style="padding:0 16px 16px 16px;"> + <MkTextarea v-model="slots"> + <template #label>{{ $ts._pages.script.blocks._fn.slots }}</template> + <template #caption>{{ $t('_pages.script.blocks._fn.slots-info') }}</template> + </MkTextarea> + <XV v-if="modelValue.value.expression" v-model="modelValue.value.expression" :title="$t(`_pages.script.blocks._fn.arg1`)" :get-expected-type="() => null" :hpml="hpml" :fn-slots="value.value.slots" :name="name"/> + </section> + <section v-else-if="modelValue.type.startsWith('fn:')" class="" style="padding:16px;"> + <XV v-for="(x, i) in modelValue.args" v-model="value.args[i]" :title="hpml.getVarByName(modelValue.type.split(':')[1]).value.slots[i].name" :get-expected-type="() => null" :hpml="hpml" :name="name" :key="i"/> + </section> + <section v-else class="" style="padding:16px;"> + <XV v-for="(x, i) in modelValue.args" v-model="modelValue.args[i]" :title="$t(`_pages.script.blocks._${modelValue.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :hpml="hpml" :name="name" :fn-slots="fnSlots" :key="i"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XContainer from './page-editor.container.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import { blockDefs } from '@/scripts/hpml/index'; +import * as os from '@/os'; +import { isLiteralValue } from '@/scripts/hpml/expr'; +import { funcDefs } from '@/scripts/hpml/lib'; + +export default defineComponent({ + components: { + XContainer, MkTextarea, + XV: defineAsyncComponent(() => import('./page-editor.script-block.vue')), + }, + + inject: ['getScriptBlockList'], + + props: { + getExpectedType: { + required: false, + default: null + }, + modelValue: { + required: true + }, + title: { + required: false + }, + removable: { + required: false, + default: false + }, + hpml: { + required: true, + }, + name: { + required: true, + }, + fnSlots: { + required: false, + }, + draggable: { + required: false, + default: false + } + }, + + data() { + return { + error: null, + warn: null, + slots: '', + }; + }, + + computed: { + icon(): any { + if (this.modelValue.type === null) return null; + if (this.modelValue.type.startsWith('fn:')) return 'fas fa-plug'; + return blockDefs.find(x => x.type === this.modelValue.type).icon; + }, + typeText(): any { + if (this.modelValue.type === null) return null; + if (this.modelValue.type.startsWith('fn:')) return this.modelValue.type.split(':')[1]; + return this.$t(`_pages.script.blocks.${this.modelValue.type}`); + }, + }, + + watch: { + slots: { + handler() { + this.modelValue.value.slots = this.slots.split('\n').map(x => ({ + name: x, + type: null + })); + }, + deep: true + } + }, + + created() { + if (this.modelValue.value == null) this.modelValue.value = null; + + if (this.modelValue.value && this.modelValue.value.slots) this.slots = this.modelValue.value.slots.map(x => x.name).join('\n'); + + this.$watch(() => this.modelValue.type, (t) => { + this.warn = null; + + if (this.modelValue.type === 'fn') { + const id = uuid(); + this.modelValue.value = { + slots: [], + expression: { id, type: null } + }; + return; + } + + if (this.modelValue.type && this.modelValue.type.startsWith('fn:')) { + const fnName = this.modelValue.type.split(':')[1]; + const fn = this.hpml.getVarByName(fnName); + + const empties = []; + for (let i = 0; i < fn.value.slots.length; i++) { + const id = uuid(); + empties.push({ id, type: null }); + } + this.modelValue.args = empties; + return; + } + + if (isLiteralValue(this.modelValue)) return; + + const empties = []; + for (let i = 0; i < funcDefs[this.modelValue.type].in.length; i++) { + const id = uuid(); + empties.push({ id, type: null }); + } + this.modelValue.args = empties; + + for (let i = 0; i < funcDefs[this.modelValue.type].in.length; i++) { + const inType = funcDefs[this.modelValue.type].in[i]; + if (typeof inType !== 'number') { + if (inType === 'number') this.modelValue.args[i].type = 'number'; + if (inType === 'string') this.modelValue.args[i].type = 'text'; + } + } + }); + + this.$watch(() => this.modelValue.args, (args) => { + if (args == null) { + this.warn = null; + return; + } + const emptySlotIndex = args.findIndex(x => x.type === null); + if (emptySlotIndex !== -1 && emptySlotIndex < args.length) { + this.warn = { + slot: emptySlotIndex + }; + } else { + this.warn = null; + } + }, { + deep: true + }); + + this.$watch(() => this.hpml.variables, () => { + if (this.type != null && this.modelValue) { + this.error = this.hpml.typeCheck(this.modelValue); + } + }, { + deep: true + }); + }, + + methods: { + async changeType() { + const { canceled, result: type } = await os.dialog({ + type: null, + title: this.$ts._pages.selectType, + select: { + groupedItems: this.getScriptBlockList(this.getExpectedType ? this.getExpectedType() : null) + }, + showCancelButton: true + }); + if (canceled) return; + this.modelValue.type = type; + }, + + _getExpectedType(slot: number) { + return this.hpml.getExpectedType(this.modelValue, slot); + } + } +}); +</script> + +<style lang="scss" scoped> +.turmquns { + opacity: 0.7; +} + +.pbglfege { + opacity: 0.5; + padding: 16px; + text-align: center; + cursor: pointer; + color: var(--fg); +} + +.tbwccoaw { + > input, + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + max-width: 100%; + min-width: 100%; + border: none; + box-shadow: none; + padding: 16px; + font-size: 16px; + background: transparent; + color: var(--fg); + box-sizing: border-box; + } + + > textarea { + min-height: 100px; + } +} + +.hpdwcrvs { + padding: 16px; + + > select { + display: block; + padding: 4px; + font-size: 16px; + width: 100%; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue new file mode 100644 index 0000000000..684b1f8c75 --- /dev/null +++ b/packages/client/src/pages/page-editor/page-editor.vue @@ -0,0 +1,561 @@ +<template> +<div> + <div class="jqqmcavi" style="margin: 16px;"> + <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton> + <MkButton inline @click="save" primary class="button" v-if="!readonly"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton inline @click="duplicate" class="button" v-if="pageId"><i class="fas fa-copy"></i> {{ $ts.duplicate }}</MkButton> + <MkButton inline @click="del" class="button" v-if="pageId && !readonly" danger><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> + </div> + + <div v-if="tab === 'settings'"> + <div style="padding: 16px;" class="_formRoot"> + <MkInput v-model="title" class="_formBlock"> + <template #label>{{ $ts._pages.title }}</template> + </MkInput> + + <MkInput v-model="summary" class="_formBlock"> + <template #label>{{ $ts._pages.summary }}</template> + </MkInput> + + <MkInput v-model="name" class="_formBlock"> + <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> + <template #label>{{ $ts._pages.url }}</template> + </MkInput> + + <MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch> + + <MkSelect v-model="font" class="_formBlock"> + <template #label>{{ $ts._pages.font }}</template> + <option value="serif">{{ $ts._pages.fontSerif }}</option> + <option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option> + </MkSelect> + + <MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch> + + <div class="eyeCatch"> + <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="fas fa-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton> + <div v-else-if="eyeCatchingImage"> + <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/> + <MkButton @click="removeEyeCatchingImage()" v-if="!readonly"><i class="fas fa-trash-alt"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton> + </div> + </div> + </div> + </div> + + <div v-else-if="tab === 'contents'"> + <div style="padding: 16px;"> + <XBlocks class="content" v-model="content" :hpml="hpml"/> + + <MkButton @click="add()" v-if="!readonly"><i class="fas fa-plus"></i></MkButton> + </div> + </div> + + <div v-else-if="tab === 'variables'"> + <div class="qmuvgica"> + <XDraggable tag="div" class="variables" v-show="variables.length > 0" v-model="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5"> + <template #item="{element}"> + <XVariable + :modelValue="element" + :removable="true" + @remove="() => removeVariable(element)" + :hpml="hpml" + :name="element.name" + :title="element.name" + :draggable="true" + /> + </template> + </XDraggable> + + <MkButton @click="addVariable()" class="add" v-if="!readonly"><i class="fas fa-plus"></i></MkButton> + </div> + </div> + + <div v-else-if="tab === 'script'"> + <div> + <MkTextarea class="_code" v-model="script"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import 'prismjs'; +import { highlight, languages } from 'prismjs/components/prism-core'; +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/themes/prism-okaidia.css'; +import 'vue-prism-editor/dist/prismeditor.min.css'; +import { v4 as uuid } from 'uuid'; +import XVariable from './page-editor.script-block.vue'; +import XBlocks from './page-editor.blocks.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkInput from '@/components/form/input.vue'; +import { blockDefs } from '@/scripts/hpml/index'; +import { HpmlTypeChecker } from '@/scripts/hpml/type-checker'; +import { url } from '@/config'; +import { collectPageVars } from '@/scripts/collect-page-vars'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput, + }, + + props: { + initPageId: { + type: String, + required: false + }, + initPageName: { + type: String, + required: false + }, + initUser: { + type: String, + required: false + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => { + let title = this.$ts._pages.newPage; + if (this.initPageId) { + title = this.$ts._pages.editPage; + } + else if (this.initPageName && this.initUser) { + title = this.$ts._pages.readPage; + } + return { + title: title, + icon: 'fas fa-pencil-alt', + bg: 'var(--bg)', + tabs: [{ + active: this.tab === 'settings', + title: this.$ts._pages.pageSetting, + icon: 'fas fa-cog', + onClick: () => { this.tab = 'settings'; }, + }, { + active: this.tab === 'contents', + title: this.$ts._pages.contents, + icon: 'fas fa-sticky-note', + onClick: () => { this.tab = 'contents'; }, + }, { + active: this.tab === 'variables', + title: this.$ts._pages.variables, + icon: 'fas fa-magic', + onClick: () => { this.tab = 'variables'; }, + }, { + active: this.tab === 'script', + title: this.$ts.script, + icon: 'fas fa-code', + onClick: () => { this.tab = 'script'; }, + }], + }; + }), + tab: 'settings', + author: this.$i, + readonly: false, + page: null, + pageId: null, + currentName: null, + title: '', + summary: null, + name: Date.now().toString(), + eyeCatchingImage: null, + eyeCatchingImageId: null, + font: 'sans-serif', + content: [], + alignCenter: false, + hideTitleWhenPinned: false, + variables: [], + hpml: null, + script: '', + url, + }; + }, + + watch: { + async eyeCatchingImageId() { + if (this.eyeCatchingImageId == null) { + this.eyeCatchingImage = null; + } else { + this.eyeCatchingImage = await os.api('drive/files/show', { + fileId: this.eyeCatchingImageId, + }); + } + }, + }, + + async created() { + this.hpml = new HpmlTypeChecker(); + + this.$watch('variables', () => { + this.hpml.variables = this.variables; + }, { deep: true }); + + this.$watch('content', () => { + this.hpml.pageVars = collectPageVars(this.content); + }, { deep: true }); + + if (this.initPageId) { + this.page = await os.api('pages/show', { + pageId: this.initPageId, + }); + } else if (this.initPageName && this.initUser) { + this.page = await os.api('pages/show', { + name: this.initPageName, + username: this.initUser, + }); + this.readonly = true; + } + + if (this.page) { + this.author = this.page.user; + this.pageId = this.page.id; + this.title = this.page.title; + this.name = this.page.name; + this.currentName = this.page.name; + this.summary = this.page.summary; + this.font = this.page.font; + this.script = this.page.script; + this.hideTitleWhenPinned = this.page.hideTitleWhenPinned; + this.alignCenter = this.page.alignCenter; + this.content = this.page.content; + this.variables = this.page.variables; + this.eyeCatchingImageId = this.page.eyeCatchingImageId; + } else { + const id = uuid(); + this.content = [{ + id, + type: 'text', + text: 'Hello World!' + }]; + } + }, + + provide() { + return { + readonly: this.readonly, + getScriptBlockList: this.getScriptBlockList, + getPageBlockList: this.getPageBlockList + } + }, + + methods: { + getSaveOptions() { + return { + title: this.title.trim(), + name: this.name.trim(), + summary: this.summary, + font: this.font, + script: this.script, + hideTitleWhenPinned: this.hideTitleWhenPinned, + alignCenter: this.alignCenter, + content: this.content, + variables: this.variables, + eyeCatchingImageId: this.eyeCatchingImageId, + }; + }, + + save() { + const options = this.getSaveOptions(); + + const onError = err => { + if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') { + if (err.info.param == 'name') { + os.dialog({ + type: 'error', + title: this.$ts._pages.invalidNameTitle, + text: this.$ts._pages.invalidNameText + }); + } + } else if (err.code == 'NAME_ALREADY_EXISTS') { + os.dialog({ + type: 'error', + text: this.$ts._pages.nameAlreadyExists + }); + } + }; + + if (this.pageId) { + options.pageId = this.pageId; + os.api('pages/update', options) + .then(page => { + this.currentName = this.name.trim(); + os.dialog({ + type: 'success', + text: this.$ts._pages.updated + }); + }).catch(onError); + } else { + os.api('pages/create', options) + .then(page => { + this.pageId = page.id; + this.currentName = this.name.trim(); + os.dialog({ + type: 'success', + text: this.$ts._pages.created + }); + this.$router.push(`/pages/edit/${this.pageId}`); + }).catch(onError); + } + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.title.trim() }), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + os.api('pages/delete', { + pageId: this.pageId, + }).then(() => { + os.dialog({ + type: 'success', + text: this.$ts._pages.deleted + }); + this.$router.push(`/pages`); + }); + }); + }, + + duplicate() { + this.title = this.title + ' - copy'; + this.name = this.name + '-copy'; + os.api('pages/create', this.getSaveOptions()).then(page => { + this.pageId = page.id; + this.currentName = this.name.trim(); + os.dialog({ + type: 'success', + text: this.$ts._pages.created + }); + this.$router.push(`/pages/edit/${this.pageId}`); + }); + }, + + async add() { + const { canceled, result: type } = await os.dialog({ + type: null, + title: this.$ts._pages.chooseBlock, + select: { + groupedItems: this.getPageBlockList() + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid(); + this.content.push({ id, type }); + }, + + async addVariable() { + let { canceled, result: name } = await os.dialog({ + title: this.$ts._pages.enterVariableName, + input: { + type: 'text', + }, + showCancelButton: true + }); + if (canceled) return; + + name = name.trim(); + + if (this.hpml.isUsedName(name)) { + os.dialog({ + type: 'error', + text: this.$ts._pages.variableNameIsAlreadyUsed + }); + return; + } + + const id = uuid(); + this.variables.push({ id, name, type: null }); + }, + + removeVariable(v) { + this.variables = this.variables.filter(x => x.name !== v.name); + }, + + getPageBlockList() { + return [{ + label: this.$ts._pages.contentBlocks, + items: [ + { value: 'section', text: this.$ts._pages.blocks.section }, + { value: 'text', text: this.$ts._pages.blocks.text }, + { value: 'image', text: this.$ts._pages.blocks.image }, + { value: 'textarea', text: this.$ts._pages.blocks.textarea }, + { value: 'note', text: this.$ts._pages.blocks.note }, + { value: 'canvas', text: this.$ts._pages.blocks.canvas }, + ] + }, { + label: this.$ts._pages.inputBlocks, + items: [ + { value: 'button', text: this.$ts._pages.blocks.button }, + { value: 'radioButton', text: this.$ts._pages.blocks.radioButton }, + { value: 'textInput', text: this.$ts._pages.blocks.textInput }, + { value: 'textareaInput', text: this.$ts._pages.blocks.textareaInput }, + { value: 'numberInput', text: this.$ts._pages.blocks.numberInput }, + { value: 'switch', text: this.$ts._pages.blocks.switch }, + { value: 'counter', text: this.$ts._pages.blocks.counter } + ] + }, { + label: this.$ts._pages.specialBlocks, + items: [ + { value: 'if', text: this.$ts._pages.blocks.if }, + { value: 'post', text: this.$ts._pages.blocks.post } + ] + }]; + }, + + getScriptBlockList(type: string = null) { + const list = []; + + const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number'); + + for (const block of blocks) { + const category = list.find(x => x.category === block.category); + if (category) { + category.items.push({ + value: block.type, + text: this.$t(`_pages.script.blocks.${block.type}`) + }); + } else { + list.push({ + category: block.category, + label: this.$t(`_pages.script.categories.${block.category}`), + items: [{ + value: block.type, + text: this.$t(`_pages.script.blocks.${block.type}`) + }] + }); + } + } + + const userFns = this.variables.filter(x => x.type === 'fn'); + if (userFns.length > 0) { + list.unshift({ + label: this.$t(`_pages.script.categories.fn`), + items: userFns.map(v => ({ + value: 'fn:' + v.name, + text: v.name + })) + }); + } + + return list; + }, + + setEyeCatchingImage(e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + this.eyeCatchingImageId = file.id; + }); + }, + + removeEyeCatchingImage() { + this.eyeCatchingImageId = null; + }, + + highlighter(code) { + return highlight(code, languages.js, 'javascript'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.jqqmcavi { + > .button { + & + .button { + margin-left: 8px; + } + } +} + +.gwbmwxkm { + position: relative; + + > header { + > .title { + z-index: 1; + margin: 0; + padding: 0 16px; + line-height: 42px; + font-size: 0.9em; + font-weight: bold; + box-shadow: 0 1px rgba(#000, 0.07); + + > i { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .buttons { + position: absolute; + z-index: 2; + top: 0; + right: 0; + + > button { + padding: 0; + width: 42px; + font-size: 0.9em; + line-height: 42px; + } + } + } + + > section { + padding: 0 32px 32px 32px; + + @media (max-width: 500px) { + padding: 0 16px 16px 16px; + } + + > .view { + display: inline-block; + margin: 16px 0 0 0; + font-size: 14px; + } + + > .content { + margin-bottom: 16px; + } + + > .eyeCatch { + margin-bottom: 16px; + + > div { + > img { + max-width: 100%; + } + } + } + } +} + +.qmuvgica { + padding: 16px; + + > .variables { + margin-bottom: 16px; + } + + > .add { + margin-bottom: 16px; + } +} +</style> diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue new file mode 100644 index 0000000000..1eff1a98cb --- /dev/null +++ b/packages/client/src/pages/page.vue @@ -0,0 +1,311 @@ +<template> +<div> + <transition name="fade" mode="out-in"> + <div v-if="page" class="xcukqgmh" :key="page.id" v-size="{ max: [450] }"> + <div class="_block main"> + <!-- + <div class="header"> + <h1>{{ page.title }}</h1> + </div> + --> + <div class="banner"> + <img :src="page.eyeCatchingImage.url" v-if="page.eyeCatchingImageId"/> + </div> + <div class="content"> + <XPage :page="page"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton class="button" @click="unlike()" v-if="page.isLiked" v-tooltip="$ts._pages.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span></MkButton> + <MkButton class="button" @click="like()" v-else v-tooltip="$ts._pages.like"><i class="far fa-heart"></i><span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button class="_button" @click="shareWithNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button> + <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="page.user" class="avatar"/> + <div class="name"> + <MkUserName :user="page.user" style="display: block;"/> + <MkAcct :user="page.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + <div class="links"> + <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ $ts._pages.viewSource }}</MkA> + <template v-if="$i && $i.id === page.userId"> + <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $ts._pages.editThisPage }}</MkA> + <button v-if="$i.pinnedPageId === page.id" @click="pin(false)" class="link _textButton">{{ $ts.unpin }}</button> + <button v-else @click="pin(true)" class="link _textButton">{{ $ts.pin }}</button> + </template> + </div> + </div> + <div class="footer"> + <div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> + <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> + </div> + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> + <MkPagination :pagination="otherPostsPagination" #default="{items}"> + <MkPagePreview v-for="page in items" :page="page" :key="page.id" class="_gap"/> + </MkPagination> + </MkContainer> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XPage from '@/components/page/page.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { url } from '@/config'; +import MkFollowButton from '@/components/follow-button.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkPagePreview from '@/components/page-preview.vue'; + +export default defineComponent({ + components: { + XPage, + MkButton, + MkFollowButton, + MkContainer, + MkPagination, + MkPagePreview, + }, + + props: { + pageName: { + type: String, + required: true + }, + username: { + type: String, + required: true + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.page ? { + title: computed(() => this.page.title || this.page.name), + avatar: this.page.user, + path: `/@${this.page.user.username}/pages/${this.page.name}`, + share: { + title: this.page.title || this.page.name, + text: this.page.summary, + }, + } : null), + page: null, + error: null, + otherPostsPagination: { + endpoint: 'users/pages', + limit: 6, + params: () => ({ + userId: this.page.user.id + }) + }, + }; + }, + + computed: { + path(): string { + return this.username + '/' + this.pageName; + } + }, + + watch: { + path() { + this.fetch(); + } + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.page = null; + os.api('pages/show', { + name: this.pageName, + username: this.username, + }).then(page => { + this.page = page; + }).catch(e => { + this.error = e; + }); + }, + + share() { + navigator.share({ + title: this.page.title || this.page.name, + text: this.page.summary, + url: `${url}/@${this.page.user.username}/pages/${this.page.name}` + }); + }, + + shareWithNote() { + os.post({ + initialText: `${this.page.title || this.page.name} ${url}/@${this.page.user.username}/pages/${this.page.name}` + }); + }, + + like() { + os.apiWithDialog('pages/like', { + pageId: this.page.id, + }).then(() => { + this.page.isLiked = true; + this.page.likedCount++; + }); + }, + + async unlike() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('pages/unlike', { + pageId: this.page.id, + }).then(() => { + this.page.isLiked = false; + this.page.likedCount--; + }); + }, + + pin(pin) { + os.apiWithDialog('i/update', { + pinnedPageId: pin ? this.page.id : null, + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.xcukqgmh { + --padding: 32px; + + &.max-width_450px { + --padding: 16px; + } + + > .main { + padding: var(--padding); + + > .header { + padding: 16px; + + > h1 { + margin: 0; + } + } + + > .banner { + > img { + // TODO: ่ฏใๆใใฎใขในใใฏใๆฏใง่กจ็คบ + display: block; + width: 100%; + height: 150px; + object-fit: cover; + } + } + + > .content { + margin-top: 16px; + padding: 16px 0 0 0; + } + + > .actions { + display: flex; + align-items: center; + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + + > .like { + > .button { + --accent: rgb(241 97 132); + --X8: rgb(241 92 128); + --buttonBg: rgb(216 71 106 / 5%); + --buttonHoverBg: rgb(216 71 106 / 10%); + color: #ff002f; + + ::v-deep(.count) { + margin-left: 0.5em; + } + } + } + + > .other { + margin-left: auto; + + > button { + padding: 8px; + margin: 0 8px; + + &:hover { + color: var(--fgHighlighted); + } + } + } + } + + > .user { + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + display: flex; + align-items: center; + + > .avatar { + width: 52px; + height: 52px; + } + + > .name { + margin: 0 0 0 12px; + font-size: 90%; + } + + > .koudoku { + margin-left: auto; + } + } + + > .links { + margin-top: 16px; + padding: 24px 0 0 0; + border-top: solid 0.5px var(--divider); + + > .link { + margin-right: 0.75em; + } + } + } + + > .footer { + margin: var(--padding); + font-size: 85%; + opacity: 0.75; + } +} +</style> diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue new file mode 100644 index 0000000000..d66fc2ad5b --- /dev/null +++ b/packages/client/src/pages/pages.vue @@ -0,0 +1,96 @@ +<template> +<MkSpacer> + <!-- TODO: MkHeaderใซ็ตฑๅ --> + <MkTab v-model="tab" v-if="$i"> + <option value="featured"><i class="fas fa-fire-alt"></i> {{ $ts._pages.featured }}</option> + <option value="my"><i class="fas fa-edit"></i> {{ $ts._pages.my }}</option> + <option value="liked"><i class="fas fa-heart"></i> {{ $ts._pages.liked }}</option> + </MkTab> + + <div class="_section"> + <div class="rknalgpo _content" v-if="tab === 'featured'"> + <MkPagination :pagination="featuredPagesPagination" #default="{items}"> + <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> + </MkPagination> + </div> + + <div class="rknalgpo _content my" v-if="tab === 'my'"> + <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton> + <MkPagination :pagination="myPagesPagination" #default="{items}"> + <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> + </MkPagination> + </div> + + <div class="rknalgpo _content" v-if="tab === 'liked'"> + <MkPagination :pagination="likedPagesPagination" #default="{items}"> + <MkPagePreview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/> + </MkPagination> + </div> + </div> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagePreview from '@/components/page-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTab from '@/components/tab.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagePreview, MkPagination, MkButton, MkTab + }, + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.pages, + icon: 'fas fa-sticky-note', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-plus', + text: this.$ts.create, + handler: this.create, + }], + }, + tab: 'featured', + featuredPagesPagination: { + endpoint: 'pages/featured', + noPaging: true, + }, + myPagesPagination: { + endpoint: 'i/pages', + limit: 5, + }, + likedPagesPagination: { + endpoint: 'i/page-likes', + limit: 5, + }, + }; + }, + methods: { + create() { + this.$router.push(`/pages/new`); + } + } +}); +</script> + +<style lang="scss" scoped> +.rknalgpo { + &.my .ckltabjg:first-child { + margin-top: 16px; + } + + .ckltabjg:not(:last-child) { + margin-bottom: 8px; + } + + @media (min-width: 500px) { + .ckltabjg:not(:last-child) { + margin-bottom: 16px; + } + } +} +</style> diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue new file mode 100644 index 0000000000..9d1ebb74ed --- /dev/null +++ b/packages/client/src/pages/preview.vue @@ -0,0 +1,32 @@ +<template> +<div class="graojtoi"> + <MkSample/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkSample from '@/components/sample.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkSample, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.preview, + icon: 'fas fa-eye', + }, + } + }, +}); +</script> + +<style lang="scss" scoped> +.graojtoi { + padding: var(--margin); +} +</style> diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue new file mode 100644 index 0000000000..f9a2500840 --- /dev/null +++ b/packages/client/src/pages/reset-password.vue @@ -0,0 +1,69 @@ +<template> +<FormBase v-if="token"> + <FormInput v-model="password" type="password"> + <template #prefix><i class="fas fa-lock"></i></template> + <span>{{ $ts.newPassword }}</span> + </FormInput> + + <FormButton primary @click="save">{{ $ts.save }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormGroup, + FormLink, + FormInput, + FormButton, + }, + + props: { + token: { + type: String, + required: false + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.resetPassword, + icon: 'fas fa-lock' + }, + password: '', + } + }, + + mounted() { + if (this.token == null) { + os.popup(import('@/components/forgot-password.vue'), {}, {}, 'closed'); + this.$router.push('/'); + } + }, + + methods: { + async save() { + await os.apiWithDialog('reset-password', { + token: this.token, + password: this.password, + }); + this.$router.push('/'); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/reversi/game.board.vue b/packages/client/src/pages/reversi/game.board.vue new file mode 100644 index 0000000000..529e00d969 --- /dev/null +++ b/packages/client/src/pages/reversi/game.board.vue @@ -0,0 +1,528 @@ +<template> +<div class="xqnhankfuuilcwvhgsopeqncafzsquya"> + <header><b><MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA></b>({{ $ts._reversi.black }}) vs <b><MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA></b>({{ $ts._reversi.white }})</header> + + <div style="overflow: hidden; line-height: 28px;"> + <p class="turn" v-if="!iAmPlayer && !game.isEnded"> + <Mfm :key="'turn:' + turnUser().name" :text="$t('_reversi.turnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/> + <MkEllipsis/> + </p> + <p class="turn" v-if="logPos != logs.length"> + <Mfm :key="'past-turn-of:' + turnUser().name" :text="$t('_reversi.pastTurnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/> + </p> + <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn()">{{ $ts._reversi.opponentTurn }}<MkEllipsis/></p> + <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn()" style="animation: tada 1s linear infinite both;">{{ $ts._reversi.myTurn }}</p> + <p class="result" v-if="game.isEnded && logPos == logs.length"> + <template v-if="game.winner"> + <Mfm :key="'won'" :text="$t('_reversi.won', { name: game.winner.name })" :plain="true" :custom-emojis="game.winner.emojis"/> + <span v-if="game.surrendered != null"> ({{ $ts._reversi.surrendered }})</span> + </template> + <template v-else>{{ $ts._reversi.drawn }}</template> + </p> + </div> + + <div class="board"> + <div class="labels-x" v-if="$store.state.gamesReversiShowBoardLabels"> + <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span> + </div> + <div class="flex"> + <div class="labels-y" v-if="$store.state.gamesReversiShowBoardLabels"> + <div v-for="i in game.map.length">{{ i }}</div> + </div> + <div class="cells" :style="cellsStyle"> + <div v-for="(stone, i) in o.board" + :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn(), can: turnUser() ? o.canPut(turnUser().id == blackUser.id, i) : null, prev: o.prevPos == i }" + @click="set(i)" + :title="`${String.fromCharCode(65 + o.transformPosToXy(i)[0])}${o.transformPosToXy(i)[1] + 1}`" + > + <template v-if="$store.state.gamesReversiUseAvatarStones || true"> + <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black"> + <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white"> + </template> + <template v-else> + <i v-if="stone === true" class="fas fa-circle"></i> + <i v-if="stone === false" class="far fa-circle"></i> + </template> + </div> + </div> + <div class="labels-y" v-if="$store.state.gamesReversiShowBoardLabels"> + <div v-for="i in game.map.length">{{ i }}</div> + </div> + </div> + <div class="labels-x" v-if="$store.state.gamesReversiShowBoardLabels"> + <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span> + </div> + </div> + + <p class="status"><b>{{ $t('_reversi.turnCount', { count: logPos }) }}</b> {{ $ts._reversi.black }}:{{ o.blackCount }} {{ $ts._reversi.white }}:{{ o.whiteCount }} {{ $ts._reversi.total }}:{{ o.blackCount + o.whiteCount }}</p> + + <div class="actions" v-if="!game.isEnded && iAmPlayer"> + <MkButton @click="surrender" inline>{{ $ts._reversi.surrender }}</MkButton> + </div> + + <div class="player" v-if="game.isEnded"> + <span>{{ logPos }} / {{ logs.length }}</span> + <div class="buttons" v-if="!autoplaying"> + <MkButton inline @click="logPos = 0" :disabled="logPos == 0"><i class="fas fa-angle-double-left"></i></MkButton> + <MkButton inline @click="logPos--" :disabled="logPos == 0"><i class="fas fa-angle-left"></i></MkButton> + <MkButton inline @click="logPos++" :disabled="logPos == logs.length"><i class="fas fa-angle-right"></i></MkButton> + <MkButton inline @click="logPos = logs.length" :disabled="logPos == logs.length"><i class="fas fa-angle-double-right"></i></MkButton> + </div> + <MkButton @click="autoplay()" :disabled="autoplaying" style="margin: var(--margin) auto 0 auto;"><i class="fas fa-play"></i></MkButton> + </div> + + <div class="info"> + <p v-if="game.isLlotheo">{{ $ts._reversi.isLlotheo }}</p> + <p v-if="game.loopedBoard">{{ $ts._reversi.loopedMap }}</p> + <p v-if="game.canPutEverywhere">{{ $ts._reversi.canPutEverywhere }}</p> + </div> + + <div class="watchers"> + <MkAvatar v-for="user in watchers" :key="user.id" :user="user" class="avatar"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as CRC32 from 'crc-32'; +import Reversi, { Color } from '@/scripts/games/reversi/core'; +import { url } from '@/config'; +import MkButton from '@/components/ui/button.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import * as sound from '@/scripts/sound'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + initGame: { + type: Object, + require: true + }, + connection: { + type: Object, + require: true + }, + }, + + data() { + return { + game: JSON.parse(JSON.stringify(this.initGame)), + o: null as Reversi, + logs: [], + logPos: 0, + watchers: [], + pollingClock: null, + }; + }, + + computed: { + iAmPlayer(): boolean { + if (!this.$i) return false; + return this.game.user1Id == this.$i.id || this.game.user2Id == this.$i.id; + }, + + myColor(): Color { + if (!this.iAmPlayer) return null; + if (this.game.user1Id == this.$i.id && this.game.black == 1) return true; + if (this.game.user2Id == this.$i.id && this.game.black == 2) return true; + return false; + }, + + opColor(): Color { + if (!this.iAmPlayer) return null; + return this.myColor === true ? false : true; + }, + + blackUser(): any { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + }, + + whiteUser(): any { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + }, + + cellsStyle(): any { + return { + 'grid-template-rows': `repeat(${this.game.map.length}, 1fr)`, + 'grid-template-columns': `repeat(${this.game.map[0].length}, 1fr)` + }; + } + }, + + watch: { + logPos(v) { + if (!this.game.isEnded) return; + const o = new Reversi(this.game.map, { + isLlotheo: this.game.isLlotheo, + canPutEverywhere: this.game.canPutEverywhere, + loopedBoard: this.game.loopedBoard + }); + for (const log of this.logs.slice(0, v)) { + o.put(log.color, log.pos); + } + this.o = o; + //this.$forceUpdate(); + } + }, + + created() { + this.o = new Reversi(this.game.map, { + isLlotheo: this.game.isLlotheo, + canPutEverywhere: this.game.canPutEverywhere, + loopedBoard: this.game.loopedBoard + }); + + for (const log of this.game.logs) { + this.o.put(log.color, log.pos); + } + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + // ้ไฟกใๅใใใผใใฆใใใใใใซๅฎๆ็ใซใใผใชใณใฐใใใ + if (this.game.isStarted && !this.game.isEnded) { + this.pollingClock = setInterval(() => { + if (this.game.isEnded) return; + const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join('')); + this.connection.send('check', { + crc32: crc32 + }); + }, 3000); + } + }, + + mounted() { + this.connection.on('set', this.onSet); + this.connection.on('rescue', this.onRescue); + this.connection.on('ended', this.onEnded); + this.connection.on('watchers', this.onWatchers); + }, + + beforeUnmount() { + this.connection.off('set', this.onSet); + this.connection.off('rescue', this.onRescue); + this.connection.off('ended', this.onEnded); + this.connection.off('watchers', this.onWatchers); + + clearInterval(this.pollingClock); + }, + + methods: { + userPage, + + // this.o ใใชใขใฏใใฃใใซใชใฃใๆใซใฏcomputedใซใงใใ + turnUser(): any { + if (this.o.turn === true) { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.turn === false) { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + return null; + } + }, + + // this.o ใใชใขใฏใใฃใใซใชใฃใๆใซใฏcomputedใซใงใใ + isMyTurn(): boolean { + if (!this.iAmPlayer) return false; + if (this.turnUser() == null) return false; + return this.turnUser().id == this.$i.id; + }, + + set(pos) { + if (this.game.isEnded) return; + if (!this.iAmPlayer) return; + if (!this.isMyTurn()) return; + if (!this.o.canPut(this.myColor, pos)) return; + + this.o.put(this.myColor, pos); + + // ใตใฆใณใใๅ็ใใ + sound.play(this.myColor ? 'reversiPutBlack' : 'reversiPutWhite'); + + this.connection.send('set', { + pos: pos + }); + + this.checkEnd(); + + this.$forceUpdate(); + }, + + onSet(x) { + this.logs.push(x); + this.logPos++; + this.o.put(x.color, x.pos); + this.checkEnd(); + this.$forceUpdate(); + + // ใตใฆใณใใๅ็ใใ + if (x.color !== this.myColor) { + sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite'); + } + }, + + onEnded(x) { + this.game = JSON.parse(JSON.stringify(x.game)); + }, + + checkEnd() { + this.game.isEnded = this.o.isEnded; + if (this.game.isEnded) { + if (this.o.winner === true) { + this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id; + this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.winner === false) { + this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id; + this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + this.game.winnerId = null; + this.game.winner = null; + } + } + }, + + // ๆญฃใใใฒใผใ ๆ
ๅ ฑใ้ใใใฆใใใจใ + onRescue(game) { + this.game = JSON.parse(JSON.stringify(game)); + + this.o = new Reversi(this.game.map, { + isLlotheo: this.game.isLlotheo, + canPutEverywhere: this.game.canPutEverywhere, + loopedBoard: this.game.loopedBoard + }); + + for (const log of this.game.logs) { + this.o.put(log.color, log.pos, true); + } + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + this.checkEnd(); + this.$forceUpdate(); + }, + + onWatchers(users) { + this.watchers = users; + }, + + surrender() { + os.api('games/reversi/games/surrender', { + gameId: this.game.id + }); + }, + + autoplay() { + this.autoplaying = true; + this.logPos = 0; + + setTimeout(() => { + this.logPos = 1; + + let i = 1; + let previousLog = this.game.logs[0]; + const tick = () => { + const log = this.game.logs[i]; + const time = new Date(log.at).getTime() - new Date(previousLog.at).getTime() + setTimeout(() => { + i++; + this.logPos++; + previousLog = log; + + if (i < this.game.logs.length) { + tick(); + } else { + this.autoplaying = false; + } + }, time); + }; + + tick(); + }, 1000); + } + } +}); +</script> + +<style lang="scss" scoped> + +@use "sass:math"; + +.xqnhankfuuilcwvhgsopeqncafzsquya { + text-align: center; + + > .go-index { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 42px; + height :42px; + } + + > header { + padding: 8px; + border-bottom: dashed 1px var(--divider); + } + + > .board { + width: calc(100% - 16px); + max-width: 500px; + margin: 0 auto; + + $label-size: 16px; + $gap: 4px; + + > .labels-x { + height: $label-size; + padding: 0 $label-size; + display: flex; + + > * { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8em; + + &:first-child { + margin-left: -(math.div($gap, 2)); + } + + &:last-child { + margin-right: -(math.div($gap, 2)); + } + } + } + + > .flex { + display: flex; + + > .labels-y { + width: $label-size; + display: flex; + flex-direction: column; + + > * { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + + &:first-child { + margin-top: -(math.div($gap, 2)); + } + + &:last-child { + margin-bottom: -(math.div($gap, 2)); + } + } + } + + > .cells { + flex: 1; + display: grid; + grid-gap: $gap; + + > div { + background: transparent; + border-radius: 6px; + overflow: hidden; + + * { + pointer-events: none; + user-select: none; + } + + &.empty { + border: solid 2px var(--divider); + } + + &.empty.can { + border-color: var(--accent); + } + + &.empty.myTurn { + border-color: var(--divider); + + &.can { + border-color: var(--accent); + cursor: pointer; + + &:hover { + background: var(--accent); + } + } + } + + &.prev { + box-shadow: 0 0 0 4px var(--accent); + } + + &.isEnded { + border-color: var(--divider); + } + + &.none { + border-color: transparent !important; + } + + > svg, > img { + display: block; + width: 100%; + height: 100%; + } + } + } + } + } + + > .status { + margin: 0; + padding: 16px 0; + } + + > .actions { + padding-bottom: 16px; + } + + > .player { + padding: 0 16px 32px 16px; + margin: 0 auto; + max-width: 500px; + + > span { + display: inline-block; + margin: 0 8px; + min-width: 70px; + } + + > .buttons { + display: flex; + + > * { + flex: 1; + } + } + } + + > .watchers { + padding: 0 0 16px 0; + + &:empty { + display: none; + } + + > .avatar { + width: 32px; + height: 32px; + } + } +} +</style> diff --git a/packages/client/src/pages/reversi/game.setting.vue b/packages/client/src/pages/reversi/game.setting.vue new file mode 100644 index 0000000000..e6a6661f16 --- /dev/null +++ b/packages/client/src/pages/reversi/game.setting.vue @@ -0,0 +1,390 @@ +<template> +<div class="urbixznjwwuukfsckrwzwsqzsxornqij"> + <header><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></header> + + <div> + <p>{{ $ts._reversi.gameSettings }}</p> + + <div class="card map _panel"> + <header> + <select v-model="mapName" :placeholder="$ts._reversi.chooseBoard" @change="onMapChange"> + <option label="-Custom-" :value="mapName" v-if="mapName == '-Custom-'"/> + <option :label="$ts.random" :value="null"/> + <optgroup v-for="c in mapCategories" :key="c" :label="c"> + <option v-for="m in Object.values(maps).filter(m => m.category == c)" :key="m.name" :label="m.name" :value="m.name">{{ m.name }}</option> + </optgroup> + </select> + </header> + + <div> + <div class="random" v-if="game.map == null"><i class="fas fa-dice"></i></div> + <div class="board" v-else :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }"> + <div v-for="(x, i) in game.map.join('')" :class="{ none: x == ' ' }" @click="onPixelClick(i, x)"> + <i v-if="x === 'b'" class="fas fa-circle"></i> + <i v-if="x === 'w'" class="far fa-circle"></i> + </div> + </div> + </div> + </div> + + <div class="card _panel"> + <header> + <span>{{ $ts._reversi.blackOrWhite }}</span> + </header> + + <div> + <MkRadio v-model="game.bw" value="random" @update:modelValue="updateSettings('bw')">{{ $ts.random }}</MkRadio> + <MkRadio v-model="game.bw" :value="'1'" @update:modelValue="updateSettings('bw')"> + <I18n :src="$ts._reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user1"/></b> + </template> + </I18n> + </MkRadio> + <MkRadio v-model="game.bw" :value="'2'" @update:modelValue="updateSettings('bw')"> + <I18n :src="$ts._reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user2"/></b> + </template> + </I18n> + </MkRadio> + </div> + </div> + + <div class="card _panel"> + <header> + <span>{{ $ts._reversi.rules }}</span> + </header> + + <div> + <MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ $ts._reversi.isLlotheo }}</MkSwitch> + <MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ $ts._reversi.loopedMap }}</MkSwitch> + <MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ $ts._reversi.canPutEverywhere }}</MkSwitch> + </div> + </div> + + <div class="card form _panel" v-if="form"> + <header> + <span>{{ $ts._reversi.botSettings }}</span> + </header> + + <div> + <template v-for="item in form"> + <MkSwitch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" @change="onChangeForm(item)">{{ item.label || item.desc || '' }}</MkSwitch> + + <div class="card" v-if="item.type == 'radio'" :key="item.id"> + <header> + <span>{{ item.label }}</span> + </header> + + <div> + <MkRadio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :value="r.value" @update:modelValue="onChangeForm(item)">{{ r.label }}</MkRadio> + </div> + </div> + + <div class="card" v-if="item.type == 'slider'" :key="item.id"> + <header> + <span>{{ item.label }}</span> + </header> + + <div> + <input type="range" :min="item.min" :max="item.max" :step="item.step || 1" v-model="item.value" @change="onChangeForm(item)"/> + </div> + </div> + + <div class="card" v-if="item.type == 'textbox'" :key="item.id"> + <header> + <span>{{ item.label }}</span> + </header> + + <div> + <input v-model="item.value" @change="onChangeForm(item)"/> + </div> + </div> + </template> + </div> + </div> + </div> + + <footer class="_acrylic"> + <p class="status"> + <template v-if="isAccepted && isOpAccepted">{{ $ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template> + <template v-if="isAccepted && !isOpAccepted">{{ $ts._reversi.waitingForOther }}<MkEllipsis/></template> + <template v-if="!isAccepted && isOpAccepted">{{ $ts._reversi.waitingForMe }}</template> + <template v-if="!isAccepted && !isOpAccepted">{{ $ts._reversi.waitingBoth }}<MkEllipsis/></template> + </p> + + <div class="actions"> + <MkButton inline @click="exit">{{ $ts.cancel }}</MkButton> + <MkButton inline primary @click="accept" v-if="!isAccepted">{{ $ts._reversi.ready }}</MkButton> + <MkButton inline primary @click="cancel" v-if="isAccepted">{{ $ts._reversi.cancelReady }}</MkButton> + </div> + </footer> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as maps from '@/scripts/games/reversi/maps'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkRadio from '@/components/form/radio.vue'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + MkRadio, + }, + + props: { + initGame: { + type: Object, + require: true + }, + connection: { + type: Object, + require: true + }, + }, + + data() { + return { + game: this.initGame, + o: null, + isLlotheo: false, + mapName: maps.eighteight.name, + maps: maps, + form: null, + messages: [], + }; + }, + + computed: { + mapCategories(): string[] { + const categories = Object.values(maps).map(x => x.category); + return categories.filter((item, pos) => categories.indexOf(item) == pos); + }, + isAccepted(): boolean { + if (this.game.user1Id == this.$i.id && this.game.user1Accepted) return true; + if (this.game.user2Id == this.$i.id && this.game.user2Accepted) return true; + return false; + }, + isOpAccepted(): boolean { + if (this.game.user1Id != this.$i.id && this.game.user1Accepted) return true; + if (this.game.user2Id != this.$i.id && this.game.user2Accepted) return true; + return false; + } + }, + + created() { + this.connection.on('changeAccepts', this.onChangeAccepts); + this.connection.on('updateSettings', this.onUpdateSettings); + this.connection.on('initForm', this.onInitForm); + this.connection.on('message', this.onMessage); + + if (this.game.user1Id != this.$i.id && this.game.form1) this.form = this.game.form1; + if (this.game.user2Id != this.$i.id && this.game.form2) this.form = this.game.form2; + }, + + beforeUnmount() { + this.connection.off('changeAccepts', this.onChangeAccepts); + this.connection.off('updateSettings', this.onUpdateSettings); + this.connection.off('initForm', this.onInitForm); + this.connection.off('message', this.onMessage); + }, + + methods: { + exit() { + + }, + + accept() { + this.connection.send('accept', {}); + }, + + cancel() { + this.connection.send('cancelAccept', {}); + }, + + onChangeAccepts(accepts) { + this.game.user1Accepted = accepts.user1; + this.game.user2Accepted = accepts.user2; + }, + + updateSettings(key: string) { + this.connection.send('updateSettings', { + key: key, + value: this.game[key] + }); + }, + + onUpdateSettings({ key, value }) { + this.game[key] = value; + if (this.game.map == null) { + this.mapName = null; + } else { + const found = Object.values(maps).find(x => x.data.join('') == this.game.map.join('')); + this.mapName = found ? found.name : '-Custom-'; + } + }, + + onInitForm(x) { + if (x.userId == this.$i.id) return; + this.form = x.form; + }, + + onMessage(x) { + if (x.userId == this.$i.id) return; + this.messages.unshift(x.message); + }, + + onChangeForm(item) { + this.connection.send('updateForm', { + id: item.id, + value: item.value + }); + }, + + onMapChange() { + if (this.mapName == null) { + this.game.map = null; + } else { + this.game.map = Object.values(maps).find(x => x.name == this.mapName).data; + } + this.updateSettings('map'); + }, + + onPixelClick(pos, pixel) { + const x = pos % this.game.map[0].length; + const y = Math.floor(pos / this.game.map[0].length); + const newPixel = + pixel == ' ' ? '-' : + pixel == '-' ? 'b' : + pixel == 'b' ? 'w' : + ' '; + const line = this.game.map[y].split(''); + line[x] = newPixel; + this.game.map[y] = line.join(''); + this.updateSettings('map'); + } + } +}); +</script> + +<style lang="scss" scoped> +.urbixznjwwuukfsckrwzwsqzsxornqij { + text-align: center; + background: var(--bg); + + > header { + padding: 8px; + border-bottom: dashed 1px #c4cdd4; + } + + > div { + padding: 0 16px; + + > .card { + margin: 0 auto 16px auto; + + &.map { + > header { + > select { + width: 100%; + padding: 12px 14px; + background: var(--face); + border: 1px solid var(--inputBorder); + border-radius: 4px; + color: var(--fg); + cursor: pointer; + transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + &:focus-visible, + &:active { + border-color: var(--accent); + } + } + } + + > div { + > .random { + padding: 32px 0; + font-size: 64px; + color: var(--fg); + opacity: 0.7; + } + + > .board { + display: grid; + grid-gap: 4px; + width: 300px; + height: 300px; + margin: 0 auto; + color: var(--fg); + + > div { + background: transparent; + border: solid 2px var(--divider); + border-radius: 6px; + overflow: hidden; + cursor: pointer; + + * { + pointer-events: none; + user-select: none; + width: 100%; + height: 100%; + } + + &.none { + border-color: transparent; + } + } + } + } + } + + &.form { + > div { + > .card + .card { + margin-top: 16px; + } + + input[type='range'] { + width: 100%; + } + } + } + } + + .card { + max-width: 400px; + + > header { + padding: 18px 20px; + border-bottom: 1px solid var(--divider); + } + + > div { + padding: 20px; + color: var(--fg); + } + } + } + + > footer { + position: sticky; + bottom: 0; + padding: 16px; + border-top: solid 1px var(--divider); + + > .status { + margin: 0 0 16px 0; + } + } +} +</style> diff --git a/packages/client/src/pages/reversi/game.vue b/packages/client/src/pages/reversi/game.vue new file mode 100644 index 0000000000..b1ed632904 --- /dev/null +++ b/packages/client/src/pages/reversi/game.vue @@ -0,0 +1,76 @@ +<template> +<div v-if="game == null"><MkLoading/></div> +<GameSetting v-else-if="!game.isStarted" :init-game="game" :connection="connection"/> +<GameBoard v-else :init-game="game" :connection="connection"/> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import GameSetting from './game.setting.vue'; +import GameBoard from './game.board.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + GameSetting, + GameBoard, + }, + + props: { + gameId: { + type: String, + required: true + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._reversi.reversi, + icon: 'fas fa-gamepad' + }, + game: null, + connection: null, + }; + }, + + watch: { + gameId() { + this.fetch(); + } + }, + + mounted() { + this.fetch(); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.dispose(); + } + }, + + methods: { + fetch() { + os.api('games/reversi/games/show', { + gameId: this.gameId + }).then(game => { + this.game = game; + + if (this.connection) { + this.connection.dispose(); + } + this.connection = markRaw(os.stream.useChannel('gamesReversiGame', { + gameId: this.game.id + })); + this.connection.on('started', this.onStarted); + }); + }, + + onStarted(game) { + Object.assign(this.game, game); + }, + } +}); +</script> diff --git a/packages/client/src/pages/reversi/index.vue b/packages/client/src/pages/reversi/index.vue new file mode 100644 index 0000000000..1b8f1ffb71 --- /dev/null +++ b/packages/client/src/pages/reversi/index.vue @@ -0,0 +1,279 @@ +<template> +<div class="bgvwxkhb" v-if="!matching"> + <h1>Misskey {{ $ts._reversi.reversi }}</h1> + + <div class="play"> + <MkButton primary round @click="match" style="margin: var(--margin) auto 0 auto;">{{ $ts.invite }}</MkButton> + </div> + + <div class="_section"> + <MkFolder v-if="invitations.length > 0"> + <template #header>{{ $ts.invitations }}</template> + <div class="nfcacttm"> + <button class="invitation _panel _button" v-for="invitation in invitations" tabindex="-1" @click="accept(invitation)"> + <MkAvatar class="avatar" :user="invitation.parent" :show-indicator="true"/> + <span class="name"><b><MkUserName :user="invitation.parent"/></b></span> + <span class="username">@{{ invitation.parent.username }}</span> + <MkTime :time="invitation.createdAt" class="time"/> + </button> + </div> + </MkFolder> + + <MkFolder v-if="myGames.length > 0"> + <template #header>{{ $ts._reversi.myGames }}</template> + <div class="knextgwz"> + <MkA class="game _panel" v-for="g in myGames" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id"> + <div class="players"> + <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/> + </div> + <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $ts._reversi.ended : $ts._reversi.playing }}</span><MkTime class="time" :time="g.createdAt"/></footer> + </MkA> + </div> + </MkFolder> + + <MkFolder v-if="games.length > 0"> + <template #header>{{ $ts._reversi.allGames }}</template> + <div class="knextgwz"> + <MkA class="game _panel" v-for="g in games" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id"> + <div class="players"> + <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/> + </div> + <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $ts._reversi.ended : $ts._reversi.playing }}</span><MkTime class="time" :time="g.createdAt"/></footer> + </MkA> + </div> + </MkFolder> + </div> +</div> +<div class="sazhgisb" v-else> + <h1> + <I18n :src="$ts.waitingFor" tag="span"> + <template #x> + <b><MkUserName :user="matching"/></b> + </template> + </I18n> + <MkEllipsis/> + </h1> + <div class="cancel"> + <MkButton inline round @click="cancel">{{ $ts.cancel }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import * as os from '@/os'; +import MkButton from '@/components/ui/button.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, MkFolder, + }, + + inject: ['navHook'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._reversi.reversi, + icon: 'fas fa-gamepad' + }, + games: [], + gamesFetching: true, + gamesMoreFetching: false, + myGames: [], + matching: null, + invitations: [], + connection: null, + pingClock: null, + }; + }, + + mounted() { + if (this.$i) { + this.connection = markRaw(os.stream.useChannel('gamesReversi')); + + this.connection.on('invited', this.onInvited); + + this.connection.on('matched', this.onMatched); + + this.pingClock = setInterval(() => { + if (this.matching) { + this.connection.send('ping', { + id: this.matching.id + }); + } + }, 3000); + + os.api('games/reversi/games', { + my: true + }).then(games => { + this.myGames = games; + }); + + os.api('games/reversi/invitations').then(invitations => { + this.invitations = this.invitations.concat(invitations); + }); + } + + os.api('games/reversi/games').then(games => { + this.games = games; + this.gamesFetching = false; + }); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.dispose(); + clearInterval(this.pingClock); + } + }, + + methods: { + go(game) { + const url = '/games/reversi/' + game.id; + if (this.navHook) { + this.navHook(url); + } else { + this.$router.push(url); + } + }, + + async match() { + const user = await os.selectUser({ local: true }); + if (user == null) return; + os.api('games/reversi/match', { + userId: user.id + }).then(res => { + if (res == null) { + this.matching = user; + } else { + this.go(res); + } + }); + }, + + cancel() { + this.matching = null; + os.api('games/reversi/match/cancel'); + }, + + accept(invitation) { + os.api('games/reversi/match', { + userId: invitation.parent.id + }).then(game => { + if (game) { + this.go(game); + } + }); + }, + + onMatched(game) { + this.go(game); + }, + + onInvited(invite) { + this.invitations.unshift(invite); + } + } +}); +</script> + +<style lang="scss" scoped> +.bgvwxkhb { + > h1 { + margin: 0; + padding: 24px; + text-align: center; + font-size: 1.5em; + background: linear-gradient(0deg, #43c583, #438881); + color: #fff; + } + + > .play { + text-align: center; + } +} + +.sazhgisb { + text-align: center; +} + +.nfcacttm { + > .invitation { + display: flex; + box-sizing: border-box; + width: 100%; + padding: 16px; + line-height: 32px; + text-align: left; + + > .avatar { + width: 32px; + height: 32px; + margin-right: 8px; + } + + > .name { + margin-right: 8px; + } + + > .username { + margin-right: 8px; + opacity: 0.7; + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } +} + +.knextgwz { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); + + > .game { + > .players { + text-align: center; + padding: 16px; + line-height: 32px; + + > .avatar { + width: 32px; + height: 32px; + + &:first-child { + margin-right: 8px; + } + + &:last-child { + margin-left: 8px; + } + } + } + + > footer { + display: flex; + align-items: baseline; + border-top: solid 0.5px var(--divider); + padding: 6px 8px; + font-size: 0.9em; + + > .state { + &.playing { + color: var(--accent); + } + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } + } +} +</style> diff --git a/packages/client/src/pages/room/preview.vue b/packages/client/src/pages/room/preview.vue new file mode 100644 index 0000000000..b0e600d4fb --- /dev/null +++ b/packages/client/src/pages/room/preview.vue @@ -0,0 +1,107 @@ +<template> +<canvas width="224" height="128"></canvas> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as THREE from 'three'; +import * as os from '@/os'; + +export default defineComponent({ + data() { + return { + selected: null, + objectHeight: 0, + orbitRadius: 5 + }; + }, + + mounted() { + const canvas = this.$el; + + const width = canvas.width; + const height = canvas.height; + + const scene = new THREE.Scene(); + + const renderer = new THREE.WebGLRenderer({ + canvas: canvas, + antialias: true, + alpha: false + }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(width, height); + renderer.setClearColor(0x000000); + renderer.autoClear = false; + renderer.shadowMap.enabled = true; + renderer.shadowMap.cullFace = THREE.CullFaceBack; + + const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 100); + camera.zoom = 10; + camera.position.x = 0; + camera.position.y = 2; + camera.position.z = 0; + camera.updateProjectionMatrix(); + scene.add(camera); + + const ambientLight = new THREE.AmbientLight(0xffffff, 1); + ambientLight.castShadow = false; + scene.add(ambientLight); + + const light = new THREE.PointLight(0xffffff, 1, 100); + light.position.set(3, 3, 3); + scene.add(light); + + const grid = new THREE.GridHelper(5, 16, 0x444444, 0x222222); + scene.add(grid); + + const render = () => { + const timer = Date.now() * 0.0004; + requestAnimationFrame(render); + + camera.position.y = Math.sin(Math.PI / 6) * this.orbitRadius; // Math.PI / 6 => 30deg + camera.position.z = Math.cos(timer) * this.orbitRadius; + camera.position.x = Math.sin(timer) * this.orbitRadius; + camera.lookAt(new THREE.Vector3(0, this.objectHeight / 2, 0)); + renderer.render(scene, camera); + }; + + this.selected = selected => { + const obj = selected.clone(); + + // Remove current object + const current = scene.getObjectByName('obj'); + if (current != null) { + scene.remove(current); + } + + // Add new object + obj.name = 'obj'; + obj.position.x = 0; + obj.position.y = 0; + obj.position.z = 0; + obj.rotation.x = 0; + obj.rotation.y = 0; + obj.rotation.z = 0; + obj.traverse(child => { + if (child instanceof THREE.Mesh) { + child.material = child.material.clone(); + return child.material.emissive.setHex(0x000000); + } + }); + const objectBoundingBox = new THREE.Box3().setFromObject(obj); + this.objectHeight = objectBoundingBox.max.y - objectBoundingBox.min.y; + + const objectWidth = objectBoundingBox.max.x - objectBoundingBox.min.x; + const objectDepth = objectBoundingBox.max.z - objectBoundingBox.min.z; + + const horizontal = Math.hypot(objectWidth, objectDepth) / camera.aspect; + this.orbitRadius = Math.max(horizontal, this.objectHeight) * camera.zoom * 0.625 / Math.tan(camera.fov * 0.5 * (Math.PI / 180)); + + scene.add(obj); + }; + + render(); + }, +}); +</script> diff --git a/packages/client/src/pages/room/room.vue b/packages/client/src/pages/room/room.vue new file mode 100644 index 0000000000..1671bcd587 --- /dev/null +++ b/packages/client/src/pages/room/room.vue @@ -0,0 +1,285 @@ +<template> +<div class="hveuntkp"> + <div class="controller _section" v-if="objectSelected"> + <div class="_content"> + <p class="name">{{ selectedFurnitureName }}</p> + <XPreview ref="preview"/> + <template v-if="selectedFurnitureInfo.props"> + <div v-for="k in Object.keys(selectedFurnitureInfo.props)" :key="k"> + <p>{{ k }}</p> + <template v-if="selectedFurnitureInfo.props[k] === 'image'"> + <MkButton @click="chooseImage(k, $event)">{{ $ts._rooms.chooseImage }}</MkButton> + </template> + <template v-else-if="selectedFurnitureInfo.props[k] === 'color'"> + <input type="color" :value="selectedFurnitureProps ? selectedFurnitureProps[k] : null" @change="updateColor(k, $event)"/> + </template> + </div> + </template> + </div> + <div class="_content"> + <MkButton inline @click="translate()" :primary="isTranslateMode"><i class="fas fa-arrows-alt"></i> {{ $ts._rooms.translate }}</MkButton> + <MkButton inline @click="rotate()" :primary="isRotateMode"><i class="fas fa-undo"></i> {{ $ts._rooms.rotate }}</MkButton> + <MkButton inline v-if="isTranslateMode || isRotateMode" @click="exit()"><i class="fas fa-ban"></i> {{ $ts._rooms.exit }}</MkButton> + </div> + <div class="_content"> + <MkButton @click="remove()"><i class="fas fa-trash-alt"></i> {{ $ts._rooms.remove }}</MkButton> + </div> + </div> + + <div class="menu _section" v-if="isMyRoom"> + <div class="_content"> + <MkButton @click="add()"><i class="fas fa-box-open"></i> {{ $ts._rooms.addFurniture }}</MkButton> + </div> + <div class="_content"> + <MkSelect :model-value="roomType" @update:modelValue="updateRoomType($event)"> + <template #label>{{ $ts._rooms.roomType }}</template> + <option value="default">{{ $ts._rooms._roomType.default }}</option> + <option value="washitsu">{{ $ts._rooms._roomType.washitsu }}</option> + </MkSelect> + <label v-if="roomType === 'default'"> + <span>{{ $ts._rooms.carpetColor }}</span> + <input type="color" :value="carpetColor" @change="updateCarpetColor($event)"/> + </label> + </div> + <div class="_content"> + <MkButton inline :disabled="!changed" primary @click="save()"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton inline @click="clear()"><i class="fas fa-broom"></i> {{ $ts._rooms.clear }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import { Room } from '@/scripts/room/room'; +import * as Acct from 'misskey-js/built/acct'; +import XPreview from './preview.vue'; +const storeItems = require('@/scripts/room/furnitures.json5'); +import { query as urlQuery } from '@/scripts/url'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/form/select.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +let room: Room; + +export default defineComponent({ + components: { + XPreview, + MkButton, + MkSelect, + }, + + props: { + acct: { + type: String, + required: true + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.user ? { + title: this.$ts.room, + avatar: this.user, + } : null), + user: null, + objectSelected: false, + selectedFurnitureName: null, + selectedFurnitureInfo: null, + selectedFurnitureProps: null, + roomType: null, + carpetColor: null, + isTranslateMode: false, + isRotateMode: false, + isMyRoom: false, + changed: false, + }; + }, + + async mounted() { + window.addEventListener('beforeunload', this.beforeunload); + + this.user = await os.api('users/show', { + ...Acct.parse(this.acct) + }); + + this.isMyRoom = this.$i && (this.$i.id === this.user.id); + + const roomInfo = await os.api('room/show', { + userId: this.user.id + }); + + this.roomType = roomInfo.roomType; + this.carpetColor = roomInfo.carpetColor; + + room = new Room(this.user, this.isMyRoom, roomInfo, this.$el, { + graphicsQuality: ColdDeviceStorage.get('roomGraphicsQuality'), + onChangeSelect: obj => { + this.objectSelected = obj != null; + if (obj) { + const f = room.findFurnitureById(obj.name); + this.selectedFurnitureName = this.$t('_rooms._furnitures.' + f.type); + this.selectedFurnitureInfo = storeItems.find(x => x.id === f.type); + this.selectedFurnitureProps = f.props + ? JSON.parse(JSON.stringify(f.props)) // Disable reactivity + : null; + this.$nextTick(() => { + this.$refs.preview.selected(obj); + }); + } + }, + useOrthographicCamera: ColdDeviceStorage.get('roomUseOrthographicCamera'), + }); + }, + + beforeRouteLeave(to, from, next) { + if (this.changed) { + os.dialog({ + type: 'warning', + text: this.$ts.leaveConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) { + next(false); + } else { + next(); + } + }); + } else { + next(); + } + }, + + beforeUnmount() { + room.destroy(); + window.removeEventListener('beforeunload', this.beforeunload); + }, + + methods: { + beforeunload(e: BeforeUnloadEvent) { + if (this.changed) { + e.preventDefault(); + e.returnValue = ''; + } + }, + + async add() { + const { canceled, result: id } = await os.dialog({ + type: null, + title: this.$ts._rooms.addFurniture, + select: { + items: storeItems.map(item => ({ + value: item.id, text: this.$t('_rooms._furnitures.' + item.id) + })) + }, + showCancelButton: true + }); + if (canceled) return; + room.addFurniture(id); + this.changed = true; + }, + + remove() { + this.isTranslateMode = false; + this.isRotateMode = false; + room.removeFurniture(); + this.changed = true; + }, + + save() { + os.api('room/update', { + room: room.getRoomInfo() + }).then(() => { + this.changed = false; + os.success(); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + }, + + clear() { + os.dialog({ + type: 'warning', + text: this.$ts._rooms.clearConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + room.removeAllFurnitures(); + this.changed = true; + }); + }, + + chooseImage(key, e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + room.updateProp(key, `/proxy/?${urlQuery({ url: file.thumbnailUrl })}`); + this.$refs.preview.selected(room.getSelectedObject()); + this.changed = true; + }); + }, + + updateColor(key, ev) { + room.updateProp(key, ev.target.value); + this.$refs.preview.selected(room.getSelectedObject()); + this.changed = true; + }, + + updateCarpetColor(ev) { + room.updateCarpetColor(ev.target.value); + this.carpetColor = ev.target.value; + this.changed = true; + }, + + updateRoomType(type) { + room.changeRoomType(type); + this.roomType = type; + this.changed = true; + }, + + translate() { + if (this.isTranslateMode) { + this.exit(); + } else { + this.isRotateMode = false; + this.isTranslateMode = true; + room.enterTransformMode('translate'); + } + this.changed = true; + }, + + rotate() { + if (this.isRotateMode) { + this.exit(); + } else { + this.isTranslateMode = false; + this.isRotateMode = true; + room.enterTransformMode('rotate'); + } + this.changed = true; + }, + + exit() { + this.isTranslateMode = false; + this.isRotateMode = false; + room.exitTransformMode(); + this.changed = true; + } + } +}); +</script> + +<style lang="scss" scoped> +.hveuntkp { + position: relative; + min-height: 500px; + + > ::v-deep(canvas) { + display: block; + } +} +</style> diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue new file mode 100644 index 0000000000..c26658cbc4 --- /dev/null +++ b/packages/client/src/pages/scratchpad.vue @@ -0,0 +1,149 @@ +<template> +<div class="iltifgqe"> + <div class="editor _panel _gap"> + <PrismEditor class="_code code" v-model="code" :highlight="highlighter" :line-numbers="false"/> + <MkButton style="position: absolute; top: 8px; right: 8px;" @click="run()" primary><i class="fas fa-play"></i></MkButton> + </div> + + <MkContainer :foldable="true" class="_gap"> + <template #header>{{ $ts.output }}</template> + <div class="bepmlvbi"> + <div v-for="log in logs" class="log" :key="log.id" :class="{ print: log.print }">{{ log.text }}</div> + </div> + </MkContainer> + + <div class="_gap"> + {{ $ts.scratchpadDescription }} + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import 'prismjs'; +import { highlight, languages } from 'prismjs/components/prism-core'; +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/themes/prism-okaidia.css'; +import { PrismEditor } from 'vue-prism-editor'; +import 'vue-prism-editor/dist/prismeditor.min.css'; +import { AiScript, parse, utils, values } from '@syuilo/aiscript'; +import MkContainer from '@/components/ui/container.vue'; +import MkButton from '@/components/ui/button.vue'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkContainer, + MkButton, + PrismEditor, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.scratchpad, + icon: 'fas fa-terminal', + }, + code: '', + logs: [], + } + }, + + watch: { + code() { + localStorage.setItem('scratchpad', this.code); + } + }, + + created() { + const saved = localStorage.getItem('scratchpad'); + if (saved) { + this.code = saved; + } + }, + + methods: { + async run() { + this.logs = []; + const aiscript = new AiScript(createAiScriptEnv({ + storageKey: 'scratchpad', + token: this.$i?.token, + }), { + in: (q) => { + return new Promise(ok => { + os.dialog({ + title: q, + input: {} + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + this.logs.push({ + id: Math.random(), + text: value.type === 'str' ? value.value : utils.valToString(value), + print: true + }); + }, + log: (type, params) => { + switch (type) { + case 'end': this.logs.push({ + id: Math.random(), + text: utils.valToString(params.val, true), + print: false + }); break; + default: break; + } + } + }); + + let ast; + try { + ast = parse(this.code); + } catch (e) { + os.dialog({ + type: 'error', + text: 'Syntax error :(' + }); + return; + } + try { + await aiscript.exec(ast); + } catch (e) { + os.dialog({ + type: 'error', + text: e + }); + } + }, + + highlighter(code) { + return highlight(code, languages.js, 'javascript'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.iltifgqe { + padding: 16px; + + > .editor { + position: relative; + } +} + +.bepmlvbi { + padding: 16px; + + > .log { + &:not(.print) { + opacity: 0.7; + } + } +} +</style> diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue new file mode 100644 index 0000000000..c7da3fe1c1 --- /dev/null +++ b/packages/client/src/pages/search.vue @@ -0,0 +1,53 @@ +<template> +<div class="_section"> + <div class="_content"> + <XNotes ref="notes" :pagination="pagination" @before="before" @after="after"/> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: computed(() => this.$t('searchWith', { q: this.$route.query.q })), + icon: 'fas fa-search', + }, + pagination: { + endpoint: 'notes/search', + limit: 10, + params: () => ({ + query: this.$route.query.q, + channelId: this.$route.query.channel, + }) + }, + }; + }, + + watch: { + $route() { + (this.$refs.notes as any).reload(); + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/2fa.vue b/packages/client/src/pages/settings/2fa.vue new file mode 100644 index 0000000000..dce217559a --- /dev/null +++ b/packages/client/src/pages/settings/2fa.vue @@ -0,0 +1,247 @@ +<template> +<section class="_card"> + <div class="_title"><i class="fas fa-lock"></i> {{ $ts.twoStepAuthentication }}</div> + <div class="_content"> + <MkButton v-if="!data && !$i.twoFactorEnabled" @click="register">{{ $ts._2fa.registerDevice }}</MkButton> + <template v-if="$i.twoFactorEnabled"> + <p>{{ $ts._2fa.alreadyRegistered }}</p> + <MkButton @click="unregister">{{ $ts.unregister }}</MkButton> + + <template v-if="supportsCredentials"> + <hr class="totp-method-sep"> + + <h2 class="heading">{{ $ts.securityKey }}</h2> + <p>{{ $ts._2fa.securityKeyInfo }}</p> + <div class="key-list"> + <div class="key" v-for="key in $i.securityKeysList"> + <h3>{{ key.name }}</h3> + <div class="last-used">{{ $ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> + <MkButton @click="unregisterKey(key)">{{ $ts.unregister }}</MkButton> + </div> + </div> + + <MkSwitch v-model="usePasswordLessLogin" @update:modelValue="updatePasswordLessLogin" v-if="$i.securityKeysList.length > 0">{{ $ts.passwordLessLogin }}</MkSwitch> + + <MkInfo warn v-if="registration && registration.error">{{ $ts.error }} {{ registration.error }}</MkInfo> + <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ $ts._2fa.registerKey }}</MkButton> + + <ol v-if="registration && !registration.error"> + <li v-if="registration.stage >= 0"> + {{ $ts.tapSecurityKey }} + <i v-if="registration.saving && registration.stage == 0" class="fas fa-spinner fa-pulse fa-fw"></i> + </li> + <li v-if="registration.stage >= 1"> + <MkForm :disabled="registration.stage != 1 || registration.saving"> + <MkInput v-model="keyName" :max="30"> + <template #label>{{ $ts.securityKeyName }}</template> + </MkInput> + <MkButton @click="registerKey" :disabled="keyName.length == 0">{{ $ts.registerSecurityKey }}</MkButton> + <i v-if="registration.saving && registration.stage == 1" class="fas fa-spinner fa-pulse fa-fw"></i> + </MkForm> + </li> + </ol> + </template> + </template> + <div v-if="data && !$i.twoFactorEnabled"> + <ol style="margin: 0; padding: 0 0 0 1em;"> + <li> + <I18n :src="$ts._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> + </li> + <li>{{ $ts._2fa.step2 }}<br><img :src="data.qr"></li> + <li>{{ $ts._2fa.step3 }}<br> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false"><template #label>{{ $ts.token }}</template></MkInput> + <MkButton primary @click="submit">{{ $ts.done }}</MkButton> + </li> + </ol> + <MkInfo>{{ $ts._2fa.step4 }}</MkInfo> + </div> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +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/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + MkButton, MkInfo, MkInput, MkSwitch + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.twoStepAuthentication, + icon: 'fas fa-lock' + }, + data: null, + supportsCredentials: !!navigator.credentials, + usePasswordLessLogin: this.$i.usePasswordLessLogin, + registration: null, + keyName: '', + token: null, + }; + }, + + methods: { + register() { + os.dialog({ + title: this.$ts.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.$ts.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.$i.twoFactorEnabled = false; + }); + }); + }, + + submit() { + os.api('i/2fa/done', { + token: this.token + }).then(() => { + os.success(); + this.$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.$ts.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.$ts.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.$i.id, 'ascii'), + name: this.$i.username, + displayName: this.$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> diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue new file mode 100644 index 0000000000..f3d5e2f2c3 --- /dev/null +++ b/packages/client/src/pages/settings/account-info.vue @@ -0,0 +1,185 @@ +<template> +<FormBase> + <FormKeyValueView> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $i.id }}</span></template> + </FormKeyValueView> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.registeredDate }}</template> + <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> + </FormKeyValueView> + </FormGroup> + + <FormGroup v-if="stats"> + <template #label>{{ $ts.statistics }}</template> + <FormKeyValueView> + <template #key>{{ $ts.notesCount }}</template> + <template #value>{{ number(stats.notesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.repliesCount }}</template> + <template #value>{{ number(stats.repliesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.renotesCount }}</template> + <template #value>{{ number(stats.renotesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.repliedCount }}</template> + <template #value>{{ number(stats.repliedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.renotedCount }}</template> + <template #value>{{ number(stats.renotedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pollVotesCount }}</template> + <template #value>{{ number(stats.pollVotesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pollVotedCount }}</template> + <template #value>{{ number(stats.pollVotedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.sentReactionsCount }}</template> + <template #value>{{ number(stats.sentReactionsCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.receivedReactionsCount }}</template> + <template #value>{{ number(stats.receivedReactionsCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.noteFavoritesCount }}</template> + <template #value>{{ number(stats.noteFavoritesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followingCount }}</template> + <template #value>{{ number(stats.followingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followingCount }} ({{ $ts.local }})</template> + <template #value>{{ number(stats.localFollowingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followingCount }} ({{ $ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followersCount }}</template> + <template #value>{{ number(stats.followersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followersCount }} ({{ $ts.local }})</template> + <template #value>{{ number(stats.localFollowersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followersCount }} ({{ $ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pageLikesCount }}</template> + <template #value>{{ number(stats.pageLikesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pageLikedCount }}</template> + <template #value>{{ number(stats.pageLikedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.driveFilesCount }}</template> + <template #value>{{ number(stats.driveFilesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.driveUsage }}</template> + <template #value>{{ bytes(stats.driveUsage) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.reversiCount }}</template> + <template #value>{{ number(stats.reversiCount) }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <template #label>{{ $ts.other }}</template> + <FormKeyValueView> + <template #key>emailVerified</template> + <template #value>{{ $i.emailVerified ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>twoFactorEnabled</template> + <template #value>{{ $i.twoFactorEnabled ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>securityKeys</template> + <template #value>{{ $i.securityKeys ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>usePasswordLessLogin</template> + <template #value>{{ $i.usePasswordLessLogin ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>isModerator</template> + <template #value>{{ $i.isModerator ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>isAdmin</template> + <template #value>{{ $i.isAdmin ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.accountInfo, + icon: 'fas fa-info-circle' + }, + stats: null + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + os.api('users/stats', { + userId: this.$i.id + }).then(stats => { + this.stats = stats; + }); + }, + + methods: { + number, + bytes, + } +}); +</script> diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue new file mode 100644 index 0000000000..94a3c9483d --- /dev/null +++ b/packages/client/src/pages/settings/accounts.vue @@ -0,0 +1,149 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormButton @click="addAccount" primary><i class="fas fa-plus"></i> {{ $ts.addAccount }}</FormButton> + + <div class="_debobigegoItem _button" v-for="account in accounts" :key="account.id" @click="menu(account, $event)"> + <div class="_debobigegoPanel lcjjdxlm"> + <div class="avatar"> + <MkAvatar :user="account" class="avatar"/> + </div> + <div class="body"> + <div class="name"> + <MkUserName :user="account"/> + </div> + <div class="acct"> + <MkAcct :user="account"/> + </div> + </div> + </div> + </div> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { getAccounts, addAccount, login } from '@/account'; + +export default defineComponent({ + components: { + FormBase, + FormSuspense, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.accounts, + icon: 'fas fa-users', + bg: 'var(--bg)', + }, + storedAccounts: getAccounts().then(accounts => accounts.filter(x => x.id !== this.$i.id)), + accounts: null, + init: async () => os.api('users/show', { + userIds: (await this.storedAccounts).map(x => x.id) + }).then(accounts => { + this.accounts = accounts; + }), + }; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + menu(account, ev) { + os.popupMenu([{ + text: this.$ts.switch, + icon: 'fas fa-exchange-alt', + action: () => this.switchAccount(account), + }, { + text: this.$ts.remove, + icon: 'fas fa-trash-alt', + danger: true, + action: () => this.removeAccount(account), + }], ev.currentTarget || ev.target); + }, + + addAccount(ev) { + os.popupMenu([{ + text: this.$ts.existingAccount, + action: () => { this.addExistingAccount(); }, + }, { + text: this.$ts.createAccount, + action: () => { this.createAccount(); }, + }], ev.currentTarget || ev.target); + }, + + addExistingAccount() { + os.popup(import('@/components/signin-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + os.success(); + }, + }, 'closed'); + }, + + createAccount() { + os.popup(import('@/components/signup-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + this.switchAccountWithToken(res.i); + }, + }, 'closed'); + }, + + async switchAccount(account: any) { + const storedAccounts = await getAccounts(); + const token = storedAccounts.find(x => x.id === account.id).token; + this.switchAccountWithToken(token); + }, + + switchAccountWithToken(token: string) { + login(token); + }, + } +}); +</script> + +<style lang="scss" scoped> +.lcjjdxlm { + display: flex; + padding: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + + > .avatar { + width: 50px; + height: 50px; + } + } + + > .body { + display: flex; + flex-direction: column; + justify-content: center; + width: calc(100% - 62px); + position: relative; + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue new file mode 100644 index 0000000000..1def0189ec --- /dev/null +++ b/packages/client/src/pages/settings/api.vue @@ -0,0 +1,65 @@ +<template> +<FormBase> + <FormButton @click="generateToken" primary>{{ $ts.generateAccessToken }}</FormButton> + <FormLink to="/settings/apps">{{ $ts.manageAccessTokens }}</FormLink> + <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'API', + icon: 'fas fa-key', + bg: 'var(--bg)', + }, + isDesktop: window.innerWidth >= 1100, + }; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + generateToken() { + os.popup(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.$ts.token, + text: token + }); + }, + }, 'closed'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue new file mode 100644 index 0000000000..6eec80d805 --- /dev/null +++ b/packages/client/src/pages/settings/apps.vue @@ -0,0 +1,113 @@ +<template> +<FormBase> + <FormPagination :pagination="pagination" ref="list"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.nothing }}</div> + </div> + </template> + <template #default="{items}"> + <div class="_debobigegoPanel bfomjevm" v-for="token in items" :key="token.id"> + <img class="icon" :src="token.iconUrl" alt="" v-if="token.iconUrl"/> + <div class="body"> + <div class="name">{{ token.name }}</div> + <div class="description">{{ token.description }}</div> + <div class="_keyValue"> + <div>{{ $ts.installedDate }}:</div> + <div><MkTime :time="token.createdAt"/></div> + </div> + <div class="_keyValue"> + <div>{{ $ts.lastUsedDate }}:</div> + <div><MkTime :time="token.lastUsedAt"/></div> + </div> + <div class="actions"> + <button class="_button" @click="revoke(token)"><i class="fas fa-trash-alt"></i></button> + </div> + <details> + <summary>{{ $ts.details }}</summary> + <ul> + <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </details> + </div> + </div> + </template> + </FormPagination> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormPagination from '@/components/debobigego/pagination.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.installedApps, + icon: 'fas fa-plug', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'i/apps', + limit: 100, + params: { + sort: '+lastUsedAt' + } + }, + }; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + revoke(token) { + os.api('i/revoke-token', { tokenId: token.id }).then(() => { + this.$refs.list.reload(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.bfomjevm { + display: flex; + padding: 16px; + + > .icon { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 50px; + height: 50px; + border-radius: 8px; + } + + > .body { + width: calc(100% - 62px); + position: relative; + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue new file mode 100644 index 0000000000..8c878fb084 --- /dev/null +++ b/packages/client/src/pages/settings/custom-css.vue @@ -0,0 +1,73 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts.customCssWarn }}</FormInfo> + + <FormTextarea v-model="localCustomCss" manual-save tall class="_monospace" style="tab-size: 2;"> + <span>{{ $ts.local }}</span> + </FormTextarea> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.customCss, + icon: 'fas fa-code', + bg: 'var(--bg)', + }, + localCustomCss: localStorage.getItem('customCss') + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + this.$watch('localCustomCss', this.apply); + }, + + methods: { + async apply() { + localStorage.setItem('customCss', this.localCustomCss); + + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue new file mode 100644 index 0000000000..a96c6cd685 --- /dev/null +++ b/packages/client/src/pages/settings/deck.vue @@ -0,0 +1,107 @@ +<template> +<FormBase> + <FormGroup> + <template #label>{{ $ts.defaultNavigationBehaviour }}</template> + <FormSwitch v-model="navWindow">{{ $ts.openInWindow }}</FormSwitch> + </FormGroup> + + <FormSwitch v-model="alwaysShowMainColumn">{{ $ts._deck.alwaysShowMainColumn }}</FormSwitch> + + <FormRadios v-model="columnAlign"> + <template #desc>{{ $ts._deck.columnAlign }}</template> + <option value="left">{{ $ts.left }}</option> + <option value="center">{{ $ts.center }}</option> + </FormRadios> + + <FormRadios v-model="columnHeaderHeight"> + <template #desc>{{ $ts._deck.columnHeaderHeight }}</template> + <option :value="42">{{ $ts.narrow }}</option> + <option :value="45">{{ $ts.medium }}</option> + <option :value="48">{{ $ts.wide }}</option> + </FormRadios> + + <FormInput v-model="columnMargin" type="number"> + <span>{{ $ts._deck.columnMargin }}</span> + <template #suffix>px</template> + </FormInput> + + <FormLink @click="setProfile">{{ $ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { deckStore } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormSwitch, + FormLink, + FormInput, + FormRadios, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.deck, + icon: 'fas fa-columns', + bg: 'var(--bg)', + }, + } + }, + + computed: { + navWindow: deckStore.makeGetterSetter('navWindow'), + alwaysShowMainColumn: deckStore.makeGetterSetter('alwaysShowMainColumn'), + columnAlign: deckStore.makeGetterSetter('columnAlign'), + columnMargin: deckStore.makeGetterSetter('columnMargin'), + columnHeaderHeight: deckStore.makeGetterSetter('columnHeaderHeight'), + profile: deckStore.makeGetterSetter('profile'), + }, + + watch: { + async navWindow() { + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async setProfile() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts._deck.profile, + input: { + allowEmpty: false + } + }); + if (canceled) return; + this.profile = name; + unisonReload(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue new file mode 100644 index 0000000000..018f7c795e --- /dev/null +++ b/packages/client/src/pages/settings/delete-account.vue @@ -0,0 +1,68 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts._accountDelete.mayTakeTime }}</FormInfo> + <FormInfo>{{ $ts._accountDelete.sendEmail }}</FormInfo> + <FormButton @click="deleteAccount" danger v-if="!$i.isDeleted">{{ $ts._accountDelete.requestAccountDelete }}</FormButton> + <FormButton disabled v-else>{{ $ts._accountDelete.inProgress }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { debug } from '@/config'; +import { signout } from '@/account'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormGroup, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._accountDelete.accountDelete, + icon: 'fas fa-exclamation-triangle', + bg: 'var(--bg)', + }, + debug, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async deleteAccount() { + const { canceled, result: password } = await os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }); + if (canceled) return; + + await os.apiWithDialog('i/delete-account', { + password: password + }); + + await os.dialog({ + title: this.$ts._accountDelete.started, + }); + + signout(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue new file mode 100644 index 0000000000..ed5282e23d --- /dev/null +++ b/packages/client/src/pages/settings/drive.vue @@ -0,0 +1,147 @@ +<template> +<FormBase class=""> + <FormGroup v-if="!fetching"> + <template #label>{{ $ts.usageAmount }}</template> + <div class="_debobigegoItem uawsfosz"> + <div class="_debobigegoPanel"> + <div class="meter"><div :style="meterStyle"></div></div> + </div> + </div> + <FormKeyValueView> + <template #key>{{ $ts.capacity }}</template> + <template #value>{{ bytes(capacity, 1) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.inUse }}</template> + <template #value>{{ bytes(usage, 1) }}</template> + </FormKeyValueView> + </FormGroup> + + <div class="_debobigegoItem"> + <div class="_debobigegoLabel">{{ $ts.statistics }}</div> + <div class="_debobigegoPanel"> + <div ref="chart"></div> + </div> + </div> + + <FormButton :center="false" @click="chooseUploadFolder()" primary> + {{ $ts.uploadFolder }} + <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> + <template #suffixIcon><i class="fas fa-folder-open"></i></template> + </FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as tinycolor from 'tinycolor2'; +import FormButton from '@/components/debobigego/button.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import * as os from '@/os'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; + +// TODO: render chart + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.drive, + icon: 'fas fa-cloud', + bg: 'var(--bg)', + }, + fetching: true, + usage: null, + capacity: null, + uploadFolder: null, + } + }, + + computed: { + meterStyle(): any { + return { + width: `${this.usage / this.capacity * 100}%`, + background: tinycolor({ + h: 180 - (this.usage / this.capacity * 180), + s: 0.7, + l: 0.5 + }) + }; + } + }, + + async created() { + os.api('drive').then(info => { + this.capacity = info.capacity; + this.usage = info.usage; + this.fetching = false; + this.$nextTick(() => { + this.renderChart(); + }); + }); + + if (this.$store.state.uploadFolder) { + this.uploadFolder = await os.api('drive/folders/show', { + folderId: this.$store.state.uploadFolder + }); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + chooseUploadFolder() { + os.selectDriveFolder(false).then(async folder => { + this.$store.set('uploadFolder', folder ? folder.id : null); + os.success(); + if (this.$store.state.uploadFolder) { + this.uploadFolder = await os.api('drive/folders/show', { + folderId: this.$store.state.uploadFolder + }); + } else { + this.uploadFolder = null; + } + }); + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> + +@use "sass:math"; + +.uawsfosz { + > div { + padding: 24px; + + > .meter { + $size: 12px; + background: rgba(0, 0, 0, 0.1); + border-radius: math.div($size, 2); + overflow: hidden; + + > div { + height: $size; + border-radius: math.div($size, 2); + } + } + } +} +</style> diff --git a/packages/client/src/pages/settings/email-address.vue b/packages/client/src/pages/settings/email-address.vue new file mode 100644 index 0000000000..476d0c0e17 --- /dev/null +++ b/packages/client/src/pages/settings/email-address.vue @@ -0,0 +1,70 @@ +<template> +<FormBase> + <FormGroup> + <FormInput v-model="emailAddress" type="email"> + {{ $ts.emailAddress }} + <template #desc v-if="$i.email && !$i.emailVerified">{{ $ts.verificationEmailSent }}</template> + <template #desc v-else-if="emailAddress === $i.email && $i.emailVerified">{{ $ts.emailVerified }}</template> + </FormInput> + </FormGroup> + <FormButton @click="save" primary>{{ $ts.save }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInput from '@/components/form/input.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormInput, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.emailAddress, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + emailAddress: null, + code: null, + } + }, + + created() { + this.emailAddress = this.$i.email; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.apiWithDialog('i/update-email', { + password: password, + email: this.emailAddress, + }); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/email-notification.vue b/packages/client/src/pages/settings/email-notification.vue new file mode 100644 index 0000000000..c1735a0728 --- /dev/null +++ b/packages/client/src/pages/settings/email-notification.vue @@ -0,0 +1,91 @@ +<template> +<FormBase> + <FormGroup> + <FormSwitch v-model="mention"> + {{ $ts._notification._types.mention }} + </FormSwitch> + <FormSwitch v-model="reply"> + {{ $ts._notification._types.reply }} + </FormSwitch> + <FormSwitch v-model="quote"> + {{ $ts._notification._types.quote }} + </FormSwitch> + <FormSwitch v-model="follow"> + {{ $ts._notification._types.follow }} + </FormSwitch> + <FormSwitch v-model="receiveFollowRequest"> + {{ $ts._notification._types.receiveFollowRequest }} + </FormSwitch> + <FormSwitch v-model="groupInvited"> + {{ $ts._notification._types.groupInvited }} + </FormSwitch> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSwitch, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.emailNotification, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + + mention: this.$i.emailNotificationTypes.includes('mention'), + reply: this.$i.emailNotificationTypes.includes('reply'), + quote: this.$i.emailNotificationTypes.includes('quote'), + follow: this.$i.emailNotificationTypes.includes('follow'), + receiveFollowRequest: this.$i.emailNotificationTypes.includes('receiveFollowRequest'), + groupInvited: this.$i.emailNotificationTypes.includes('groupInvited'), + } + }, + + created() { + this.$watch('mention', this.save); + this.$watch('reply', this.save); + this.$watch('quote', this.save); + this.$watch('follow', this.save); + this.$watch('receiveFollowRequest', this.save); + this.$watch('groupInvited', this.save); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + os.api('i/update', { + emailNotificationTypes: [ + ...[this.mention ? 'mention' : null], + ...[this.reply ? 'reply' : null], + ...[this.quote ? 'quote' : null], + ...[this.follow ? 'follow' : null], + ...[this.receiveFollowRequest ? 'receiveFollowRequest' : null], + ...[this.groupInvited ? 'groupInvited' : null], + ].filter(x => x != null) + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue new file mode 100644 index 0000000000..d1dda20f00 --- /dev/null +++ b/packages/client/src/pages/settings/email.vue @@ -0,0 +1,66 @@ +<template> +<FormBase> + <FormGroup> + <template #label>{{ $ts.emailAddress }}</template> + <FormLink to="/settings/email/address"> + <template v-if="$i.email && !$i.emailVerified" #icon><i class="fas fa-exclamation-triangle" style="color: var(--warn);"></i></template> + <template v-else-if="$i.email && $i.emailVerified" #icon><i class="fas fa-check" style="color: var(--success);"></i></template> + {{ $i.email || $ts.notSet }} + </FormLink> + </FormGroup> + + <FormLink to="/settings/email/notification"> + <template #icon><i class="fas fa-bell"></i></template> + {{ $ts.emailNotification }} + </FormLink> + + <FormSwitch :value="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail"> + {{ $ts.receiveAnnouncementFromInstance }} + </FormSwitch> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormSwitch, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.email, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + onChangeReceiveAnnouncementEmail(v) { + os.api('i/update', { + receiveAnnouncementEmail: v + }); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/experimental-features.vue b/packages/client/src/pages/settings/experimental-features.vue new file mode 100644 index 0000000000..5a7bcb3b41 --- /dev/null +++ b/packages/client/src/pages/settings/experimental-features.vue @@ -0,0 +1,52 @@ +<template> +<FormBase> + <FormButton @click="error()">error test</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.experimentalFeatures, + icon: 'fas fa-flask' + }, + stats: null + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + error() { + throw new Error('Test error'); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue new file mode 100644 index 0000000000..8e3dcc3e41 --- /dev/null +++ b/packages/client/src/pages/settings/general.vue @@ -0,0 +1,223 @@ +<template> +<FormBase> + <FormSwitch v-model="showFixedPostForm">{{ $ts.showFixedPostForm }}</FormSwitch> + + <FormSelect v-model="lang"> + <template #label>{{ $ts.uiLanguage }}</template> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + <template #caption> + <I18n :src="$ts.i18nInfo" tag="span"> + <template #link> + <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> + </template> + </I18n> + </template> + </FormSelect> + + <FormGroup> + <template #label>{{ $ts.behavior }}</template> + <FormSwitch v-model="imageNewTab">{{ $ts.openImageInNewTab }}</FormSwitch> + <FormSwitch v-model="enableInfiniteScroll">{{ $ts.enableInfiniteScroll }}</FormSwitch> + <FormSwitch v-model="useReactionPickerForContextMenu">{{ $ts.useReactionPickerForContextMenu }}</FormSwitch> + <FormSwitch v-model="disablePagesScript">{{ $ts.disablePagesScript }}</FormSwitch> + </FormGroup> + + <FormSelect v-model="serverDisconnectedBehavior"> + <template #label>{{ $ts.whenServerDisconnected }}</template> + <option value="reload">{{ $ts._serverDisconnectedBehavior.reload }}</option> + <option value="dialog">{{ $ts._serverDisconnectedBehavior.dialog }}</option> + <option value="quiet">{{ $ts._serverDisconnectedBehavior.quiet }}</option> + </FormSelect> + + <FormGroup> + <template #label>{{ $ts.appearance }}</template> + <FormSwitch v-model="disableAnimatedMfm">{{ $ts.disableAnimatedMfm }}</FormSwitch> + <FormSwitch v-model="reduceAnimation">{{ $ts.reduceUiAnimation }}</FormSwitch> + <FormSwitch v-model="useBlurEffect">{{ $ts.useBlurEffect }}</FormSwitch> + <FormSwitch v-model="useBlurEffectForModal">{{ $ts.useBlurEffectForModal }}</FormSwitch> + <FormSwitch v-model="showGapBetweenNotesInTimeline">{{ $ts.showGapBetweenNotesInTimeline }}</FormSwitch> + <FormSwitch v-model="loadRawImages">{{ $ts.loadRawImages }}</FormSwitch> + <FormSwitch v-model="disableShowingAnimatedImages">{{ $ts.disableShowingAnimatedImages }}</FormSwitch> + <FormSwitch v-model="squareAvatars">{{ $ts.squareAvatars }}</FormSwitch> + <FormSwitch v-model="useSystemFont">{{ $ts.useSystemFont }}</FormSwitch> + <FormSwitch v-model="useOsNativeEmojis">{{ $ts.useOsNativeEmojis }} + <div><Mfm text="๐ฎ๐ฆ๐ญ๐ฉ๐ฐ๐ซ๐ฌ๐ฅ๐ช" :key="useOsNativeEmojis"/></div> + </FormSwitch> + </FormGroup> + + <FormGroup> + <FormSwitch v-model="aiChanMode">{{ $ts.aiChanMode }}</FormSwitch> + </FormGroup> + + <FormRadios v-model="fontSize"> + <template #desc>{{ $ts.fontSize }}</template> + <option value="small"><span style="font-size: 14px;">Aa</span></option> + <option :value="null"><span style="font-size: 16px;">Aa</span></option> + <option value="large"><span style="font-size: 18px;">Aa</span></option> + <option value="veryLarge"><span style="font-size: 20px;">Aa</span></option> + </FormRadios> + + <FormSelect v-model="instanceTicker"> + <template #label>{{ $ts.instanceTicker }}</template> + <option value="none">{{ $ts._instanceTicker.none }}</option> + <option value="remote">{{ $ts._instanceTicker.remote }}</option> + <option value="always">{{ $ts._instanceTicker.always }}</option> + </FormSelect> + + <FormSelect v-model="nsfw"> + <template #label>{{ $ts.nsfw }}</template> + <option value="respect">{{ $ts._nsfw.respect }}</option> + <option value="ignore">{{ $ts._nsfw.ignore }}</option> + <option value="force">{{ $ts._nsfw.force }}</option> + </FormSelect> + + <FormGroup> + <template #label>{{ $ts.defaultNavigationBehaviour }}</template> + <FormSwitch v-model="defaultSideView">{{ $ts.openInSideView }}</FormSwitch> + </FormGroup> + + <FormSelect v-model="chatOpenBehavior"> + <template #label>{{ $ts.chatOpenBehavior }}</template> + <option value="page">{{ $ts.showInPage }}</option> + <option value="window">{{ $ts.openInWindow }}</option> + <option value="popout">{{ $ts.popout }}</option> + </FormSelect> + + <FormLink to="/settings/deck">{{ $ts.deck }}</FormLink> + + <FormLink to="/settings/custom-css"><template #icon><i class="fas fa-code"></i></template>{{ $ts.customCss }}</FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import MkLink from '@/components/link.vue'; +import { langs } from '@/config'; +import { defaultStore } from '@/store'; +import { ColdDeviceStorage } from '@/store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkLink, + FormSwitch, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.general, + icon: 'fas fa-cogs', + bg: 'var(--bg)' + }, + langs, + lang: localStorage.getItem('lang'), + fontSize: localStorage.getItem('fontSize'), + useSystemFont: localStorage.getItem('useSystemFont') != null, + } + }, + + computed: { + serverDisconnectedBehavior: defaultStore.makeGetterSetter('serverDisconnectedBehavior'), + reduceAnimation: defaultStore.makeGetterSetter('animation', v => !v, v => !v), + useBlurEffectForModal: defaultStore.makeGetterSetter('useBlurEffectForModal'), + useBlurEffect: defaultStore.makeGetterSetter('useBlurEffect'), + showGapBetweenNotesInTimeline: defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'), + disableAnimatedMfm: defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v), + useOsNativeEmojis: defaultStore.makeGetterSetter('useOsNativeEmojis'), + disableShowingAnimatedImages: defaultStore.makeGetterSetter('disableShowingAnimatedImages'), + loadRawImages: defaultStore.makeGetterSetter('loadRawImages'), + imageNewTab: defaultStore.makeGetterSetter('imageNewTab'), + nsfw: defaultStore.makeGetterSetter('nsfw'), + disablePagesScript: defaultStore.makeGetterSetter('disablePagesScript'), + showFixedPostForm: defaultStore.makeGetterSetter('showFixedPostForm'), + defaultSideView: defaultStore.makeGetterSetter('defaultSideView'), + chatOpenBehavior: ColdDeviceStorage.makeGetterSetter('chatOpenBehavior'), + instanceTicker: defaultStore.makeGetterSetter('instanceTicker'), + enableInfiniteScroll: defaultStore.makeGetterSetter('enableInfiniteScroll'), + useReactionPickerForContextMenu: defaultStore.makeGetterSetter('useReactionPickerForContextMenu'), + squareAvatars: defaultStore.makeGetterSetter('squareAvatars'), + aiChanMode: defaultStore.makeGetterSetter('aiChanMode'), + }, + + watch: { + lang() { + localStorage.setItem('lang', this.lang); + localStorage.removeItem('locale'); + this.reloadAsk(); + }, + + fontSize() { + if (this.fontSize == null) { + localStorage.removeItem('fontSize'); + } else { + localStorage.setItem('fontSize', this.fontSize); + } + this.reloadAsk(); + }, + + useSystemFont() { + if (this.useSystemFont) { + localStorage.setItem('useSystemFont', 't'); + } else { + localStorage.removeItem('useSystemFont'); + } + this.reloadAsk(); + }, + + enableInfiniteScroll() { + this.reloadAsk(); + }, + + squareAvatars() { + this.reloadAsk(); + }, + + aiChanMode() { + this.reloadAsk(); + }, + + showGapBetweenNotesInTimeline() { + this.reloadAsk(); + }, + + instanceTicker() { + this.reloadAsk(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async reloadAsk() { + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue new file mode 100644 index 0000000000..8923483b98 --- /dev/null +++ b/packages/client/src/pages/settings/import-export.vue @@ -0,0 +1,112 @@ +<template> +<div style="margin: 16px;"> + <FormSection> + <template #label>{{ $ts._exportOrImport.allNotes }}</template> + <MkButton :class="$style.button" inline @click="doExport('notes')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.followingList }}</template> + <MkButton :class="$style.button" inline @click="doExport('following')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('following', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.userLists }}</template> + <MkButton :class="$style.button" inline @click="doExport('user-lists')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('user-lists', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.muteList }}</template> + <MkButton :class="$style.button" inline @click="doExport('muting')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('muting', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.blockingList }}</template> + <MkButton :class="$style.button" inline @click="doExport('blocking')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('blocking', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormSection, + MkButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.importAndExport, + icon: 'fas fa-boxes', + bg: 'var(--bg)', + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + doExport(target) { + os.api( + target === 'notes' ? 'i/export-notes' : + target === 'following' ? 'i/export-following' : + target === 'blocking' ? 'i/export-blocking' : + target === 'user-lists' ? 'i/export-user-lists' : + target === 'muting' ? 'i/export-mute' : + null, {}) + .then(() => { + os.dialog({ + type: 'info', + text: this.$ts.exportRequested + }); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + }, + + async doImport(target, e) { + const file = await selectFile(e.currentTarget || e.target); + + os.api( + target === 'following' ? 'i/import-following' : + target === 'user-lists' ? 'i/import-user-lists' : + target === 'muting' ? 'i/import-muting' : + target === 'blocking' ? 'i/import-blocking' : + null, { + fileId: file.id + }).then(() => { + os.dialog({ + type: 'info', + text: this.$ts.importRequested + }); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + }, + } +}); +</script> + +<style module> +.button { + margin-right: 16px; +} +</style> diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue new file mode 100644 index 0000000000..b9d3903269 --- /dev/null +++ b/packages/client/src/pages/settings/index.vue @@ -0,0 +1,326 @@ +<template> +<div class="vvcocwet" :class="{ wide: !narrow }" ref="el"> + <div class="nav" v-if="!narrow || page == null"> + <MkSpacer :content-max="700"> + <div class="baaadecd"> + <div class="title">{{ $ts.settings }}</div> + <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu> + </div> + </MkSpacer> + </div> + <div class="main"> + <component :is="component" :key="page" v-bind="pageProps"/> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkInfo from '@/components/ui/info.vue'; +import MkSuperMenu from '@/components/ui/super-menu.vue'; +import { scroll } from '@/scripts/scroll'; +import { signout } from '@/account'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; +import { instance } from '@/instance'; +import { $i } from '@/account'; + +export default defineComponent({ + components: { + MkInfo, + MkSuperMenu, + }, + + props: { + initialPage: { + type: String, + required: false + } + }, + + setup(props, context) { + const indexInfo = { + title: i18n.locale.settings, + icon: 'fas fa-cog', + bg: 'var(--bg)', + hideHeader: true, + }; + const INFO = ref(indexInfo); + const page = ref(props.initialPage); + const narrow = ref(false); + const view = ref(null); + const el = ref(null); + const menuDef = computed(() => [{ + title: i18n.locale.basicSettings, + items: [{ + icon: 'fas fa-user', + text: i18n.locale.profile, + to: '/settings/profile', + active: page.value === 'profile', + }, { + icon: 'fas fa-lock-open', + text: i18n.locale.privacy, + to: '/settings/privacy', + active: page.value === 'privacy', + }, { + icon: 'fas fa-laugh', + text: i18n.locale.reaction, + to: '/settings/reaction', + active: page.value === 'reaction', + }, { + icon: 'fas fa-cloud', + text: i18n.locale.drive, + to: '/settings/drive', + active: page.value === 'drive', + }, { + icon: 'fas fa-bell', + text: i18n.locale.notifications, + to: '/settings/notifications', + active: page.value === 'notifications', + }, { + icon: 'fas fa-envelope', + text: i18n.locale.email, + to: '/settings/email', + active: page.value === 'email', + }, { + icon: 'fas fa-share-alt', + text: i18n.locale.integration, + to: '/settings/integration', + active: page.value === 'integration', + }, { + icon: 'fas fa-lock', + text: i18n.locale.security, + to: '/settings/security', + active: page.value === 'security', + }], + }, { + title: i18n.locale.clientSettings, + items: [{ + icon: 'fas fa-cogs', + text: i18n.locale.general, + to: '/settings/general', + active: page.value === 'general', + }, { + icon: 'fas fa-palette', + text: i18n.locale.theme, + to: '/settings/theme', + active: page.value === 'theme', + }, { + icon: 'fas fa-list-ul', + text: i18n.locale.menu, + to: '/settings/menu', + active: page.value === 'menu', + }, { + icon: 'fas fa-music', + text: i18n.locale.sounds, + to: '/settings/sounds', + active: page.value === 'sounds', + }, { + icon: 'fas fa-plug', + text: i18n.locale.plugins, + to: '/settings/plugin', + active: page.value === 'plugin', + }], + }, { + title: i18n.locale.otherSettings, + items: [{ + icon: 'fas fa-boxes', + text: i18n.locale.importAndExport, + to: '/settings/import-export', + active: page.value === 'import-export', + }, { + icon: 'fas fa-ban', + text: i18n.locale.muteAndBlock, + to: '/settings/mute-block', + active: page.value === 'mute-block', + }, { + icon: 'fas fa-comment-slash', + text: i18n.locale.wordMute, + to: '/settings/word-mute', + active: page.value === 'word-mute', + }, { + icon: 'fas fa-key', + text: 'API', + to: '/settings/api', + active: page.value === 'api', + }, { + icon: 'fas fa-ellipsis-h', + text: i18n.locale.other, + to: '/settings/other', + active: page.value === 'other', + }], + }, { + items: [{ + type: 'button', + icon: 'fas fa-trash', + text: i18n.locale.clearCache, + action: () => { + localStorage.removeItem('locale'); + localStorage.removeItem('theme'); + unisonReload(); + }, + }, { + type: 'button', + icon: 'fas fa-sign-in-alt fa-flip-horizontal', + text: i18n.locale.logout, + action: () => { + signout(); + }, + danger: true, + },], + }]); + + const pageProps = ref({}); + const component = computed(() => { + if (page.value == null) return null; + switch (page.value) { + case 'accounts': return defineAsyncComponent(() => import('./accounts.vue')); + case 'profile': return defineAsyncComponent(() => import('./profile.vue')); + case 'privacy': return defineAsyncComponent(() => import('./privacy.vue')); + case 'reaction': return defineAsyncComponent(() => import('./reaction.vue')); + case 'drive': return defineAsyncComponent(() => import('./drive.vue')); + case 'notifications': return defineAsyncComponent(() => import('./notifications.vue')); + case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue')); + case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue')); + case 'integration': return defineAsyncComponent(() => import('./integration.vue')); + case 'security': return defineAsyncComponent(() => import('./security.vue')); + case '2fa': return defineAsyncComponent(() => import('./2fa.vue')); + case 'api': return defineAsyncComponent(() => import('./api.vue')); + case 'apps': return defineAsyncComponent(() => import('./apps.vue')); + case 'other': return defineAsyncComponent(() => import('./other.vue')); + case 'general': return defineAsyncComponent(() => import('./general.vue')); + case 'email': return defineAsyncComponent(() => import('./email.vue')); + case 'email/address': return defineAsyncComponent(() => import('./email-address.vue')); + case 'email/notification': return defineAsyncComponent(() => import('./email-notification.vue')); + case 'theme': return defineAsyncComponent(() => import('./theme.vue')); + case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); + case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); + case 'menu': return defineAsyncComponent(() => import('./menu.vue')); + case 'sounds': return defineAsyncComponent(() => import('./sounds.vue')); + case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue')); + case 'deck': return defineAsyncComponent(() => import('./deck.vue')); + case 'plugin': return defineAsyncComponent(() => import('./plugin.vue')); + case 'plugin/install': return defineAsyncComponent(() => import('./plugin.install.vue')); + case 'plugin/manage': return defineAsyncComponent(() => import('./plugin.manage.vue')); + case 'import-export': return defineAsyncComponent(() => import('./import-export.vue')); + case 'account-info': return defineAsyncComponent(() => import('./account-info.vue')); + case 'update': return defineAsyncComponent(() => import('./update.vue')); + case 'registry': return defineAsyncComponent(() => import('./registry.vue')); + case 'delete-account': return defineAsyncComponent(() => import('./delete-account.vue')); + case 'experimental-features': return defineAsyncComponent(() => import('./experimental-features.vue')); + } + if (page.value.startsWith('registry/keys/system/')) { + return defineAsyncComponent(() => import('./registry.keys.vue')); + } + if (page.value.startsWith('registry/value/system/')) { + return defineAsyncComponent(() => import('./registry.value.vue')); + } + }); + + watch(component, () => { + pageProps.value = {}; + + if (page.value) { + if (page.value.startsWith('registry/keys/system/')) { + pageProps.value.scope = page.value.replace('registry/keys/system/', '').split('/'); + } + if (page.value.startsWith('registry/value/system/')) { + const path = page.value.replace('registry/value/system/', '').split('/'); + pageProps.value.xKey = path.pop(); + pageProps.value.scope = path; + } + } + + nextTick(() => { + scroll(el.value, { top: 0 }); + }); + }, { immediate: true }); + + watch(() => props.initialPage, () => { + if (props.initialPage == null && !narrow.value) { + page.value = 'profile'; + } else { + page.value = props.initialPage; + if (props.initialPage == null) { + INFO.value = indexInfo; + } + } + }); + + onMounted(() => { + narrow.value = el.value.offsetWidth < 800; + if (!narrow.value) { + page.value = 'profile'; + } + }); + + const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); + + return { + [symbols.PAGE_INFO]: INFO, + page, + menuDef, + narrow, + view, + el, + pageProps, + component, + emailNotConfigured, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.vvcocwet { + > .nav { + .baaadecd { + > .title { + margin: 16px; + font-size: 1.5em; + font-weight: bold; + } + + > .info { + margin: 0 16px; + } + + > .accounts { + > .avatar { + display: block; + width: 50px; + height: 50px; + margin: 8px auto 16px auto; + } + } + } + } + + &.wide { + display: flex; + max-width: 1000px; + margin: 0 auto; + height: 100%; + + > .nav { + width: 32%; + box-sizing: border-box; + overflow: auto; + + .baaadecd { + > .title { + margin: 24px 0; + } + } + } + + > .main { + flex: 1; + min-width: 0; + overflow: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue new file mode 100644 index 0000000000..405f93b779 --- /dev/null +++ b/packages/client/src/pages/settings/integration.vue @@ -0,0 +1,141 @@ +<template> +<FormBase> + <div class="_debobigegoItem" v-if="enableTwitterIntegration"> + <div class="_debobigegoLabel"><i class="fab fa-twitter"></i> Twitter</div> + <div class="_debobigegoPanel" style="padding: 16px;"> + <p v-if="integrations.twitter">{{ $ts.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" danger>{{ $ts.disconnectService }}</MkButton> + <MkButton v-else @click="connectTwitter" primary>{{ $ts.connectService }}</MkButton> + </div> + </div> + + <div class="_debobigegoItem" v-if="enableDiscordIntegration"> + <div class="_debobigegoLabel"><i class="fab fa-discord"></i> Discord</div> + <div class="_debobigegoPanel" style="padding: 16px;"> + <p v-if="integrations.discord">{{ $ts.connectedTo }}: <a :href="`https://discord.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" danger>{{ $ts.disconnectService }}</MkButton> + <MkButton v-else @click="connectDiscord" primary>{{ $ts.connectService }}</MkButton> + </div> + </div> + + <div class="_debobigegoItem" v-if="enableGithubIntegration"> + <div class="_debobigegoLabel"><i class="fab fa-github"></i> GitHub</div> + <div class="_debobigegoPanel" style="padding: 16px;"> + <p v-if="integrations.github">{{ $ts.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" danger>{{ $ts.disconnectService }}</MkButton> + <MkButton v-else @click="connectGithub" primary>{{ $ts.connectService }}</MkButton> + </div> + </div> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { apiUrl } from '@/config'; +import FormBase from '@/components/debobigego/base.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + MkButton + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.integration, + icon: 'fas fa-share-alt', + bg: 'var(--bg)', + }, + apiUrl, + twitterForm: null, + discordForm: null, + githubForm: null, + enableTwitterIntegration: false, + enableDiscordIntegration: false, + enableGithubIntegration: false, + }; + }, + + computed: { + integrations() { + return this.$i.integrations; + }, + + meta() { + return this.$instance; + }, + }, + + created() { + this.enableTwitterIntegration = this.meta.enableTwitterIntegration; + this.enableDiscordIntegration = this.meta.enableDiscordIntegration; + this.enableGithubIntegration = this.meta.enableGithubIntegration; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + document.cookie = `igi=${this.$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> diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/menu.vue new file mode 100644 index 0000000000..e40740a3a4 --- /dev/null +++ b/packages/client/src/pages/settings/menu.vue @@ -0,0 +1,117 @@ +<template> +<FormBase> + <FormTextarea v-model="items" tall manual-save> + <span>{{ $ts.menu }}</span> + <template #desc><button class="_textButton" @click="addItem">{{ $ts.addItem }}</button></template> + </FormTextarea> + + <FormRadios v-model="menuDisplay"> + <template #desc>{{ $ts.display }}</template> + <option value="sideFull">{{ $ts._menuDisplay.sideFull }}</option> + <option value="sideIcon">{{ $ts._menuDisplay.sideIcon }}</option> + <option value="top">{{ $ts._menuDisplay.top }}</option> + <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ $ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: ใตใคใใใผใๅฎๅ
จใซ้ ใใใใใซใใใจใๅฅ้ใใณใใผใฌใผใใฟใณใฎใใใชใใฎใUIใซ่กจ็คบใใๅฟ
่ฆใใใ้ขๅ --> + </FormRadios> + + <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import { defaultStore } from '@/store'; +import * as symbols from '@/symbols'; +import { unisonReload } from '@/scripts/unison-reload'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormTextarea, + FormRadios, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.menu, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', + }, + menuDef: menuDef, + items: defaultStore.state.menu.join('\n'), + } + }, + + computed: { + splited(): string[] { + return this.items.trim().split('\n').filter(x => x.trim() !== ''); + }, + + menuDisplay: defaultStore.makeGetterSetter('menuDisplay') + }, + + watch: { + menuDisplay() { + this.reloadAsk(); + }, + + items() { + this.save(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async addItem() { + const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.menu.includes(k)); + const { canceled, result: item } = await os.dialog({ + type: null, + title: this.$ts.addItem, + select: { + items: [...menu.map(k => ({ + value: k, text: this.$ts[this.menuDef[k].title] + })), ...[{ + value: '-', text: this.$ts.divider + }]] + }, + showCancelButton: true + }); + if (canceled) return; + this.items = [...this.splited, item].join('\n'); + }, + + save() { + this.$store.set('menu', this.splited); + this.reloadAsk(); + }, + + reset() { + this.$store.reset('menu'); + this.items = this.$store.state.menu.join('\n'); + }, + + async reloadAsk() { + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + }, +}); +</script> diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue new file mode 100644 index 0000000000..4a9633a20d --- /dev/null +++ b/packages/client/src/pages/settings/mute-block.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="mute">{{ $ts.mutedUsers }}</option> + <option value="block">{{ $ts.blockedUsers }}</option> + </MkTab> + <div v-if="tab === 'mute'"> + <MkPagination :pagination="mutingPagination" class="muting"> + <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormGroup> + <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)"> + <MkAcct :user="mute.mutee"/> + </FormLink> + </FormGroup> + </template> + </MkPagination> + </div> + <div v-if="tab === 'block'"> + <MkPagination :pagination="blockingPagination" class="blocking"> + <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormGroup> + <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)"> + <MkAcct :user="block.blockee"/> + </FormLink> + </FormGroup> + </template> + </MkPagination> + </div> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkTab from '@/components/tab.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkTab, + FormInfo, + FormBase, + FormGroup, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.muteAndBlock, + icon: 'fas fa-ban', + bg: 'var(--bg)', + }, + tab: 'mute', + mutingPagination: { + endpoint: 'mute/list', + limit: 10, + }, + blockingPagination: { + endpoint: 'blocking/list', + limit: 10, + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + userPage + } +}); +</script> diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue new file mode 100644 index 0000000000..7de10a182c --- /dev/null +++ b/packages/client/src/pages/settings/notifications.vue @@ -0,0 +1,77 @@ +<template> +<FormBase> + <FormLink @click="configure">{{ $ts.notificationSetting }}</FormLink> + <FormGroup> + <FormButton @click="readAllNotifications">{{ $ts.markAsReadAllNotifications }}</FormButton> + <FormButton @click="readAllUnreadNotes">{{ $ts.markAsReadAllUnreadNotes }}</FormButton> + <FormButton @click="readAllMessagingMessages">{{ $ts.markAsReadAllTalkMessages }}</FormButton> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { notificationTypes } from 'misskey-js'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.notifications, + icon: 'fas fa-bell', + bg: 'var(--bg)', + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + readAllUnreadNotes() { + os.api('i/read-all-unread-notes'); + }, + + readAllMessagingMessages() { + os.api('i/read-all-messaging-messages'); + }, + + readAllNotifications() { + os.api('notifications/mark-all-as-read'); + }, + + configure() { + const includingTypes = notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x)); + os.popup(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.$i.mutingNotificationTypes = i.mutingNotificationTypes; + }); + } + }, 'closed'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue new file mode 100644 index 0000000000..fbc895a07d --- /dev/null +++ b/packages/client/src/pages/settings/other.vue @@ -0,0 +1,97 @@ +<template> +<FormBase> + <FormLink to="/settings/update">Misskey Update</FormLink> + + <FormSwitch :value="$i.injectFeaturedNote" @update:modelValue="onChangeInjectFeaturedNote"> + {{ $ts.showFeaturedNotesInTimeline }} + </FormSwitch> + + <FormSwitch v-model="reportError">{{ $ts.sendErrorReports }}<template #desc>{{ $ts.sendErrorReportsDescription }}</template></FormSwitch> + + <FormLink to="/settings/account-info">{{ $ts.accountInfo }}</FormLink> + <FormLink to="/settings/experimental-features">{{ $ts.experimentalFeatures }}</FormLink> + + <FormGroup> + <template #label>{{ $ts.developer }}</template> + <FormSwitch v-model="debug" @update:modelValue="changeDebug"> + DEBUG MODE + </FormSwitch> + <template v-if="debug"> + <FormButton @click="taskmanager">Task Manager</FormButton> + </template> + </FormGroup> + + <FormLink to="/settings/registry"><template #icon><i class="fas fa-cogs"></i></template>{{ $ts.registry }}</FormLink> + + <FormLink to="/bios" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>BIOS</FormLink> + <FormLink to="/cli" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>CLI</FormLink> + + <FormLink to="/settings/delete-account"><template #icon><i class="fas fa-exclamation-triangle"></i></template>{{ $ts.closeAccount }}</FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { debug } from '@/config'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.other, + icon: 'fas fa-ellipsis-h', + bg: 'var(--bg)', + }, + debug, + } + }, + + computed: { + reportError: defaultStore.makeGetterSetter('reportError'), + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + changeDebug(v) { + console.log(v); + localStorage.setItem('debug', v.toString()); + unisonReload(); + }, + + onChangeInjectFeaturedNote(v) { + os.api('i/update', { + injectFeaturedNote: v + }); + }, + + taskmanager() { + os.popup(import('@/components/taskmanager.vue'), { + }, {}, 'closed'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue new file mode 100644 index 0000000000..9958f98f58 --- /dev/null +++ b/packages/client/src/pages/settings/plugin.install.vue @@ -0,0 +1,147 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts._plugin.installWarn }}</FormInfo> + + <FormGroup> + <FormTextarea v-model="code" tall> + <span>{{ $ts.code }}</span> + </FormTextarea> + </FormGroup> + + <FormButton @click="install" :disabled="code == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton> +</FormBase> +</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 FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._plugin.install, + icon: 'fas fa-download', + bg: 'var(--bg)', + }, + code: null, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + installPlugin({ id, meta, ast, token }) { + ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ + ...meta, + id, + active: true, + configData: {}, + token: token, + ast: ast + })); + }, + + async install() { + let ast; + try { + ast = parse(this.code); + } 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((res, rej) => { + os.popup(import('@/components/token-generate-window.vue'), { + title: this.$ts.tokenRequested, + information: this.$ts.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.installPlugin({ + id: uuid(), + meta: { + name, version, author, description, permissions, config + }, + token, + ast: serialize(ast) + }); + + os.success(); + + this.$nextTick(() => { + unisonReload(); + }); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/plugin.manage.vue b/packages/client/src/pages/settings/plugin.manage.vue new file mode 100644 index 0000000000..3a0168d13d --- /dev/null +++ b/packages/client/src/pages/settings/plugin.manage.vue @@ -0,0 +1,115 @@ +<template> +<FormBase> + <FormGroup v-for="plugin in plugins" :key="plugin.id"> + <template #label><span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span></template> + + <FormSwitch :value="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ $ts.makeActive }}</FormSwitch> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel" style="padding: 16px;"> + <div class="_keyValue"> + <div>{{ $ts.author }}:</div> + <div>{{ plugin.author }}</div> + </div> + <div class="_keyValue"> + <div>{{ $ts.description }}:</div> + <div>{{ plugin.description }}</div> + </div> + <div class="_keyValue"> + <div>{{ $ts.permission }}:</div> + <div>{{ plugin.permissions }}</div> + </div> + </div> + </div> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel" style="padding: 16px;"> + <MkButton @click="config(plugin)" inline v-if="plugin.config"><i class="fas fa-cog"></i> {{ $ts.settings }}</MkButton> + <MkButton @click="uninstall(plugin)" inline danger><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</MkButton> + </div> + </div> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSelect from '@/components/form/select.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkTextarea, + MkSelect, + FormSwitch, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._plugin.manage, + icon: 'fas fa-plug', + bg: 'var(--bg)', + }, + plugins: ColdDeviceStorage.get('plugins'), + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + uninstall(plugin) { + ColdDeviceStorage.set('plugins', this.plugins.filter(x => x.id !== plugin.id)); + os.success(); + this.$nextTick(() => { + unisonReload(); + }); + }, + + // TODO: ใใฎๅฆ็ใstoreๅดใซactionใจใใฆ็งปๅใใ่จญๅฎ็ป้ขใ้ใAiScriptAPIใๅฎ่ฃ
ใงใใใใใซใใ + async config(plugin) { + const config = plugin.config; + for (const key in plugin.configData) { + config[key].default = plugin.configData[key]; + } + + const { canceled, result } = await os.form(plugin.name, config); + if (canceled) return; + + const plugins = ColdDeviceStorage.get('plugins'); + plugins.find(p => p.id === plugin.id).configData = result; + ColdDeviceStorage.set('plugins', plugins); + + this.$nextTick(() => { + location.reload(); + }); + }, + + changeActive(plugin, active) { + const plugins = ColdDeviceStorage.get('plugins'); + plugins.find(p => p.id === plugin.id).active = active; + ColdDeviceStorage.set('plugins', plugins); + + this.$nextTick(() => { + location.reload(); + }); + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue new file mode 100644 index 0000000000..50e53f459f --- /dev/null +++ b/packages/client/src/pages/settings/plugin.vue @@ -0,0 +1,44 @@ +<template> +<FormBase> + <FormLink to="/settings/plugin/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._plugin.install }}</FormLink> + <FormLink to="/settings/plugin/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._plugin.manage }}<template #suffix>{{ plugins }}</template></FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.plugins, + icon: 'fas fa-plug', + bg: 'var(--bg)', + }, + plugins: ColdDeviceStorage.get('plugins').length, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue new file mode 100644 index 0000000000..94afba9aa4 --- /dev/null +++ b/packages/client/src/pages/settings/privacy.vue @@ -0,0 +1,120 @@ +<template> +<FormBase> + <FormGroup> + <FormSwitch v-model="isLocked" @update:modelValue="save()">{{ $ts.makeFollowManuallyApprove }}</FormSwitch> + <FormSwitch v-model="autoAcceptFollowed" :disabled="!isLocked" @update:modelValue="save()">{{ $ts.autoAcceptFollowed }}</FormSwitch> + <template #caption>{{ $ts.lockedAccountInfo }}</template> + </FormGroup> + <FormSwitch v-model="publicReactions" @update:modelValue="save()"> + {{ $ts.makeReactionsPublic }} + <template #desc>{{ $ts.makeReactionsPublicDescription }}</template> + </FormSwitch> + <FormGroup> + <template #label>{{ $ts.ffVisibility }}</template> + <FormSelect v-model="ffVisibility"> + <option value="public">{{ $ts._ffVisibility.public }}</option> + <option value="followers">{{ $ts._ffVisibility.followers }}</option> + <option value="private">{{ $ts._ffVisibility.private }}</option> + </FormSelect> + <template #caption>{{ $ts.ffVisibilityDescription }}</template> + </FormGroup> + <FormSwitch v-model="hideOnlineStatus" @update:modelValue="save()"> + {{ $ts.hideOnlineStatus }} + <template #desc>{{ $ts.hideOnlineStatusDescription }}</template> + </FormSwitch> + <FormSwitch v-model="noCrawle" @update:modelValue="save()"> + {{ $ts.noCrawle }} + <template #desc>{{ $ts.noCrawleDescription }}</template> + </FormSwitch> + <FormSwitch v-model="isExplorable" @update:modelValue="save()"> + {{ $ts.makeExplorable }} + <template #desc>{{ $ts.makeExplorableDescription }}</template> + </FormSwitch> + <FormSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ $ts.rememberNoteVisibility }}</FormSwitch> + <FormGroup v-if="!rememberNoteVisibility"> + <template #label>{{ $ts.defaultNoteVisibility }}</template> + <FormSelect v-model="defaultNoteVisibility"> + <option value="public">{{ $ts._visibility.public }}</option> + <option value="home">{{ $ts._visibility.home }}</option> + <option value="followers">{{ $ts._visibility.followers }}</option> + <option value="specified">{{ $ts._visibility.specified }}</option> + </FormSelect> + <FormSwitch v-model="defaultNoteLocalOnly">{{ $ts._visibility.localOnly }}</FormSwitch> + </FormGroup> + <FormSwitch v-model="keepCw" @update:modelValue="save()">{{ $ts.keepCw }}</FormSwitch> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormGroup, + FormSwitch, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.privacy, + icon: 'fas fa-lock-open', + bg: 'var(--bg)', + }, + isLocked: false, + autoAcceptFollowed: false, + noCrawle: false, + isExplorable: false, + hideOnlineStatus: false, + publicReactions: false, + ffVisibility: 'public', + } + }, + + computed: { + defaultNoteVisibility: defaultStore.makeGetterSetter('defaultNoteVisibility'), + defaultNoteLocalOnly: defaultStore.makeGetterSetter('defaultNoteLocalOnly'), + rememberNoteVisibility: defaultStore.makeGetterSetter('rememberNoteVisibility'), + keepCw: defaultStore.makeGetterSetter('keepCw'), + }, + + created() { + this.isLocked = this.$i.isLocked; + this.autoAcceptFollowed = this.$i.autoAcceptFollowed; + this.noCrawle = this.$i.noCrawle; + this.isExplorable = this.$i.isExplorable; + this.hideOnlineStatus = this.$i.hideOnlineStatus; + this.publicReactions = this.$i.publicReactions; + this.ffVisibility = this.$i.ffVisibility; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + os.api('i/update', { + isLocked: !!this.isLocked, + autoAcceptFollowed: !!this.autoAcceptFollowed, + noCrawle: !!this.noCrawle, + isExplorable: !!this.isExplorable, + hideOnlineStatus: !!this.hideOnlineStatus, + publicReactions: !!this.publicReactions, + ffVisibility: this.ffVisibility, + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue new file mode 100644 index 0000000000..a7ddc6d178 --- /dev/null +++ b/packages/client/src/pages/settings/profile.vue @@ -0,0 +1,281 @@ +<template> +<FormBase> + <FormGroup> + <div class="_debobigegoItem _debobigegoPanel llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> + <MkAvatar class="avatar" :user="$i"/> + </div> + <FormButton @click="changeAvatar" primary>{{ $ts._profile.changeAvatar }}</FormButton> + <FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton> + </FormGroup> + + <FormInput v-model="name" :max="30" manual-save> + <span>{{ $ts._profile.name }}</span> + </FormInput> + + <FormTextarea v-model="description" :max="500" tall manual-save> + <span>{{ $ts._profile.description }}</span> + <template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template> + </FormTextarea> + + <FormInput v-model="location" manual-save> + <span>{{ $ts.location }}</span> + <template #prefix><i class="fas fa-map-marker-alt"></i></template> + </FormInput> + + <FormInput v-model="birthday" type="date" manual-save> + <span>{{ $ts.birthday }}</span> + <template #prefix><i class="fas fa-birthday-cake"></i></template> + </FormInput> + + <FormSelect v-model="lang"> + <template #label>{{ $ts.language }}</template> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </FormSelect> + + <FormGroup> + <FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton> + <template #caption>{{ $ts._profile.metadataDescription }}</template> + </FormGroup> + + <FormSwitch v-model="isCat">{{ $ts.flagAsCat }}<template #desc>{{ $ts.flagAsCatDescription }}</template></FormSwitch> + + <FormSwitch v-model="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch> + + <FormSwitch v-model="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { host, langs } from '@/config'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormButton, + FormInput, + FormTextarea, + FormSwitch, + FormSelect, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.profile, + icon: 'fas fa-user', + bg: 'var(--bg)', + }, + host, + langs, + name: null, + description: null, + birthday: null, + lang: 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, + alwaysMarkNsfw: false, + saving: false, + } + }, + + created() { + this.name = this.$i.name; + this.description = this.$i.description; + this.location = this.$i.location; + this.birthday = this.$i.birthday; + this.lang = this.$i.lang; + this.avatarId = this.$i.avatarId; + this.bannerId = this.$i.bannerId; + this.isBot = this.$i.isBot; + this.isCat = this.$i.isCat; + this.alwaysMarkNsfw = this.$i.alwaysMarkNsfw; + + this.fieldName0 = this.$i.fields[0] ? this.$i.fields[0].name : null; + this.fieldValue0 = this.$i.fields[0] ? this.$i.fields[0].value : null; + this.fieldName1 = this.$i.fields[1] ? this.$i.fields[1].name : null; + this.fieldValue1 = this.$i.fields[1] ? this.$i.fields[1].value : null; + this.fieldName2 = this.$i.fields[2] ? this.$i.fields[2].name : null; + this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null; + this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null; + this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null; + + this.$watch('name', this.save); + this.$watch('description', this.save); + this.$watch('location', this.save); + this.$watch('birthday', this.save); + this.$watch('lang', this.save); + this.$watch('isBot', this.save); + this.$watch('isCat', this.save); + this.$watch('alwaysMarkNsfw', this.save); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + changeAvatar(e) { + selectFile(e.currentTarget || e.target, this.$ts.avatar).then(file => { + os.api('i/update', { + avatarId: file.id, + }); + }); + }, + + changeBanner(e) { + selectFile(e.currentTarget || e.target, this.$ts.banner).then(file => { + os.api('i/update', { + bannerId: file.id, + }); + }); + }, + + async editMetadata() { + const { canceled, result } = await os.form(this.$ts._profile.metadata, { + fieldName0: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 1', + default: this.fieldName0, + }, + fieldValue0: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 1', + default: this.fieldValue0, + }, + fieldName1: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 2', + default: this.fieldName1, + }, + fieldValue1: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 2', + default: this.fieldValue1, + }, + fieldName2: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 3', + default: this.fieldName2, + }, + fieldValue2: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 3', + default: this.fieldValue2, + }, + fieldName3: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 4', + default: this.fieldName3, + }, + fieldValue3: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 4', + default: this.fieldValue3, + }, + }); + if (canceled) return; + + this.fieldName0 = result.fieldName0; + this.fieldValue0 = result.fieldValue0; + this.fieldName1 = result.fieldName1; + this.fieldValue1 = result.fieldValue1; + this.fieldName2 = result.fieldName2; + this.fieldValue2 = result.fieldValue2; + this.fieldName3 = result.fieldName3; + this.fieldValue3 = result.fieldValue3; + + 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 }, + ]; + + os.api('i/update', { + fields, + }).then(i => { + os.success(); + }).catch(err => { + os.dialog({ + type: 'error', + text: err.id + }); + }); + }, + + save() { + this.saving = true; + + os.apiWithDialog('i/update', { + name: this.name || null, + description: this.description || null, + location: this.location || null, + birthday: this.birthday || null, + lang: this.lang || null, + isBot: !!this.isBot, + isCat: !!this.isCat, + alwaysMarkNsfw: !!this.alwaysMarkNsfw, + }).then(i => { + this.saving = false; + this.$i.avatarId = i.avatarId; + this.$i.avatarUrl = i.avatarUrl; + this.$i.bannerId = i.bannerId; + this.$i.bannerUrl = i.bannerUrl; + }).catch(err => { + this.saving = false; + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.llvierxe { + position: relative; + height: 150px; + background-size: cover; + background-position: center; + + > * { + pointer-events: none; + } + + > .avatar { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: block; + width: 72px; + height: 72px; + margin: auto; + box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5); + } +} +</style> diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue new file mode 100644 index 0000000000..905a3e4957 --- /dev/null +++ b/packages/client/src/pages/settings/reaction.vue @@ -0,0 +1,152 @@ +<template> +<FormBase> + <div class="_debobigegoItem"> + <div class="_debobigegoLabel">{{ $ts.reactionSettingDescription }}</div> + <div class="_debobigegoPanel"> + <XDraggable class="zoaiodol" v-model="reactions" :item-key="item => item" animation="150" delay="100" delay-on-touch-only="true"> + <template #item="{element}"> + <button class="_button item" @click="remove(element, $event)"> + <MkEmoji :emoji="element" :normal="true"/> + </button> + </template> + <template #footer> + <button class="_button add" @click="chooseEmoji"><i class="fas fa-plus"></i></button> + </template> + </XDraggable> + </div> + <div class="_debobigegoCaption">{{ $ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ $ts.preview }}</button></div> + </div> + + <FormRadios v-model="reactionPickerWidth"> + <template #desc>{{ $ts.width }}</template> + <option :value="1">{{ $ts.small }}</option> + <option :value="2">{{ $ts.medium }}</option> + <option :value="3">{{ $ts.large }}</option> + </FormRadios> + <FormRadios v-model="reactionPickerHeight"> + <template #desc>{{ $ts.height }}</template> + <option :value="1">{{ $ts.small }}</option> + <option :value="2">{{ $ts.medium }}</option> + <option :value="3">{{ $ts.large }}</option> + </FormRadios> + <FormButton @click="preview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton> + <FormButton danger @click="setDefault"><i class="fas fa-undo"></i> {{ $ts.default }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XDraggable from 'vuedraggable'; +import FormInput from '@/components/debobigego/input.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormInput, + FormButton, + FormBase, + FormRadios, + XDraggable, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.reaction, + icon: 'fas fa-laugh', + action: { + icon: 'fas fa-eye', + handler: this.preview + }, + bg: 'var(--bg)', + }, + reactions: JSON.parse(JSON.stringify(this.$store.state.reactions)), + } + }, + + computed: { + reactionPickerWidth: defaultStore.makeGetterSetter('reactionPickerWidth'), + reactionPickerHeight: defaultStore.makeGetterSetter('reactionPickerHeight'), + }, + + watch: { + reactions: { + handler() { + this.save(); + }, + deep: true + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + this.$store.set('reactions', this.reactions); + }, + + remove(reaction, ev) { + os.popupMenu([{ + text: this.$ts.remove, + action: () => { + this.reactions = this.reactions.filter(x => x !== reaction) + } + }], ev.currentTarget || ev.target); + }, + + preview(ev) { + os.popup(import('@/components/emoji-picker-dialog.vue'), { + asReactionPicker: true, + src: ev.currentTarget || ev.target, + }, {}, 'closed'); + }, + + async setDefault() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.resetAreYouSure, + showCancelButton: true + }); + if (canceled) return; + + this.reactions = JSON.parse(JSON.stringify(this.$store.def.reactions.default)); + }, + + chooseEmoji(ev) { + os.pickEmoji(ev.currentTarget || ev.target, { + showPinned: false + }).then(emoji => { + if (!this.reactions.includes(emoji)) { + this.reactions.push(emoji); + } + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.zoaiodol { + padding: 16px; + + > .item { + display: inline-block; + padding: 8px; + cursor: move; + } + + > .add { + display: inline-block; + padding: 8px; + } +} +</style> diff --git a/packages/client/src/pages/settings/registry.keys.vue b/packages/client/src/pages/settings/registry.keys.vue new file mode 100644 index 0000000000..ca4d01cc94 --- /dev/null +++ b/packages/client/src/pages/settings/registry.keys.vue @@ -0,0 +1,114 @@ +<template> +<FormBase> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts._registry.domain }}</template> + <template #value>{{ $ts.system }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup v-if="keys"> + <template #label>{{ $ts._registry.keys }}</template> + <FormLink v-for="key in keys" :to="`/settings/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink> + </FormGroup> + + <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + props: { + scope: { + required: true + } + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.registry, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + keys: null, + } + }, + + watch: { + scope() { + this.fetch(); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + this.fetch(); + }, + + methods: { + fetch() { + os.api('i/registry/keys-with-type', { + scope: this.scope + }).then(keys => { + this.keys = Object.entries(keys).sort((a, b) => a[0].localeCompare(b[0])); + }); + }, + + async createKey() { + const { canceled, result } = await os.form(this.$ts._registry.createKey, { + key: { + type: 'string', + label: this.$ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: this.$ts.value, + }, + scope: { + type: 'string', + label: this.$ts._registry.scope, + default: this.scope.join('/') + } + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + this.fetch(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/registry.value.vue b/packages/client/src/pages/settings/registry.value.vue new file mode 100644 index 0000000000..36f989dbc5 --- /dev/null +++ b/packages/client/src/pages/settings/registry.value.vue @@ -0,0 +1,149 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts.editTheseSettingsMayBreakAccount }}</FormInfo> + + <template v-if="value"> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts._registry.domain }}</template> + <template #value>{{ $ts.system }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts._registry.key }}</template> + <template #value>{{ xKey }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <FormTextarea tall v-model="valueForEditor" class="_monospace" style="tab-size: 2;"> + <span>{{ $ts.value }} (JSON)</span> + </FormTextarea> + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormGroup> + + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime :time="value.updatedAt" mode="detail"/></template> + </FormKeyValueView> + + <FormButton danger @click="del"><i class="fas fa-trash"></i> {{ $ts.delete }}</FormButton> + </template> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormInfo, + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormTextarea, + FormGroup, + FormKeyValueView, + }, + + props: { + scope: { + required: true + }, + xKey: { + required: true + }, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.registry, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + value: null, + valueForEditor: null, + } + }, + + watch: { + key() { + this.fetch(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + this.fetch(); + }, + + methods: { + fetch() { + os.api('i/registry/get-detail', { + scope: this.scope, + key: this.xKey + }).then(value => { + this.value = value; + this.valueForEditor = JSON5.stringify(this.value.value, null, '\t'); + }); + }, + + save() { + try { + JSON5.parse(this.valueForEditor); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$ts.invalidValue + }); + return; + } + + os.dialog({ + type: 'warning', + text: this.$ts.saveConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: this.scope, + key: this.xKey, + value: JSON5.parse(this.valueForEditor) + }); + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/remove', { + scope: this.scope, + key: this.xKey + }); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/registry.vue b/packages/client/src/pages/settings/registry.vue new file mode 100644 index 0000000000..0bfed0ddb7 --- /dev/null +++ b/packages/client/src/pages/settings/registry.vue @@ -0,0 +1,90 @@ +<template> +<FormBase> + <FormGroup v-if="scopes"> + <template #label>{{ $ts.system }}</template> + <FormLink v-for="scope in scopes" :to="`/settings/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink> + </FormGroup> + <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.registry, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + scopes: null, + } + }, + + created() { + this.fetch(); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + fetch() { + os.api('i/registry/scopes').then(scopes => { + this.scopes = scopes.slice().sort((a, b) => a.join('/').localeCompare(b.join('/'))); + }); + }, + + async createKey() { + const { canceled, result } = await os.form(this.$ts._registry.createKey, { + key: { + type: 'string', + label: this.$ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: this.$ts.value, + }, + scope: { + type: 'string', + label: this.$ts._registry.scope, + } + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + this.fetch(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue new file mode 100644 index 0000000000..4d81bf1b9e --- /dev/null +++ b/packages/client/src/pages/settings/security.vue @@ -0,0 +1,158 @@ +<template> +<FormBase> + <X2fa/> + <FormLink to="/settings/2fa"><template #icon><i class="fas fa-mobile-alt"></i></template>{{ $ts.twoStepAuthentication }}</FormLink> + <FormButton primary @click="change()">{{ $ts.changePassword }}</FormButton> + <FormPagination :pagination="pagination"> + <template #label>{{ $ts.signinHistory }}</template> + <template #default="{items}"> + <div class="_debobigegoPanel timnmucd" v-for="item in items" :key="item.id"> + <header> + <i v-if="item.success" class="fas fa-check icon succ"></i> + <i v-else class="fas fa-times-circle icon fail"></i> + <code class="ip _monospace">{{ item.ip }}</code> + <MkTime :time="item.createdAt" class="time"/> + </header> + </div> + </template> + </FormPagination> + <FormGroup> + <FormButton danger @click="regenerateToken"><i class="fas fa-sync-alt"></i> {{ $ts.regenerateLoginToken }}</FormButton> + <template #caption>{{ $ts.regenerateLoginTokenDescription }}</template> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormPagination from '@/components/debobigego/pagination.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormPagination, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.security, + icon: 'fas fa-lock', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'i/signin-history', + limit: 5, + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async change() { + const { canceled: canceled1, result: currentPassword } = await os.dialog({ + title: this.$ts.currentPassword, + input: { + type: 'password' + } + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await os.dialog({ + title: this.$ts.newPassword, + input: { + type: 'password' + } + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await os.dialog({ + title: this.$ts.newPasswordRetype, + input: { + type: 'password' + } + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + os.dialog({ + type: 'error', + text: this.$ts.retypedNotMatch + }); + return; + } + + os.apiWithDialog('i/change-password', { + currentPassword, + newPassword + }); + }, + + regenerateToken() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/regenerate_token', { + password: password + }); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.timnmucd { + padding: 16px; + + > header { + display: flex; + align-items: center; + + > .icon { + width: 1em; + margin-right: 0.75em; + + &.succ { + color: var(--success); + } + + &.fail { + color: var(--error); + } + } + + > .ip { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 12px; + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue new file mode 100644 index 0000000000..ea3daced9d --- /dev/null +++ b/packages/client/src/pages/settings/sounds.vue @@ -0,0 +1,155 @@ +<template> +<FormBase> + <FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05"> + <template #label><i class="fas fa-volume-icon"></i> {{ $ts.masterVolume }}</template> + </FormRange> + + <FormGroup> + <template #label>{{ $ts.sounds }}</template> + <FormButton v-for="type in Object.keys(sounds)" :key="type" :center="false" @click="edit(type)"> + {{ $t('_sfx.' + type) }} + <template #suffix>{{ sounds[type].type || $ts.none }}</template> + <template #suffixIcon><i class="fas fa-chevron-down"></i></template> + </FormButton> + </FormGroup> + + <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormRange from '@/components/debobigego/range.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { playFile } from '@/scripts/sound'; +import * as symbols from '@/symbols'; + +const soundsTypes = [ + 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', + 'syuilo/kick', + 'syuilo/snare', + 'syuilo/queue-jammed', + 'aisha/1', + 'aisha/2', + 'aisha/3', + 'noizenecio/kick_gaba', + 'noizenecio/kick_gaba2', +]; + +export default defineComponent({ + components: { + FormSelect, + FormButton, + FormBase, + FormRange, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.sounds, + icon: 'fas fa-music', + bg: 'var(--bg)', + }, + sounds: {}, + } + }, + + computed: { + masterVolume: { // TODO: (ๅค้จ)้ขๆฐใซcomputedใไฝฟใใฎใฏใขใฌใชใฎใง็ดใ + get() { return ColdDeviceStorage.get('sound_masterVolume'); }, + set(value) { ColdDeviceStorage.set('sound_masterVolume', value); } + }, + volumeIcon() { + return this.masterVolume === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up'; + } + }, + + created() { + this.sounds.note = ColdDeviceStorage.get('sound_note'); + this.sounds.noteMy = ColdDeviceStorage.get('sound_noteMy'); + this.sounds.notification = ColdDeviceStorage.get('sound_notification'); + this.sounds.chat = ColdDeviceStorage.get('sound_chat'); + this.sounds.chatBg = ColdDeviceStorage.get('sound_chatBg'); + this.sounds.antenna = ColdDeviceStorage.get('sound_antenna'); + this.sounds.channel = ColdDeviceStorage.get('sound_channel'); + this.sounds.reversiPutBlack = ColdDeviceStorage.get('sound_reversiPutBlack'); + this.sounds.reversiPutWhite = ColdDeviceStorage.get('sound_reversiPutWhite'); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async edit(type) { + const { canceled, result } = await os.form(this.$t('_sfx.' + type), { + type: { + type: 'enum', + enum: soundsTypes.map(x => ({ + value: x, + label: x == null ? this.$ts.none : x, + })), + label: this.$ts.sound, + default: this.sounds[type].type, + }, + volume: { + type: 'range', + mim: 0, + max: 1, + step: 0.05, + label: this.$ts.volume, + default: this.sounds[type].volume + }, + listen: { + type: 'button', + content: this.$ts.listen, + action: (_, values) => { + playFile(values.type, values.volume); + } + } + }); + if (canceled) return; + + const v = { + type: result.type, + volume: result.volume, + }; + + ColdDeviceStorage.set('sound_' + type, v); + this.sounds[type] = v; + }, + + reset() { + for (const sound of Object.keys(this.sounds)) { + const v = ColdDeviceStorage.default['sound_' + sound]; + ColdDeviceStorage.set('sound_' + sound, v); + this.sounds[sound] = v; + } + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue new file mode 100644 index 0000000000..59ad3ad9b7 --- /dev/null +++ b/packages/client/src/pages/settings/theme.install.vue @@ -0,0 +1,105 @@ +<template> +<FormBase> + <FormGroup> + <FormTextarea v-model="installThemeCode"> + <span>{{ $ts._theme.code }}</span> + </FormTextarea> + <FormButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton> + </FormGroup> + + <FormButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import { applyTheme, validateTheme } from '@/scripts/theme'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { addTheme, getThemes } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._theme.install, + icon: 'fas fa-download', + bg: 'var(--bg)', + }, + installThemeCode: null, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + parseThemeCode(code) { + let theme; + + try { + theme = JSON5.parse(code); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$ts._theme.invalid + }); + return false; + } + if (!validateTheme(theme)) { + os.dialog({ + type: 'error', + text: this.$ts._theme.invalid + }); + return false; + } + if (getThemes().some(t => t.id === theme.id)) { + os.dialog({ + type: 'info', + text: this.$ts._theme.alreadyInstalled + }); + return false; + } + + return theme; + }, + + preview(code) { + const theme = this.parseThemeCode(code); + if (theme) applyTheme(theme, false); + }, + + async install(code) { + const theme = this.parseThemeCode(code); + if (!theme) return; + await addTheme(theme); + os.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: theme.name }) + }); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue new file mode 100644 index 0000000000..8a24481ae2 --- /dev/null +++ b/packages/client/src/pages/settings/theme.manage.vue @@ -0,0 +1,105 @@ +<template> +<FormBase> + <FormSelect v-model="selectedThemeId"> + <template #label>{{ $ts.theme }}</template> + <optgroup :label="$ts._theme.installedThemes"> + <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts._theme.builtinThemes"> + <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <template v-if="selectedTheme"> + <FormInput readonly :modelValue="selectedTheme.author"> + <span>{{ $ts.author }}</span> + </FormInput> + <FormTextarea readonly :modelValue="selectedTheme.desc" v-if="selectedTheme.desc"> + <span>{{ $ts._theme.description }}</span> + </FormTextarea> + <FormTextarea readonly tall :modelValue="selectedThemeCode"> + <span>{{ $ts._theme.code }}</span> + <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $ts.copy }}</button></template> + </FormTextarea> + <FormButton @click="uninstall()" danger v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</FormButton> + </template> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import { Theme, builtinThemes } from '@/scripts/theme'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { getThemes, removeTheme } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormInput, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._theme.manage, + icon: 'fas fa-folder-open', + bg: 'var(--bg)', + }, + installedThemes: getThemes(), + builtinThemes, + selectedThemeId: null, + } + }, + + computed: { + themes(): Theme[] { + return this.builtinThemes.concat(this.installedThemes); + }, + + 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'); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + copyThemeCode() { + copyToClipboard(this.selectedThemeCode); + os.success(); + }, + + uninstall() { + removeTheme(this.selectedTheme); + this.installedThemes = this.installedThemes.filter(t => t.id !== this.selectedThemeId); + this.selectedThemeId = null; + os.success(); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue new file mode 100644 index 0000000000..a9cca40f3c --- /dev/null +++ b/packages/client/src/pages/settings/theme.vue @@ -0,0 +1,424 @@ +<template> +<FormBase> + <FormGroup> + <div class="rfqxtzch _debobigegoItem _debobigegoPanel"> + <div class="darkMode"> + <div class="toggleWrapper"> + <input type="checkbox" class="dn" id="dn" v-model="darkMode"/> + <label for="dn" class="toggle"> + <span class="before">{{ $ts.light }}</span> + <span class="after">{{ $ts.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> + </div> + <FormSwitch v-model="syncDeviceDarkMode">{{ $ts.syncDeviceDarkMode }}</FormSwitch> + </FormGroup> + + <template v-if="darkMode"> + <FormSelect v-model="darkThemeId"> + <template #label>{{ $ts.themeForDarkMode }}</template> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model="lightThemeId"> + <template #label>{{ $ts.themeForLightMode }}</template> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + </template> + <template v-else> + <FormSelect v-model="lightThemeId"> + <template #label>{{ $ts.themeForLightMode }}</template> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model="darkThemeId"> + <template #label>{{ $ts.themeForDarkMode }}</template> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + </template> + + <FormButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $ts.setWallpaper }}</FormButton> + <FormButton primary v-else @click="wallpaper = null">{{ $ts.removeWallpaper }}</FormButton> + + <FormGroup> + <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="fas fa-globe"></i></template>{{ $ts._theme.explore }}</FormLink> + <FormLink to="/settings/theme/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._theme.install }}</FormLink> + </FormGroup> + + <FormGroup> + <FormLink to="/theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }}</FormLink> + <!--<FormLink to="/advanced-theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }} ({{ $ts.advanced }})</FormLink>--> + </FormGroup> + + <FormLink to="/settings/theme/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineComponent, onActivated, onMounted, ref, watch } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import { builtinThemes } from '@/scripts/theme'; +import { selectFile } from '@/scripts/select-file'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { ColdDeviceStorage } from '@/store'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import { fetchThemes, getThemes } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormSwitch, + FormSelect, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + setup(props, { emit }) { + const INFO = { + title: i18n.locale.theme, + icon: 'fas fa-palette', + bg: 'var(--bg)', + }; + + const installedThemes = ref(getThemes()); + const themes = computed(() => builtinThemes.concat(installedThemes.value)); + const darkThemes = computed(() => themes.value.filter(t => t.base == 'dark' || t.kind == 'dark')); + const lightThemes = computed(() => themes.value.filter(t => t.base == 'light' || t.kind == 'light')); + const darkTheme = ColdDeviceStorage.ref('darkTheme'); + const darkThemeId = computed({ + get() { + return darkTheme.value.id; + }, + set(id) { + ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id)) + } + }); + const lightTheme = ColdDeviceStorage.ref('lightTheme'); + const lightThemeId = computed({ + get() { + return lightTheme.value.id; + }, + set(id) { + ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id)) + } + }); + const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); + const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); + const wallpaper = ref(localStorage.getItem('wallpaper')); + const themesCount = installedThemes.value.length; + + watch(syncDeviceDarkMode, () => { + if (syncDeviceDarkMode) { + defaultStore.set('darkMode', isDeviceDarkmode()); + } + }); + + watch(wallpaper, () => { + if (wallpaper.value == null) { + localStorage.removeItem('wallpaper'); + } else { + localStorage.setItem('wallpaper', wallpaper.value); + } + location.reload(); + }); + + onMounted(() => { + emit('info', INFO); + }); + + onActivated(() => { + fetchThemes().then(() => { + installedThemes.value = getThemes(); + }); + }); + + fetchThemes().then(() => { + installedThemes.value = getThemes(); + }); + + return { + [symbols.PAGE_INFO]: INFO, + darkThemes, + lightThemes, + darkThemeId, + lightThemeId, + darkMode, + syncDeviceDarkMode, + themesCount, + wallpaper, + setWallpaper(e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + wallpaper.value = file.url; + }); + }, + }; + } +}); +</script> + +<style lang="scss" scoped> +.rfqxtzch { + padding: 16px; + + > .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> diff --git a/packages/client/src/pages/settings/update.vue b/packages/client/src/pages/settings/update.vue new file mode 100644 index 0000000000..aa4050fe9f --- /dev/null +++ b/packages/client/src/pages/settings/update.vue @@ -0,0 +1,95 @@ +<template> +<FormBase> + <template v-if="meta"> + <FormInfo v-if="version === meta.version">{{ $ts.youAreRunningUpToDateClient }}</FormInfo> + <FormInfo v-else warn>{{ $ts.newVersionOfClientAvailable }}</FormInfo> + </template> + <FormGroup> + <template #label>{{ instanceName }}</template> + <FormKeyValueView> + <template #key>{{ $ts.currentVersion }}</template> + <template #value>{{ version }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.latestVersion }}</template> + <template #value v-if="meta">{{ meta.version }}</template> + <template #value v-else><MkEllipsis/></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <template #label>Misskey</template> + <FormKeyValueView> + <template #key>{{ $ts.latestVersion }}</template> + <template #value v-if="releases">{{ releases[0].tag_name }}</template> + <template #value v-else><MkEllipsis/></template> + </FormKeyValueView> + <template #caption v-if="releases"><MkTime :time="releases[0].published_at" mode="detail"/></template> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import * as os from '@/os'; +import { version, instanceName } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Misskey Update', + icon: 'fas fa-sync-alt', + bg: 'var(--bg)', + }, + version, + instanceName, + releases: null, + meta: null + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + os.api('meta', { + detail: false + }).then(meta => { + this.meta = meta; + localStorage.setItem('v', meta.version); + }); + + fetch('https://api.github.com/repos/misskey-dev/misskey/releases', { + method: 'GET', + }) + .then(res => res.json()) + .then(res => { + this.releases = res; + }); + }, + + methods: { + } +}); +</script> diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue new file mode 100644 index 0000000000..c2162bb1f3 --- /dev/null +++ b/packages/client/src/pages/settings/word-mute.vue @@ -0,0 +1,110 @@ +<template> +<div> + <MkTab v-model="tab"> + <option value="soft">{{ $ts._wordMute.soft }}</option> + <option value="hard">{{ $ts._wordMute.hard }}</option> + </MkTab> + <FormBase> + <div class="_debobigegoItem"> + <div v-show="tab === 'soft'"> + <FormInfo>{{ $ts._wordMute.softDescription }}</FormInfo> + <FormTextarea v-model="softMutedWords"> + <span>{{ $ts._wordMute.muteWords }}</span> + <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + </div> + <div v-show="tab === 'hard'"> + <FormInfo>{{ $ts._wordMute.hardDescription }}</FormInfo> + <FormTextarea v-model="hardMutedWords"> + <span>{{ $ts._wordMute.muteWords }}</span> + <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + <FormKeyValueView v-if="hardWordMutedNotesCount != null"> + <template #key>{{ $ts._wordMute.mutedNotes }}</template> + <template #value>{{ number(hardWordMutedNotesCount) }}</template> + </FormKeyValueView> + </div> + </div> + <FormButton @click="save()" primary inline :disabled="!changed"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormBase> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormTextarea, + FormKeyValueView, + MkTab, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.wordMute, + icon: 'fas fa-comment-slash', + bg: 'var(--bg)', + }, + tab: 'soft', + softMutedWords: '', + hardMutedWords: '', + hardWordMutedNotesCount: null, + changed: false, + } + }, + + watch: { + softMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + hardMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + }, + + async created() { + this.softMutedWords = this.$store.state.mutedWords.map(x => x.join(' ')).join('\n'); + this.hardMutedWords = this.$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[symbols.PAGE_INFO]); + }, + + methods: { + async save() { + this.$store.set('mutedWords', 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; + }, + + number + } +}); +</script> diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue new file mode 100644 index 0000000000..c0af44fdd1 --- /dev/null +++ b/packages/client/src/pages/share.vue @@ -0,0 +1,184 @@ +<template> +<div class=""> + <section class="_section"> + <div class="_content"> + <XPostForm + v-if="state === 'writing'" + fixed + :share="true" + :initial-text="initialText" + :initial-visibility="visibility" + :initial-files="files" + :initial-local-only="localOnly" + :reply="reply" + :renote="renote" + :visible-users="visibleUsers" + @posted="state = 'posted'" + class="_panel" + /> + <MkButton v-else-if="state === 'posted'" primary @click="close()" class="close">{{ $ts.close }}</MkButton> + </div> + </section> +</div> +</template> + +<script lang="ts"> +// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html + +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import XPostForm from '@/components/post-form.vue'; +import * as os from '@/os'; +import { noteVisibilities } from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; +import * as symbols from '@/symbols'; +import * as Misskey from 'misskey-js'; + +export default defineComponent({ + components: { + XPostForm, + MkButton, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.share, + icon: 'fas fa-share-alt' + }, + state: 'fetching' as 'fetching' | 'writing' | 'posted', + + title: null as string | null, + initialText: null as string | null, + reply: null as Misskey.entities.Note | null, + renote: null as Misskey.entities.Note | null, + visibility: null as string | null, + localOnly: null as boolean | null, + files: [] as Misskey.entities.DriveFile[], + visibleUsers: [] as Misskey.entities.User[], + } + }, + + async created() { + const urlParams = new URLSearchParams(window.location.search); + + this.title = urlParams.get('title'); + const text = urlParams.get('text'); + const url = urlParams.get('url'); + + let noteText = ''; + if (this.title) noteText += `[ ${this.title} ]\n`; + // Googleใใฅใผในๅฏพ็ญ + if (text?.startsWith(`${this.title}.\n`)) noteText += text.replace(`${this.title}.\n`, ''); + else if (text && this.title !== text) noteText += `${text}\n`; + if (url) noteText += `${url}`; + this.initialText = noteText.trim(); + + const visibility = urlParams.get('visibility'); + if (noteVisibilities.includes(visibility)) { + this.visibility = visibility; + } + + if (this.visibility === 'specified') { + const visibleUserIds = urlParams.get('visibleUserIds'); + const visibleAccts = urlParams.get('visibleAccts'); + await Promise.all( + [ + ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []), + ...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []) + ] + // TypeScriptใฎๆ็คบ้ใใซๅคๆใใ + .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q) + .map(q => os.api('users/show', q) + .then(user => { + this.visibleUsers.push(user); + }, () => { + console.error(`Invalid user query: ${JSON.stringify(q)}`); + }) + ) + ); + } + + const localOnly = urlParams.get('localOnly'); + if (localOnly === '0') this.localOnly = false; + else if (localOnly === '1') this.localOnly = true; + + try { + //#region Reply + const replyId = urlParams.get('replyId'); + const replyUri = urlParams.get('replyUri'); + if (replyId) { + this.reply = await os.api('notes/show', { + noteId: replyId + }); + } else if (replyUri) { + const obj = await os.api('ap/show', { + uri: replyUri + }); + if (obj.type === 'Note') { + this.reply = obj.object; + } + } + //#endregion + + //#region Renote + const renoteId = urlParams.get('renoteId'); + const renoteUri = urlParams.get('renoteUri'); + if (renoteId) { + this.renote = await os.api('notes/show', { + noteId: renoteId + }); + } else if (renoteUri) { + const obj = await os.api('ap/show', { + uri: renoteUri + }); + if (obj.type === 'Note') { + this.renote = obj.object; + } + } + //#endregion + + //#region Drive files + const fileIds = urlParams.get('fileIds'); + if (fileIds) { + await Promise.all( + fileIds.split(',') + .map(fileId => os.api('drive/files/show', { fileId }) + .then(file => { + this.files.push(file); + }, () => { + console.error(`Failed to fetch a file ${fileId}`); + }) + ) + ); + } + //#endregion + } catch (e) { + os.dialog({ + type: 'error', + title: e.message, + text: e.name + }); + } + + this.state = 'writing'; + }, + + methods: { + close() { + window.close(); + + // ้ใใชใใใฐ100msๅพใฟใคใ ใฉใคใณใซ + setTimeout(() => { + this.$router.push('/'); + }, 100); + } + } +}); +</script> + +<style lang="scss" scoped> +.close { + margin: 16px auto; +} +</style> diff --git a/packages/client/src/pages/signup-complete.vue b/packages/client/src/pages/signup-complete.vue new file mode 100644 index 0000000000..3bbc9938dd --- /dev/null +++ b/packages/client/src/pages/signup-complete.vue @@ -0,0 +1,50 @@ +<template> +<div> + {{ $ts.processing }} +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + + }, + + props: { + code: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.signup, + icon: 'fas fa-user' + }, + } + }, + + mounted() { + os.apiWithDialog('signup-pending', { + code: this.code, + }).then(res => { + login(res.i, '/'); + }); + }, + + methods: { + + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue new file mode 100644 index 0000000000..f4709659e3 --- /dev/null +++ b/packages/client/src/pages/tag.vue @@ -0,0 +1,57 @@ +<template> +<div class="_section"> + <XNotes ref="notes" class="_content" :pagination="pagination" @before="before" @after="after"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + props: { + tag: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.tag, + icon: 'fas fa-hashtag' + }, + pagination: { + endpoint: 'notes/search-by-tag', + limit: 10, + params: () => ({ + tag: this.tag, + }) + }, + }; + }, + + watch: { + tag() { + (this.$refs.notes as any).reload(); + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/test.vue b/packages/client/src/pages/test.vue new file mode 100644 index 0000000000..9dd9ae5e0c --- /dev/null +++ b/packages/client/src/pages/test.vue @@ -0,0 +1,259 @@ +<template> +<div class="_section"> + <div class="_content"> + <div class="_card _gap"> + <div class="_title">Dialog</div> + <div class="_content"> + <MkInput v-model="dialogTitle"> + <template #label>Title</template> + </MkInput> + <MkInput v-model="dialogBody"> + <template #label>Body</template> + </MkInput> + <MkRadio v-model="dialogType" value="info">Info</MkRadio> + <MkRadio v-model="dialogType" value="success">Success</MkRadio> + <MkRadio v-model="dialogType" value="warning">Warn</MkRadio> + <MkRadio v-model="dialogType" value="error">Error</MkRadio> + <MkSwitch v-model="dialogCancel"> + <span>With cancel button</span> + </MkSwitch> + <MkSwitch v-model="dialogCancelByBgClick"> + <span>Can cancel by modal bg click</span> + </MkSwitch> + <MkSwitch v-model="dialogInput"> + <span>With input field</span> + </MkSwitch> + <MkButton @click="showDialog()">Show</MkButton> + </div> + <div class="_content"> + <code>Result: {{ dialogResult }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">Form</div> + <div class="_content"> + <MkInput v-model="formTitle"> + <template #label>Title</template> + </MkInput> + <MkTextarea v-model="formForm"> + <template #label>Form</template> + </MkTextarea> + <MkButton @click="form()">Show</MkButton> + </div> + <div class="_content"> + <code>Result: {{ formResult }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">MFM</div> + <div class="_content"> + <MkTextarea v-model="mfm"> + <template #label>MFM</template> + </MkTextarea> + </div> + <div class="_content"> + <Mfm :text="mfm"/> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">selectDriveFile</div> + <div class="_content"> + <MkSwitch v-model="selectDriveFileMultiple"> + <span>Multiple</span> + </MkSwitch> + <MkButton @click="selectDriveFile()">selectDriveFile</MkButton> + </div> + <div class="_content"> + <code>Result: {{ JSON.stringify(selectDriveFileResult) }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">selectDriveFolder</div> + <div class="_content"> + <MkSwitch v-model="selectDriveFolderMultiple"> + <span>Multiple</span> + </MkSwitch> + <MkButton @click="selectDriveFolder()">selectDriveFolder</MkButton> + </div> + <div class="_content"> + <code>Result: {{ JSON.stringify(selectDriveFolderResult) }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">selectUser</div> + <div class="_content"> + <MkButton @click="selectUser()">selectUser</MkButton> + </div> + <div class="_content"> + <code>Result: {{ user }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">Notification</div> + <div class="_content"> + <MkInput v-model="notificationIconUrl"> + <template #label>Icon URL</template> + </MkInput> + <MkInput v-model="notificationHeader"> + <template #label>Header</template> + </MkInput> + <MkTextarea v-model="notificationBody"> + <template #label>Body</template> + </MkTextarea> + <MkButton @click="createNotification()">createNotification</MkButton> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">Waiting dialog</div> + <div class="_content"> + <MkButton inline @click="openWaitingDialog()">icon only</MkButton> + <MkButton inline @click="openWaitingDialog('Doing')">with text</MkButton> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">Messaging window</div> + <div class="_content"> + <MkButton @click="messagingWindowOpen()">open</MkButton> + </div> + </div> + + <MkButton @click="resetTutorial()">Reset tutorial</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkRadio from '@/components/form/radio.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSwitch, + MkTextarea, + MkRadio, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'TEST', + icon: 'fas fa-exclamation-triangle' + }, + dialogTitle: 'Hello', + dialogBody: 'World!', + dialogType: 'info', + dialogCancel: false, + dialogCancelByBgClick: true, + dialogInput: false, + dialogResult: null, + formTitle: 'Test form', + formForm: JSON.stringify({ + foo: { + type: 'boolean', + default: true, + label: 'This is a boolean property' + }, + bar: { + type: 'number', + default: 300, + label: 'This is a number property' + }, + baz: { + type: 'string', + default: 'Misskey makes you happy.', + label: 'This is a string property' + }, + qux: { + type: 'string', + multiline: true, + default: 'Misskey makes\nyou happy.', + label: 'Multiline string' + }, + }, null, '\t'), + formResult: null, + mfm: '', + selectDriveFileMultiple: false, + selectDriveFolderMultiple: false, + selectDriveFileResult: null, + selectDriveFolderResult: null, + user: null, + notificationIconUrl: null, + notificationHeader: '', + notificationBody: '', + } + }, + + methods: { + async showDialog() { + this.dialogResult = null; + this.dialogResult = await os.dialog({ + type: this.dialogType, + title: this.dialogTitle, + text: this.dialogBody, + showCancelButton: this.dialogCancel, + cancelableByBgClick: this.dialogCancelByBgClick, + input: this.dialogInput ? {} : null + }); + }, + + async form() { + this.formResult = null; + this.formResult = await os.form(this.formTitle, JSON.parse(this.formForm)); + }, + + async selectDriveFile() { + this.selectDriveFileResult = null; + this.selectDriveFileResult = await os.selectDriveFile(this.selectDriveFileMultiple); + }, + + async selectDriveFolder() { + this.selectDriveFolderResult = null; + this.selectDriveFolderResult = await os.selectDriveFolder(this.selectDriveFolderMultiple); + }, + + async selectUser() { + this.user = null; + this.user = await os.selectUser(); + }, + + async createNotification() { + os.api('notifications/create', { + header: this.notificationHeader, + body: this.notificationBody, + icon: this.notificationIconUrl, + }); + }, + + messagingWindowOpen() { + os.pageWindow('/my/messaging'); + }, + + openWaitingDialog(text?) { + const promise = new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + os.promiseDialog(promise, null, null, text); + }, + + resetTutorial() { + this.$store.set('tutorial', 0); + }, + } +}); +</script> diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue new file mode 100644 index 0000000000..d1a892629b --- /dev/null +++ b/packages/client/src/pages/theme-editor.vue @@ -0,0 +1,306 @@ +<template> +<FormBase class="cwepdizn"> + <div class="_debobigegoItem colorPicker"> + <div class="_debobigegoLabel">{{ $ts.backgroundColor }}</div> + <div class="_debobigegoPanel colors"> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" @click="setBgColor(color)" class="color _button" :class="{ active: theme.props.bg === color.color }"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" @click="setBgColor(color)" class="color _button" :class="{ active: theme.props.bg === color.color }"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> + </div> + </div> + <div class="_debobigegoItem colorPicker"> + <div class="_debobigegoLabel">{{ $ts.accentColor }}</div> + <div class="_debobigegoPanel colors"> + <div class="row"> + <button v-for="color in accentColors" :key="color" @click="setAccentColor(color)" class="color rounded _button" :class="{ active: theme.props.accent === color }"> + <div class="preview" :style="{ background: color }"></div> + </button> + </div> + </div> + </div> + <div class="_debobigegoItem colorPicker"> + <div class="_debobigegoLabel">{{ $ts.textColor }}</div> + <div class="_debobigegoPanel colors"> + <div class="row"> + <button v-for="color in fgColors" :key="color" @click="setFgColor(color)" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }"> + <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div> + </button> + </div> + </div> + </div> + + <FormGroup v-if="codeEnabled"> + <FormTextarea v-model="themeCode" tall> + <span>{{ $ts._theme.code }}</span> + </FormTextarea> + <FormButton @click="applyThemeCode" primary>{{ $ts.apply }}</FormButton> + </FormGroup> + <FormButton v-else @click="codeEnabled = true"><i class="fas fa-code"></i> {{ $ts.editCode }}</FormButton> + + <FormGroup v-if="descriptionEnabled"> + <FormTextarea v-model="description"> + <span>{{ $ts._theme.description }}</span> + </FormTextarea> + </FormGroup> + <FormButton v-else @click="descriptionEnabled = true">{{ $ts.addDescription }}</FormButton> + + <FormGroup> + <FormButton @click="showPreview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton> + <FormButton @click="saveAs" primary><i class="fas fa-save"></i> {{ $ts.saveAs }}</FormButton> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import * as tinycolor from 'tinycolor2'; +import { v4 as uuid} from 'uuid'; +import * as JSON5 from 'json5'; + +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormGroup from '@/components/debobigego/group.vue'; + +import { Theme, applyTheme, validateTheme, darkTheme, lightTheme } from '@/scripts/theme'; +import { host } from '@/config'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { addTheme } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormTextarea, + FormGroup, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.themeEditor, + icon: 'fas fa-palette', + }, + theme: { + base: 'light', + props: lightTheme.props + } as Theme, + codeEnabled: false, + descriptionEnabled: false, + description: null, + themeCode: null, + bgColors: [ + { color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' }, + { color: '#f0eee9', kind: 'light', forPreview: '#f3e2b9' }, + { color: '#e9eff0', kind: 'light', forPreview: '#bfe3e8' }, + { color: '#f0e9ee', kind: 'light', forPreview: '#f1d1e8' }, + { color: '#dce2e0', kind: 'light', forPreview: '#a4dccc' }, + { color: '#e2e0dc', kind: 'light', forPreview: '#d8c7a5' }, + { color: '#d5dbe0', kind: 'light', forPreview: '#b0cae0' }, + { color: '#dad5d5', kind: 'light', forPreview: '#d6afaf' }, + { color: '#2b2b2b', kind: 'dark', forPreview: '#444444' }, + { color: '#362e29', kind: 'dark', forPreview: '#735c4d' }, + { color: '#303629', kind: 'dark', forPreview: '#506d2f' }, + { color: '#293436', kind: 'dark', forPreview: '#258192' }, + { color: '#2e2936', kind: 'dark', forPreview: '#504069' }, + { color: '#252722', kind: 'dark', forPreview: '#3c462f' }, + { color: '#212525', kind: 'dark', forPreview: '#303e3e' }, + { color: '#191919', kind: 'dark', forPreview: '#272727' }, + ], + accentColors: ['#e36749', '#f29924', '#98c934', '#34c9a9', '#34a1c9', '#606df7', '#8d34c9', '#e84d83'], + fgColors: [ + { color: 'none', forLight: '#5f5f5f', forDark: '#dadada', forPreview: null }, + { color: 'red', forLight: '#7f6666', forDark: '#e4d1d1', forPreview: '#ca4343' }, + { color: 'yellow', forLight: '#736955', forDark: '#e0d5c0', forPreview: '#d49923' }, + { color: 'green', forLight: '#586d5b', forDark: '#d1e4d4', forPreview: '#4cbd5c' }, + { color: 'cyan', forLight: '#5d7475', forDark: '#d1e3e4', forPreview: '#2abdc3' }, + { color: 'blue', forLight: '#676880', forDark: '#d1d2e4', forPreview: '#7275d8' }, + { color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' }, + ], + changed: false, + } + }, + + created() { + this.$watch('theme', this.apply, { deep: true }); + window.addEventListener('beforeunload', this.beforeunload); + }, + + beforeUnmount() { + window.removeEventListener('beforeunload', this.beforeunload); + }, + + async beforeRouteLeave(to, from) { + if (this.changed && !(await this.leaveConfirm())) { + return false; + } + }, + + methods: { + beforeunload(e: BeforeUnloadEvent) { + if (this.changed) { + e.preventDefault(); + e.returnValue = ''; + } + }, + + async leaveConfirm(): Promise<boolean> { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.leaveConfirm, + showCancelButton: true + }); + return !canceled; + }, + + showPreview() { + os.pageWindow('preview'); + }, + + setBgColor(color) { + if (this.theme.base != color.kind) { + const base = color.kind === 'dark' ? darkTheme : lightTheme; + for (const prop of Object.keys(base.props)) { + if (prop === 'accent') continue; + if (prop === 'fg') continue; + this.theme.props[prop] = base.props[prop]; + } + } + this.theme.base = color.kind; + this.theme.props.bg = color.color; + + if (this.theme.props.fg) { + const matchedFgColor = this.fgColors.find(x => [tinycolor(x.forLight).toRgbString(), tinycolor(x.forDark).toRgbString()].includes(tinycolor(this.theme.props.fg).toRgbString())); + if (matchedFgColor) this.setFgColor(matchedFgColor); + } + }, + + setAccentColor(color) { + this.theme.props.accent = color; + }, + + setFgColor(color) { + this.theme.props.fg = this.theme.base === 'light' ? color.forLight : color.forDark; + }, + + apply() { + this.themeCode = JSON5.stringify(this.theme, null, '\t'); + applyTheme(this.theme, false); + this.changed = true; + }, + + applyThemeCode() { + let parsed; + + try { + parsed = JSON5.parse(this.themeCode); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$ts._theme.invalid + }); + return; + } + + this.theme = parsed; + }, + + async saveAs() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.name, + input: { + allowEmpty: false + } + }); + if (canceled) return; + + this.theme.id = uuid(); + this.theme.name = name; + this.theme.author = `@${this.$i.username}@${toUnicode(host)}`; + if (this.description) this.theme.desc = this.description; + addTheme(this.theme); + applyTheme(this.theme); + if (this.$store.state.darkMode) { + ColdDeviceStorage.set('darkTheme', this.theme); + } else { + ColdDeviceStorage.set('lightTheme', this.theme); + } + this.changed = false; + os.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: this.theme.name }) + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.cwepdizn { + max-width: 800px; + margin: 0 auto; + + > .colorPicker { + > .colors { + padding: 32px; + text-align: center; + + > .row { + > .color { + display: inline-block; + position: relative; + width: 64px; + height: 64px; + border-radius: 8px; + + > .preview { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 42px; + height: 42px; + border-radius: 4px; + box-shadow: 0 2px 4px rgb(0 0 0 / 30%); + transition: transform 0.15s ease; + } + + &:hover { + > .preview { + transform: scale(1.1); + } + } + + &.active { + box-shadow: 0 0 0 2px var(--divider) inset; + } + + &.rounded { + border-radius: 999px; + + > .preview { + border-radius: 999px; + } + } + + &.char { + line-height: 42px; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/timeline.tutorial.vue b/packages/client/src/pages/timeline.tutorial.vue new file mode 100644 index 0000000000..4d6dd0af41 --- /dev/null +++ b/packages/client/src/pages/timeline.tutorial.vue @@ -0,0 +1,131 @@ +<template> +<div class="_card tbkwesmv"> + <div class="_title"><i class="fas fa-info-circle"></i> {{ $ts._tutorial.title }}</div> + <div class="_content" v-if="tutorial === 0"> + <div>{{ $ts._tutorial.step1_1 }}</div> + <div>{{ $ts._tutorial.step1_2 }}</div> + <div>{{ $ts._tutorial.step1_3 }}</div> + </div> + <div class="_content" v-else-if="tutorial === 1"> + <div>{{ $ts._tutorial.step2_1 }}</div> + <div>{{ $ts._tutorial.step2_2 }}</div> + <MkA class="_link" to="/settings/profile">{{ $ts.editProfile }}</MkA> + </div> + <div class="_content" v-else-if="tutorial === 2"> + <div>{{ $ts._tutorial.step3_1 }}</div> + <div>{{ $ts._tutorial.step3_2 }}</div> + <div>{{ $ts._tutorial.step3_3 }}</div> + <small>{{ $ts._tutorial.step3_4 }}</small> + </div> + <div class="_content" v-else-if="tutorial === 3"> + <div>{{ $ts._tutorial.step4_1 }}</div> + <div>{{ $ts._tutorial.step4_2 }}</div> + </div> + <div class="_content" v-else-if="tutorial === 4"> + <div>{{ $ts._tutorial.step5_1 }}</div> + <I18n :src="$ts._tutorial.step5_2" tag="div"> + <template #featured> + <MkA class="_link" to="/featured">{{ $ts.featured }}</MkA> + </template> + <template #explore> + <MkA class="_link" to="/explore">{{ $ts.explore }}</MkA> + </template> + </I18n> + <div>{{ $ts._tutorial.step5_3 }}</div> + <small>{{ $ts._tutorial.step5_4 }}</small> + </div> + <div class="_content" v-else-if="tutorial === 5"> + <div>{{ $ts._tutorial.step6_1 }}</div> + <div>{{ $ts._tutorial.step6_2 }}</div> + <div>{{ $ts._tutorial.step6_3 }}</div> + </div> + <div class="_content" v-else-if="tutorial === 6"> + <div>{{ $ts._tutorial.step7_1 }}</div> + <I18n :src="$ts._tutorial.step7_2" tag="div"> + <template #help> + <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ $ts.help }}</a> + </template> + </I18n> + <div>{{ $ts._tutorial.step7_3 }}</div> + </div> + + <div class="_footer navigation"> + <div class="step"> + <button class="arrow _button" @click="tutorial--" :disabled="tutorial === 0"> + <i class="fas fa-chevron-left"></i> + </button> + <span>{{ tutorial + 1 }} / 7</span> + <button class="arrow _button" @click="tutorial++" :disabled="tutorial === 6"> + <i class="fas fa-chevron-right"></i> + </button> + </div> + <MkButton class="ok" @click="tutorial = -1" primary v-if="tutorial === 6"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton> + <MkButton class="ok" @click="tutorial++" primary v-else><i class="fas fa-check"></i> {{ $ts.next }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkButton, + }, + + data() { + return { + } + }, + + computed: { + tutorial: { + get() { return this.$store.reactiveState.tutorial.value || 0; }, + set(value) { this.$store.set('tutorial', value); } + }, + }, +}); +</script> + +<style lang="scss" scoped> +.tbkwesmv { + > ._content { + > small { + opacity: 0.7; + } + } + + > .navigation { + display: flex; + flex-direction: row; + align-items: baseline; + + > .step { + > .arrow { + padding: 4px; + + &:disabled { + opacity: 0.5; + } + + &:first-child { + padding-right: 8px; + } + + &:last-child { + padding-left: 8px; + } + } + + > span { + margin: 0 4px; + } + } + + > .ok { + margin-left: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue new file mode 100644 index 0000000000..911d6f5c6a --- /dev/null +++ b/packages/client/src/pages/timeline.vue @@ -0,0 +1,225 @@ +<template> +<div class="cmuxhskf" v-size="{ min: [800] }" v-hotkey.global="keymap"> + <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/> + <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/> + + <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline ref="tl" class="tl" + :key="src" + :src="src" + :sound="true" + @before="before()" + @after="after()" + @queue="queueUpdated" + /> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import Progress from '@/scripts/loading'; +import XTimeline from '@/components/timeline.vue'; +import XPostForm from '@/components/post-form.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + name: 'timeline', + + components: { + XTimeline, + XTutorial: defineAsyncComponent(() => import('./timeline.tutorial.vue')), + XPostForm, + }, + + data() { + return { + src: 'home', + queue: 0, + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.timeline, + icon: this.src === 'local' ? 'fas fa-comments' : this.src === 'social' ? 'fas fa-share-alt' : this.src === 'global' ? 'fas fa-globe' : 'fas fa-home', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-list-ul', + text: this.$ts.lists, + handler: this.chooseList + }, { + icon: 'fas fa-satellite', + text: this.$ts.antennas, + handler: this.chooseAntenna + }, { + icon: 'fas fa-satellite-dish', + text: this.$ts.channel, + handler: this.chooseChannel + }, { + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }], + tabs: [{ + active: this.src === 'home', + title: this.$ts._timelines.home, + icon: 'fas fa-home', + iconOnly: true, + onClick: () => { this.src = 'home'; this.saveSrc(); }, + }, { + active: this.src === 'local', + title: this.$ts._timelines.local, + icon: 'fas fa-comments', + iconOnly: true, + onClick: () => { this.src = 'local'; this.saveSrc(); }, + }, { + active: this.src === 'social', + title: this.$ts._timelines.social, + icon: 'fas fa-share-alt', + iconOnly: true, + onClick: () => { this.src = 'social'; this.saveSrc(); }, + }, { + active: this.src === 'global', + title: this.$ts._timelines.global, + icon: 'fas fa-globe', + iconOnly: true, + onClick: () => { this.src = 'global'; this.saveSrc(); }, + }], + })), + }; + }, + + computed: { + keymap(): any { + return { + 't': this.focus + }; + }, + + isLocalTimelineAvailable(): boolean { + return !this.$instance.disableLocalTimeline || this.$i.isModerator || this.$i.isAdmin; + }, + + isGlobalTimelineAvailable(): boolean { + return !this.$instance.disableGlobalTimeline || this.$i.isModerator || this.$i.isAdmin; + }, + }, + + watch: { + src() { + this.showNav = false; + }, + }, + + created() { + this.src = this.$store.state.tl.src; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + }, + + queueUpdated(q) { + this.queue = q; + }, + + top() { + scroll(this.$el, { top: 0 }); + }, + + async chooseList(ev) { + const lists = await os.api('users/lists/list'); + const items = lists.map(list => ({ + type: 'link', + text: list.name, + to: `/timeline/list/${list.id}` + })); + os.popupMenu(items, ev.currentTarget || ev.target); + }, + + async chooseAntenna(ev) { + const antennas = await os.api('antennas/list'); + const items = antennas.map(antenna => ({ + type: 'link', + text: antenna.name, + indicate: antenna.hasUnreadNote, + to: `/timeline/antenna/${antenna.id}` + })); + os.popupMenu(items, ev.currentTarget || ev.target); + }, + + async chooseChannel(ev) { + const channels = await os.api('channels/followed'); + const items = channels.map(channel => ({ + type: 'link', + text: channel.name, + indicate: channel.hasUnreadNote, + to: `/channels/${channel.id}` + })); + os.popupMenu(items, ev.currentTarget || ev.target); + }, + + saveSrc() { + this.$store.set('tl', { + src: this.src, + }); + }, + + async timetravel() { + const { canceled, result: date } = await os.dialog({ + title: this.$ts.date, + input: { + type: 'date' + } + }); + if (canceled) return; + + this.$refs.tl.timetravel(new Date(date)); + }, + + focus() { + (this.$refs.tl as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.cmuxhskf { + padding: var(--margin); + + > .new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + + > button { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; + } + } + + > .post-form { + border-radius: var(--radius); + } + + > .tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; + } + + &.min-width_800px { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/client/src/pages/user-ap-info.vue b/packages/client/src/pages/user-ap-info.vue new file mode 100644 index 0000000000..6253faa242 --- /dev/null +++ b/packages/client/src/pages/user-ap-info.vue @@ -0,0 +1,124 @@ +<template> +<FormBase> + <FormSuspense :p="apPromiseFactory" v-slot="{ result: ap }"> + <FormGroup> + <template #label>ActivityPub</template> + <FormKeyValueView> + <template #key>Type</template> + <template #value><span class="_monospace">{{ ap.type }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>URI</template> + <template #value><span class="_monospace">{{ ap.id }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>URL</template> + <template #value><span class="_monospace">{{ ap.url }}</span></template> + </FormKeyValueView> + <FormGroup> + <FormKeyValueView> + <template #key>Inbox</template> + <template #value><span class="_monospace">{{ ap.inbox }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Shared Inbox</template> + <template #value><span class="_monospace">{{ ap.sharedInbox || ap.endpoints.sharedInbox }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Outbox</template> + <template #value><span class="_monospace">{{ ap.outbox }}</span></template> + </FormKeyValueView> + </FormGroup> + <FormTextarea readonly tall code pre :value="ap.publicKey.publicKeyPem"> + <span>Public Key</span> + </FormTextarea> + <FormKeyValueView> + <template #key>Discoverable</template> + <template #value>{{ ap.discoverable ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>ManuallyApprovesFollowers</template> + <template #value>{{ ap.manuallyApprovesFollowers ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormObjectView tall :value="ap"> + <span>Raw</span> + </FormObjectView> + <FormGroup> + <FormLink :to="`https://${user.host}/.well-known/webfinger?resource=acct:${user.username}`" external>WebFinger</FormLink> + </FormGroup> + <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> + <FormKeyValueView v-else> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormObjectView from '@/components/debobigego/object-view.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; +import { url } from '@/config'; + +export default defineComponent({ + components: { + FormBase, + FormTextarea, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + }, + + props: { + userId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.userInfo, + icon: 'fas fa-info-circle' + }, + user: null, + apPromiseFactory: null, + } + }, + + mounted() { + this.fetch(); + }, + + methods: { + number, + bytes, + + async fetch() { + this.user = await os.api('users/show', { + userId: this.userId + }); + + this.apPromiseFactory = () => os.api('ap/get', { + uri: this.user.uri || `${url}/users/${this.user.id}` + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue new file mode 100644 index 0000000000..b77d879a7e --- /dev/null +++ b/packages/client/src/pages/user-info.vue @@ -0,0 +1,245 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <div class="_debobigegoItem aeakzknw"> + <MkAvatar class="avatar" :user="user" :show-indicator="true"/> + </div> + + <FormLink :to="userPage(user)">Profile</FormLink> + + <FormGroup> + <FormKeyValueView> + <template #key>Acct</template> + <template #value><span class="_monospace">{{ acct(user) }}</span></template> + </FormKeyValueView> + + <FormKeyValueView> + <template #key>ID</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> + </FormKeyValueView> + </FormGroup> + + <FormGroup v-if="iAmModerator"> + <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:modelValue="toggleModerator" v-model="moderator">{{ $ts.moderator }}</FormSwitch> + <FormSwitch @update:modelValue="toggleSilence" v-model="silenced">{{ $ts.silence }}</FormSwitch> + <FormSwitch @update:modelValue="toggleSuspend" v-model="suspended">{{ $ts.suspend }}</FormSwitch> + </FormGroup> + + <FormGroup> + <FormButton v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> + <FormButton v-if="user.host == null && iAmModerator" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> + </FormGroup> + + <FormGroup> + <FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink> + + <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> + <FormKeyValueView v-else> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + </FormGroup> + + <FormObjectView tall :value="user"> + <span>Raw</span> + </FormObjectView> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent } from 'vue'; +import FormObjectView from '@/components/debobigego/object-view.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; +import { url } from '@/config'; +import { userPage, acct } from '@/filters/user'; + +export default defineComponent({ + components: { + FormBase, + FormTextarea, + FormSwitch, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + }, + + props: { + userId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.user ? acct(this.user) : this.$ts.userInfo, + icon: 'fas fa-info-circle', + actions: this.user ? [this.user.url ? { + text: this.user.url, + icon: 'fas fa-external-link-alt', + handler: () => { + window.open(this.user.url, '_blank'); + } + } : undefined].filter(x => x !== undefined) : [], + })), + init: null, + user: null, + info: null, + moderator: false, + silenced: false, + suspended: false, + } + }, + + computed: { + iAmModerator(): boolean { + return this.$i && (this.$i.isAdmin || this.$i.isModerator); + } + }, + + watch: { + userId: { + handler() { + this.init = this.createFetcher(); + }, + immediate: true + } + }, + + methods: { + number, + bytes, + userPage, + acct, + + createFetcher() { + if (this.iAmModerator) { + return () => Promise.all([os.api('users/show', { + userId: this.userId + }), os.api('admin/show-user', { + userId: this.userId + })]).then(([user, info]) => { + this.user = user; + this.info = info; + this.moderator = this.info.isModerator; + this.silenced = this.info.isSilenced; + this.suspended = this.info.isSuspended; + }); + } else { + return () => os.api('users/show', { + userId: this.userId + }).then((user) => { + this.user = user; + }); + } + }, + + refreshUser() { + this.init = this.createFetcher(); + }, + + async updateRemoteUser() { + await os.apiWithDialog('federation/update-remote-user', { userId: this.user.id }); + this.refreshUser(); + }, + + async resetPassword() { + const { password } = await os.api('admin/reset-password', { + userId: this.user.id, + }); + + os.dialog({ + type: 'success', + text: this.$t('newPasswordIs', { password }) + }); + }, + + async toggleSilence(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm, + }); + if (confirm.canceled) { + this.silenced = !v; + } else { + await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleSuspend(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm, + }); + if (confirm.canceled) { + this.suspended = !v; + } else { + await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleModerator(v) { + await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); + await this.refreshUser(); + }, + + async deleteAllFiles() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$ts.deleteAllFilesConfirm, + }); + if (confirm.canceled) return; + const process = async () => { + await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); + os.success(); + }; + await process().catch(e => { + os.dialog({ + type: 'error', + text: e.toString() + }); + }); + await this.refreshUser(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.aeakzknw { + > .avatar { + display: block; + margin: 0 auto; + width: 64px; + height: 64px; + } +} +</style> diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue new file mode 100644 index 0000000000..2fc2476fba --- /dev/null +++ b/packages/client/src/pages/user-list-timeline.vue @@ -0,0 +1,147 @@ +<template> +<div class="eqqrhokj" v-hotkey.global="keymap" v-size="{ min: [800] }"> + <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline ref="tl" class="tl" + :key="listId" + src="list" + :list="listId" + :sound="true" + @before="before()" + @after="after()" + @queue="queueUpdated" + /> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import Progress from '@/scripts/loading'; +import XTimeline from '@/components/timeline.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XTimeline, + }, + + props: { + listId: { + type: String, + required: true + } + }, + + data() { + return { + list: null, + queue: 0, + [symbols.PAGE_INFO]: computed(() => this.list ? { + title: this.list.name, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }, { + icon: 'fas fa-cog', + text: this.$ts.settings, + handler: this.settings + }], + } : null), + }; + }, + + computed: { + keymap(): any { + return { + 't': this.focus + }; + }, + }, + + watch: { + listId: { + async handler() { + this.list = await os.api('users/lists/show', { + listId: this.listId + }); + }, + immediate: true + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + }, + + queueUpdated(q) { + this.queue = q; + }, + + top() { + scroll(this.$el, { top: 0 }); + }, + + settings() { + this.$router.push(`/my/lists/${this.listId}`); + }, + + async timetravel() { + const { canceled, result: date } = await os.dialog({ + title: this.$ts.date, + input: { + type: 'date' + } + }); + if (canceled) return; + + this.$refs.tl.timetravel(new Date(date)); + }, + + focus() { + (this.$refs.tl as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.eqqrhokj { + padding: var(--margin); + + > .new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + + > button { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; + } + } + + > .tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; + } + + &.min-width_800px { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/client/src/pages/user/clips.vue b/packages/client/src/pages/user/clips.vue new file mode 100644 index 0000000000..2ec96d2286 --- /dev/null +++ b/packages/client/src/pages/user/clips.vue @@ -0,0 +1,50 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + </MkA> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; + +export default defineComponent({ + components: { + MkPagination, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/clips', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/user/follow-list.vue b/packages/client/src/pages/user/follow-list.vue new file mode 100644 index 0000000000..fec4431419 --- /dev/null +++ b/packages/client/src/pages/user/follow-list.vue @@ -0,0 +1,65 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" class="mk-following-or-followers" ref="list"> + <div class="users _isolated"> + <MkUserInfo class="user" v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :user="user" :key="user.id"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkUserInfo from '@/components/user-info.vue'; +import MkPagination from '@/components/ui/pagination.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkUserInfo, + }, + + props: { + user: { + type: Object, + required: true + }, + type: { + type: String, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: () => this.type === 'following' ? 'users/following' : 'users/followers', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + type() { + this.$refs.list.reload(); + }, + + user() { + this.$refs.list.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-following-or-followers { + > .users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); + } +} +</style> diff --git a/packages/client/src/pages/user/gallery.vue b/packages/client/src/pages/user/gallery.vue new file mode 100644 index 0000000000..fb99cdff19 --- /dev/null +++ b/packages/client/src/pages/user/gallery.vue @@ -0,0 +1,56 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}"> + <div class="jrnovfpt"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkGalleryPostPreview, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/gallery/posts', + limit: 6, + params: () => ({ + userId: this.user.id + }) + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> +.jrnovfpt { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: var(--margin); +} +</style> diff --git a/packages/client/src/pages/user/index.activity.vue b/packages/client/src/pages/user/index.activity.vue new file mode 100644 index 0000000000..e51d6c6090 --- /dev/null +++ b/packages/client/src/pages/user/index.activity.vue @@ -0,0 +1,34 @@ +<template> +<MkContainer> + <template #header><i class="fas fa-chart-bar" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template> + + <div style="padding: 8px;"> + <MkChart src="per-user-notes" :args="{ user, withoutAll: true }" span="day" :limit="limit" :stacked="true" :detailed="false" :aspect-ratio="6"/> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import MkContainer from '@/components/ui/container.vue'; +import MkChart from '@/components/chart.vue'; + +export default defineComponent({ + components: { + MkContainer, + MkChart, + }, + props: { + user: { + type: Object, + required: true + }, + limit: { + type: Number, + required: false, + default: 40 + } + }, +}); +</script> diff --git a/packages/client/src/pages/user/index.photos.vue b/packages/client/src/pages/user/index.photos.vue new file mode 100644 index 0000000000..4c52dceae6 --- /dev/null +++ b/packages/client/src/pages/user/index.photos.vue @@ -0,0 +1,107 @@ +<template> +<MkContainer :max-height="300" :foldable="true"> + <template #header><i class="fas fa-image" style="margin-right: 0.5em;"></i>{{ $ts.images }}</template> + <div class="ujigsodd"> + <MkLoading v-if="fetching"/> + <div class="stream" v-if="!fetching && images.length > 0"> + <MkA v-for="image in images" + class="img" + :to="notePage(image.note)" + :key="image.id" + > + <ImgWithBlurhash :hash="image.blurhash" :src="thumbnail(image.file)" :alt="image.name" :title="image.name"/> + </MkA> + </div> + <p class="empty" v-if="!fetching && images.length == 0">{{ $ts.nothing }}</p> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import notePage from '@/filters/note'; +import * as os from '@/os'; +import MkContainer from '@/components/ui/container.vue'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; + +export default defineComponent({ + components: { + MkContainer, + ImgWithBlurhash, + }, + props: { + user: { + type: Object, + required: true + }, + }, + data() { + return { + fetching: true, + images: [], + }; + }, + mounted() { + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/apng', + 'image/vnd.mozilla.apng', + ]; + os.api('users/notes', { + userId: this.user.id, + fileType: image, + excludeNsfw: this.$store.state.nsfw !== 'ignore', + limit: 10, + }).then(notes => { + for (const note of notes) { + for (const file of note.files) { + this.images.push({ + note, + file + }); + } + } + this.fetching = false; + }); + }, + methods: { + thumbnail(image: any): string { + return this.$store.state.disableShowingAnimatedImages + ? getStaticImageUrl(image.thumbnailUrl) + : image.thumbnailUrl; + }, + notePage + }, +}); +</script> + +<style lang="scss" scoped> +.ujigsodd { + padding: 8px; + + > .stream { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-gap: 6px; + + > .img { + height: 128px; + border-radius: 6px; + overflow: clip; + } + } + + > .empty { + margin: 0; + padding: 16px; + text-align: center; + + > i { + margin-right: 4px; + } + } +} +</style> diff --git a/packages/client/src/pages/user/index.timeline.vue b/packages/client/src/pages/user/index.timeline.vue new file mode 100644 index 0000000000..eff38ec3c8 --- /dev/null +++ b/packages/client/src/pages/user/index.timeline.vue @@ -0,0 +1,68 @@ +<template> +<div class="yrzkoczt" v-sticky-container> + <MkTab v-model="with_" class="tab"> + <option :value="null">{{ $ts.notes }}</option> + <option value="replies">{{ $ts.notesAndReplies }}</option> + <option value="files">{{ $ts.withFiles }}</option> + </MkTab> + <XNotes ref="timeline" :no-gap="true" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNotes from '@/components/notes.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XNotes, + MkTab, + }, + + props: { + user: { + type: Object, + required: true, + }, + }, + + watch: { + user() { + this.$refs.timeline.reload(); + }, + + with_() { + this.$refs.timeline.reload(); + }, + }, + + data() { + return { + date: null, + with_: null, + pagination: { + endpoint: 'users/notes', + limit: 10, + params: init => ({ + userId: this.user.id, + includeReplies: this.with_ === 'replies', + withFiles: this.with_ === 'files', + untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), + }) + } + }; + }, +}); +</script> + +<style lang="scss" scoped> +.yrzkoczt { + > .tab { + margin: calc(var(--margin) / 2) 0; + padding: calc(var(--margin) / 2) 0; + background: var(--bg); + } +} +</style> diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue new file mode 100644 index 0000000000..d2531c0d1b --- /dev/null +++ b/packages/client/src/pages/user/index.vue @@ -0,0 +1,829 @@ +<template> +<div> +<transition name="fade" mode="out-in"> + <div class="ftskorzw wide" v-if="user && narrow === false"> + <MkRemoteCaution v-if="user.host != null" :href="user.url"/> + + <div class="banner-container" :style="style"> + <div class="banner" ref="banner" :style="style"></div> + </div> + <div class="contents"> + <div class="side _forceContainerFull_"> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="name"> + <MkUserName :user="user" :nowrap="false" class="name"/> + <MkAcct :user="user" :detail="true" class="acct"/> + </div> + <div class="followed" v-if="$i && $i.id != user.id && user.isFollowed"><span>{{ $ts.followsYou }}</span></div> + <div class="status"> + <MkA :to="userPage(user)" :class="{ active: page === 'index' }"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ $ts.notes }}</span> + </MkA> + <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ $ts.following }}</span> + </MkA> + <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ $ts.followers }}</span> + </MkA> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ $ts.noAccountDescription }}</p> + </div> + <div class="fields system"> + <dl class="field" v-if="user.location"> + <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl class="field" v-if="user.birthday"> + <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div class="fields" v-if="user.fields.length > 0"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <XActivity :user="user" :key="user.id" class="_gap"/> + <XPhotos :user="user" :key="user.id" class="_gap"/> + </div> + <div class="main"> + <div class="actions"> + <button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button> + <MkFollowButton v-if="!$i || $i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + <template v-if="page === 'index'"> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> + <XNote v-for="note in user.pinnedNotes" class="note _gap" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/> + </div> + <div class="_gap"> + <XUserTimeline :user="user"/> + </div> + </template> + <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_gap"/> + <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_gap"/> + <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/> + <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> + </div> + </div> + </div> + <MkSpacer v-else-if="user && narrow === true" :content-max="800"> + <div class="ftskorzw narrow" v-size="{ max: [500] }"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> --> + + <div class="profile"> + <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> + + <div class="_block main" :key="user.id"> + <div class="banner-container" :style="style"> + <div class="banner" ref="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true" /></span> + <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> + <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> + <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> + </div> + </div> + <span class="followed" v-if="$i && $i.id != user.id && user.isFollowed">{{ $ts.followsYou }}</span> + <div class="actions" v-if="$i"> + <button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button> + <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkUserName :user="user" :nowrap="false" class="name"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true" /></span> + <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> + <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> + <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> + </div> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ $ts.noAccountDescription }}</p> + </div> + <div class="fields system"> + <dl class="field" v-if="user.location"> + <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl class="field" v-if="user.birthday"> + <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div class="fields" v-if="user.fields.length > 0"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <div class="status"> + <MkA :to="userPage(user)" :class="{ active: page === 'index' }" v-click-anime> + <b>{{ number(user.notesCount) }}</b> + <span>{{ $ts.notes }}</span> + </MkA> + <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }" v-click-anime> + <b>{{ number(user.followingCount) }}</b> + <span>{{ $ts.following }}</span> + </MkA> + <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }" v-click-anime> + <b>{{ number(user.followersCount) }}</b> + <span>{{ $ts.followers }}</span> + </MkA> + </div> + </div> + </div> + + <div class="contents"> + <template v-if="page === 'index'"> + <div> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> + <XNote v-for="note in user.pinnedNotes" class="note _block" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/> + </div> + <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo> + <XPhotos :user="user" :key="user.id"/> + <XActivity :user="user" :key="user.id" style="margin-top: var(--margin);"/> + </div> + <div> + <XUserTimeline :user="user"/> + </div> + </template> + <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/> + <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/> + <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/> + <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/> + <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> + <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/> + </div> + </div> + </MkSpacer> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> +</transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import * as age from 's-age'; +import XUserTimeline from './index.timeline.vue'; +import XNote from '@/components/note.vue'; +import MkFollowButton from '@/components/follow-button.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkRemoteCaution from '@/components/remote-caution.vue'; +import MkTab from '@/components/tab.vue'; +import MkInfo from '@/components/ui/info.vue'; +import Progress from '@/scripts/loading'; +import * as Acct from 'misskey-js/built/acct'; +import { getScrollPosition } from '@/scripts/scroll'; +import { getUserMenu } from '@/scripts/get-user-menu'; +import number from '@/filters/number'; +import { userPage, acct as getAcct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XUserTimeline, + XNote, + MkFollowButton, + MkContainer, + MkRemoteCaution, + MkFolder, + MkTab, + MkInfo, + XFollowList: defineAsyncComponent(() => import('./follow-list.vue')), + XReactions: defineAsyncComponent(() => import('./reactions.vue')), + XClips: defineAsyncComponent(() => import('./clips.vue')), + XPages: defineAsyncComponent(() => import('./pages.vue')), + XGallery: defineAsyncComponent(() => import('./gallery.vue')), + XPhotos: defineAsyncComponent(() => import('./index.photos.vue')), + XActivity: defineAsyncComponent(() => import('./index.activity.vue')), + }, + + props: { + acct: { + type: String, + required: true + }, + page: { + type: String, + required: false, + default: 'index' + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.user ? { + icon: 'fas fa-user', + title: this.user.name ? `${this.user.name} (@${this.user.username})` : `@${this.user.username}`, + subtitle: `@${getAcct(this.user)}`, + userName: this.user, + avatar: this.user, + path: `/@${this.user.username}`, + share: { + title: this.user.name, + }, + bg: 'var(--bg)', + tabs: [{ + active: this.page === 'index', + title: this.$ts.overview, + icon: 'fas fa-home', + onClick: () => { this.$router.push('/@' + getAcct(this.user)); }, + }, ...(this.$i && (this.$i.id === this.user.id)) || this.user.publicReactions ? [{ + active: this.page === 'reactions', + title: this.$ts.reaction, + icon: 'fas fa-laugh', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/reactions'); }, + }] : [], { + active: this.page === 'clips', + title: this.$ts.clips, + icon: 'fas fa-paperclip', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/clips'); }, + }, { + active: this.page === 'pages', + title: this.$ts.pages, + icon: 'fas fa-file-alt', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/pages'); }, + }, { + active: this.page === 'gallery', + title: this.$ts.gallery, + icon: 'fas fa-icons', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/gallery'); }, + }], + } : null), + user: null, + error: null, + parallaxAnimationId: null, + narrow: null, + }; + }, + + computed: { + style(): any { + if (this.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ this.user.bannerUrl })` + }; + }, + + age(): number { + return age(this.user.birthday); + } + }, + + watch: { + acct: 'fetch' + }, + + created() { + this.fetch(); + }, + + mounted() { + window.requestAnimationFrame(this.parallaxLoop); + this.narrow = true//this.$el.clientWidth < 1000; + }, + + beforeUnmount() { + window.cancelAnimationFrame(this.parallaxAnimationId); + }, + + methods: { + getAcct, + + fetch() { + if (this.acct == null) return; + this.user = null; + Progress.start(); + os.api('users/show', Acct.parse(this.acct)).then(user => { + this.user = user; + }).catch(e => { + this.error = e; + }).finally(() => { + Progress.done(); + }); + }, + + menu(ev) { + os.popupMenu(getUserMenu(this.user), ev.currentTarget || ev.target); + }, + + parallaxLoop() { + this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop); + this.parallax(); + }, + + parallax() { + const banner = this.$refs.banner as any; + if (banner == null) return; + + const top = getScrollPosition(this.$el); + + if (top < 0) return; + + const z = 1.75; // ๅฅฅ่กใ(ๅฐใใใปใฉๅฅฅ) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; + }, + + pinnedNoteUpdated(oldValue, newValue) { + const i = this.user.pinnedNotes.findIndex(n => n === oldValue); + this.user.pinnedNotes[i] = newValue; + }, + + number, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.ftskorzw.wide { + + > .banner-container { + position: relative; + height: 300px; + overflow: hidden; + background-size: cover; + background-position: center; + + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } + } + + > .contents { + display: flex; + padding: 16px; + + > .side { + width: 360px; + + > .avatar { + display: block; + width: 180px; + height: 180px; + margin: -130px auto 0 auto; + } + + > .name { + padding: 16px 0px 20px 0; + text-align: center; + + > .name { + display: block; + font-size: 1.75em; + font-weight: bold; + } + } + + > .followed { + text-align: center; + + > span { + display: inline-block; + font-size: 80%; + padding: 8px 12px; + margin-bottom: 20px; + border: solid 0.5px var(--divider); + border-radius: 999px; + } + } + + > .status { + display: flex; + padding: 20px 16px; + border-top: solid 0.5px var(--divider); + font-size: 90%; + + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 75%; + } + } + } + + > .description { + padding: 20px 16px; + border-top: solid 0.5px var(--divider); + font-size: 90%; + } + + > .fields { + padding: 20px 16px; + border-top: solid 0.5px var(--divider); + font-size: 90%; + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; + } + } + } + } + + > .main { + flex: 1; + margin-left: var(--margin); + min-width: 0; + + > .nav { + display: flex; + align-items: center; + margin-top: var(--margin); + //font-size: 120%; + font-weight: bold; + + > .link { + display: inline-block; + padding: 15px 24px 12px 24px; + text-align: center; + border-bottom: solid 3px transparent; + + &:hover { + text-decoration: none; + } + + &.active { + color: var(--accent); + border-bottom-color: var(--accent); + } + + &:not(.active):hover { + color: var(--fgHighlighted); + } + + > .icon { + margin-right: 6px; + } + } + + > .actions { + display: flex; + align-items: center; + margin-left: auto; + + > .menu { + padding: 12px 16px; + } + } + } + } + } +} + +.ftskorzw.narrow { + box-sizing: border-box; + overflow: clip; + background: var(--bg); + + > .punished { + font-size: 0.8em; + padding: 16px; + } + + > .profile { + + > .main { + position: relative; + overflow: hidden; + + > .banner-container { + position: relative; + height: 250px; + overflow: hidden; + background-size: cover; + background-position: center; + + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 78px; + background: linear-gradient(transparent, rgba(#000, 0.7)); + } + + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; + } + + > .actions { + position: absolute; + top: 12px; + right: 12px; + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 24px; + + > .menu { + vertical-align: bottom; + height: 31px; + width: 31px; + color: #fff; + text-shadow: 0 0 8px #000; + font-size: 16px; + } + + > .koudoku { + margin-left: 4px; + vertical-align: bottom; + } + } + + > .title { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0 0 8px 154px; + box-sizing: border-box; + color: #fff; + + > .name { + display: block; + margin: 0; + line-height: 32px; + font-weight: bold; + font-size: 1.8em; + text-shadow: 0 0 8px #000; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 16px; + line-height: 20px; + opacity: 0.8; + + &.username { + font-weight: bold; + } + } + } + } + } + + > .title { + display: none; + text-align: center; + padding: 50px 8px 16px 8px; + font-weight: bold; + border-bottom: solid 0.5px var(--divider); + + > .bottom { + > * { + display: inline-block; + margin-right: 8px; + opacity: 0.8; + } + } + } + + > .avatar { + display: block; + position: absolute; + top: 170px; + left: 16px; + z-index: 2; + width: 120px; + height: 120px; + box-shadow: 1px 1px 3px rgba(#000, 0.2); + } + + > .description { + padding: 24px 24px 24px 154px; + font-size: 0.95em; + + > .empty { + margin: 0; + opacity: 0.5; + } + } + + > .fields { + padding: 24px; + font-size: 0.9em; + border-top: solid 0.5px var(--divider); + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + text-align: center; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; + } + } + + &.system > .field > .name { + } + } + + > .status { + display: flex; + padding: 24px; + border-top: solid 0.5px var(--divider); + + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 70%; + } + } + } + } + } + + > .contents { + > .content { + margin-bottom: var(--margin); + } + } + + &.max-width_500px { + > .profile > .main { + > .banner-container { + height: 140px; + + > .fade { + display: none; + } + + > .title { + display: none; + } + } + + > .title { + display: block; + } + + > .avatar { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + + > .description { + padding: 16px; + text-align: center; + } + + > .fields { + padding: 16px; + } + + > .status { + padding: 16px; + } + } + + > .contents { + > .nav { + font-size: 80%; + } + } + } +} +</style> diff --git a/packages/client/src/pages/user/pages.vue b/packages/client/src/pages/user/pages.vue new file mode 100644 index 0000000000..0bf925d7d5 --- /dev/null +++ b/packages/client/src/pages/user/pages.vue @@ -0,0 +1,49 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <MkPagePreview v-for="page in items" :page="page" :key="page.id" class="_gap"/> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagePreview from '@/components/page-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkPagePreview, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/pages', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/user/reactions.vue b/packages/client/src/pages/user/reactions.vue new file mode 100644 index 0000000000..3ca3b2aac8 --- /dev/null +++ b/packages/client/src/pages/user/reactions.vue @@ -0,0 +1,81 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap afdcfbfb"> + <div class="header"> + <MkAvatar class="avatar" :user="user"/> + <MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/> + <MkTime :time="item.createdAt" class="createdAt"/> + </div> + <MkNote :note="item.note" @update:note="updated(note, $event)" :key="item.id"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkNote from '@/components/note.vue'; +import MkReactionIcon from '@/components/reaction-icon.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkNote, + MkReactionIcon, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/reactions', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + }, +}); +</script> + +<style lang="scss" scoped> +.afdcfbfb { + > .header { + display: flex; + align-items: center; + padding: 8px 16px; + margin-bottom: 8px; + border-bottom: solid 2px var(--divider); + + > .avatar { + width: 24px; + height: 24px; + margin-right: 8px; + } + + > .reaction { + width: 32px; + height: 32px; + } + + > .createdAt { + margin-left: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/v.vue b/packages/client/src/pages/v.vue new file mode 100644 index 0000000000..3b1bb20861 --- /dev/null +++ b/packages/client/src/pages/v.vue @@ -0,0 +1,29 @@ +<template> +<div> + <section class="_section"> + <div class="_content" style="text-align: center;"> + <img src="/static-assets/icons/512.png" alt="" style="display: block; width: 100px; margin: 0 auto; border-radius: 16px;"/> + <div style="margin-top: 0.75em;">Misskey</div> + <div style="opacity: 0.5;">v{{ version }}</div> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { version } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Misskey', + icon: null + }, + version, + } + }, +}); +</script> diff --git a/packages/client/src/pages/welcome.entrance.a.vue b/packages/client/src/pages/welcome.entrance.a.vue new file mode 100644 index 0000000000..2e0c520bc6 --- /dev/null +++ b/packages/client/src/pages/welcome.entrance.a.vue @@ -0,0 +1,320 @@ +<template> +<div class="rsqzvsbo" v-if="meta"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <XTimeline class="tl"/> + <div class="shape1"></div> + <div class="shape2"></div> + <img src="/client-assets/misskey.svg" class="misskey"/> + <div class="emojis"> + <MkEmoji :normal="true" :no-style="true" emoji="๐"/> + <MkEmoji :normal="true" :no-style="true" emoji="โค"/> + <MkEmoji :normal="true" :no-style="true" emoji="๐"/> + <MkEmoji :normal="true" :no-style="true" emoji="๐"/> + <MkEmoji :normal="true" :no-style="true" emoji="๐ฎ"/> + </div> + <div class="main _panel"> + <div class="bg"> + <div class="fade"></div> + </div> + <div class="fg"> + <h1> + <!-- ่ๆฏ่ฒใซใใฃใฆใฏใญใดใ่ฆใใชใใชใใฎใงใจใใใใ็กๅนใซ --> + <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> --> + <span class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton @click="signup()" inline gradate data-cy-signup style="margin-right: 12px;">{{ $ts.signup }}</MkButton> + <MkButton @click="signin()" inline data-cy-signin>{{ $ts.login }}</MkButton> + </div> + <div class="status" v-if="onlineUsersCount && stats"> + <div> + <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> + </I18n> + <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> + </I18n> + </div> + <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> + </div> + <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XNote from '@/components/note.vue'; +import MkFeaturedPhotos from '@/components/featured-photos.vue'; +import XTimeline from './welcome.timeline.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkButton, + XNote, + MkFeaturedPhotos, + XTimeline, + }, + + data() { + return { + host: toUnicode(host), + instanceName, + meta: null, + stats: null, + tags: [], + onlineUsersCount: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats').then(stats => { + this.stats = stats; + }); + + os.api('get-online-users-count').then(res => { + this.onlineUsersCount = res.count; + }); + + os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8 + }).then(tags => { + this.tags = tags; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + }, + + showMenu(ev) { + os.popupMenu([{ + text: this.$t('aboutX', { x: instanceName }), + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about'); + } + }, { + text: this.$ts.aboutMisskey, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + } + }, null, { + text: this.$ts.help, + icon: 'fas fa-question-circle', + action: () => { + window.open(`https://misskey-hub.net/help.md`, '_blank'); + } + }], ev.currentTarget || ev.target); + }, + + number + } +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .top { + display: flex; + text-align: center; + min-height: 100vh; + box-sizing: border-box; + padding: 16px; + + > .bg { + position: absolute; + top: 0; + right: 0; + width: 80%; // 100%ใใshapeใฎๅน
ใๅผใใฆใใ + height: 100%; + } + + > .tl { + position: absolute; + top: 0; + bottom: 0; + right: 64px; + margin: auto; + width: 500px; + height: calc(100% - 128px); + overflow: hidden; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + + @media (max-width: 1200px) { + display: none; + } + } + + > .shape1 { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--accent); + clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%); + } + > .shape2 { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--accent); + clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%); + opacity: 0.5; + } + + > .misskey { + position: absolute; + top: 42px; + left: 42px; + width: 160px; + + @media (max-width: 450px) { + width: 130px; + } + } + + > .emojis { + position: absolute; + bottom: 32px; + left: 35px; + + > * { + margin-right: 8px; + } + + @media (max-width: 1200px) { + display: none; + } + } + + > .main { + position: relative; + width: min(480px, 100%); + margin: auto auto auto 128px; + box-shadow: 0 12px 32px rgb(0 0 0 / 25%); + + @media (max-width: 1200px) { + margin: auto; + } + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 128px; + background-position: center; + background-size: cover; + opacity: 0.75; + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 128px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } + } + + > .fg { + position: relative; + z-index: 1; + + > h1 { + display: block; + margin: 0; + padding: 32px 32px 24px 32px; + font-size: 1.5em; + + > .logo { + vertical-align: bottom; + max-height: 120px; + max-width: min(100%, 300px); + } + } + + > .about { + padding: 0 32px; + } + + > .action { + padding: 32px; + + > * { + line-height: 28px; + } + } + + > .status { + border-top: solid 0.5px var(--divider); + padding: 32px; + font-size: 90%; + + > div { + > span:not(:last-child) { + padding-right: 1em; + margin-right: 1em; + border-right: solid 0.5px var(--divider); + } + } + + > .online { + ::v-deep(b) { + color: #41b781; + } + + ::v-deep(span) { + opacity: 0.7; + } + } + } + + > .menu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.entrance.b.vue b/packages/client/src/pages/welcome.entrance.b.vue new file mode 100644 index 0000000000..efb8b09360 --- /dev/null +++ b/packages/client/src/pages/welcome.entrance.b.vue @@ -0,0 +1,236 @@ +<template> +<div class="rsqzvsbo" v-if="meta"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <XTimeline class="tl"/> + <div class="shape"></div> + <div class="main"> + <h1> + <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton class="signup" @click="signup()" inline gradate>{{ $ts.signup }}</MkButton> + <MkButton class="signin" @click="signin()" inline>{{ $ts.login }}</MkButton> + </div> + <div class="status" v-if="onlineUsersCount && stats"> + <div> + <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> + </I18n> + <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> + </I18n> + </div> + <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> + </div> + </div> + <img src="/client-assets/misskey.svg" class="misskey"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XNote from '@/components/note.vue'; +import MkFeaturedPhotos from '@/components/featured-photos.vue'; +import XTimeline from './welcome.timeline.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkButton, + XNote, + XTimeline, + MkFeaturedPhotos, + }, + + data() { + return { + host: toUnicode(host), + instanceName, + meta: null, + stats: null, + tags: [], + onlineUsersCount: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats').then(stats => { + this.stats = stats; + }); + + os.api('get-online-users-count').then(res => { + this.onlineUsersCount = res.count; + }); + + os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8 + }).then(tags => { + this.tags = tags; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + }, + + showMenu(ev) { + os.popupMenu([{ + text: this.$t('aboutX', { x: instanceName }), + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about'); + } + }, { + text: this.$ts.aboutMisskey, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + } + }, null, { + text: this.$ts.help, + icon: 'fas fa-question-circle', + action: () => { + window.open(`https://misskey-hub.net/help.md`, '_blank'); + } + }], ev.currentTarget || ev.target); + }, + + number + } +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .top { + min-height: 100vh; + box-sizing: border-box; + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + > .tl { + position: absolute; + top: 0; + bottom: 0; + right: 64px; + margin: auto; + width: 500px; + height: calc(100% - 128px); + overflow: hidden; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + } + + > .shape { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--accent); + clip-path: polygon(0% 0%, 40% 0%, 22% 100%, 0% 100%); + } + + > .misskey { + position: absolute; + bottom: 64px; + left: 64px; + width: 160px; + } + + > .main { + position: relative; + width: min(450px, 100%); + padding: 64px; + color: #fff; + font-size: 1.1em; + + @media (max-width: 1200px) { + margin: auto; + } + + > h1 { + display: block; + margin: 0 0 32px 0; + padding: 0; + + > .logo { + vertical-align: bottom; + max-height: 100px; + } + } + + > .about { + padding: 0; + } + + > .action { + margin: 32px 0; + + > * { + line-height: 32px; + } + + > .signup { + background: var(--panel); + color: var(--fg); + } + + > .signin { + background: var(--accent); + color: inherit; + } + } + + > .status { + margin: 32px 0; + border-top: solid 1px rgba(255, 255, 255, 0.5); + font-size: 90%; + + > div { + padding: 16px 0; + + > span:not(:last-child) { + padding-right: 1em; + margin-right: 1em; + border-right: solid 1px rgba(255, 255, 255, 0.5); + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.entrance.c.vue b/packages/client/src/pages/welcome.entrance.c.vue new file mode 100644 index 0000000000..2b0ff7a31c --- /dev/null +++ b/packages/client/src/pages/welcome.entrance.c.vue @@ -0,0 +1,305 @@ +<template> +<div class="rsqzvsbo" v-if="meta"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <div class="fade"></div> + <div class="emojis"> + <MkEmoji :normal="true" :no-style="true" emoji="๐"/> + <MkEmoji :normal="true" :no-style="true" emoji="โค"/> + <MkEmoji :normal="true" :no-style="true" emoji="๐"/> + <MkEmoji :normal="true" :no-style="true" emoji="๐"/> + <MkEmoji :normal="true" :no-style="true" emoji="๐ฎ"/> + </div> + <div class="main"> + <img src="/client-assets/misskey.svg" class="misskey"/> + <div class="form _panel"> + <div class="bg"> + <div class="fade"></div> + </div> + <div class="fg"> + <h1> + <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton @click="signup()" inline gradate>{{ $ts.signup }}</MkButton> + <MkButton @click="signin()" inline>{{ $ts.login }}</MkButton> + </div> + <div class="status" v-if="onlineUsersCount && stats"> + <div> + <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> + </I18n> + <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> + </I18n> + </div> + <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> + </div> + <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> + </div> + </div> + <nav class="nav"> + <MkA to="/announcements">{{ $ts.announcements }}</MkA> + <MkA to="/explore">{{ $ts.explore }}</MkA> + <MkA to="/channels">{{ $ts.channel }}</MkA> + <MkA to="/featured">{{ $ts.featured }}</MkA> + </nav> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XNote from '@/components/note.vue'; +import MkFeaturedPhotos from '@/components/featured-photos.vue'; +import XTimeline from './welcome.timeline.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkButton, + XNote, + MkFeaturedPhotos, + XTimeline, + }, + + data() { + return { + host: toUnicode(host), + instanceName, + meta: null, + stats: null, + tags: [], + onlineUsersCount: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats').then(stats => { + this.stats = stats; + }); + + os.api('get-online-users-count').then(res => { + this.onlineUsersCount = res.count; + }); + + os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8 + }).then(tags => { + this.tags = tags; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + }, + + showMenu(ev) { + os.popupMenu([{ + text: this.$t('aboutX', { x: instanceName }), + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about'); + } + }, { + text: this.$ts.aboutMisskey, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + } + }, null, { + text: this.$ts.help, + icon: 'fas fa-question-circle', + action: () => { + window.open(`https://misskey-hub.net/help.md`, '_blank'); + } + }], ev.currentTarget || ev.target); + }, + + number + } +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .top { + display: flex; + text-align: center; + min-height: 100vh; + box-sizing: border-box; + padding: 16px; + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + > .fade { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.25); + } + + > .emojis { + position: absolute; + bottom: 32px; + left: 35px; + + > * { + margin-right: 8px; + } + + @media (max-width: 1200px) { + display: none; + } + } + + > .main { + position: relative; + width: min(460px, 100%); + margin: auto; + + > .misskey { + width: 150px; + margin-bottom: 16px; + + @media (max-width: 450px) { + width: 130px; + } + } + + > .form { + position: relative; + box-shadow: 0 12px 32px rgb(0 0 0 / 25%); + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 128px; + background-position: center; + background-size: cover; + opacity: 0.75; + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 128px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } + } + + > .fg { + position: relative; + z-index: 1; + + > h1 { + display: block; + margin: 0; + padding: 32px 32px 24px 32px; + + > .logo { + vertical-align: bottom; + max-height: 120px; + } + } + + > .about { + padding: 0 32px; + } + + > .action { + padding: 32px; + + > * { + line-height: 28px; + } + } + + > .status { + border-top: solid 0.5px var(--divider); + padding: 32px; + font-size: 90%; + + > div { + > span:not(:last-child) { + padding-right: 1em; + margin-right: 1em; + border-right: solid 0.5px var(--divider); + } + } + + > .online { + ::v-deep(b) { + color: #41b781; + } + + ::v-deep(span) { + opacity: 0.7; + } + } + } + + > .menu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + } + } + } + + > .nav { + position: relative; + z-index: 2; + margin-top: 20px; + color: #fff; + text-shadow: 0 0 8px black; + font-size: 0.9em; + + > *:not(:last-child) { + margin-right: 1.5em; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.setup.vue b/packages/client/src/pages/welcome.setup.vue new file mode 100644 index 0000000000..8c88720cf3 --- /dev/null +++ b/packages/client/src/pages/welcome.setup.vue @@ -0,0 +1,102 @@ +<template> +<form class="mk-setup" @submit.prevent="submit()"> + <h1>Welcome to Misskey!</h1> + <div> + <p>{{ $ts.intro }}</p> + <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required data-cy-admin-username> + <template #label>{{ $ts.username }}</template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkInput v-model="password" type="password" data-cy-admin-password> + <template #label>{{ $ts.password }}</template> + <template #prefix><i class="fas fa-lock"></i></template> + </MkInput> + <footer> + <MkButton primary type="submit" :disabled="submitting" data-cy-admin-ok> + {{ submitting ? $ts.processing : $ts.done }}<MkEllipsis v-if="submitting"/> + </MkButton> + </footer> + </div> +</form> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import { host } from '@/config'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + }, + + data() { + return { + username: '', + password: '', + submitting: false, + host, + } + }, + + methods: { + submit() { + if (this.submitting) return; + this.submitting = true; + + os.api('admin/accounts/create', { + username: this.username, + password: this.password, + }).then(res => { + return login(res.token); + }).catch(() => { + this.submitting = false; + + os.dialog({ + type: 'error', + text: this.$ts.somethingHappened + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-setup { + border-radius: var(--radius); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: hidden; + max-width: 500px; + margin: 32px auto; + + > h1 { + margin: 0; + font-size: 1.5em; + text-align: center; + padding: 32px; + background: var(--accent); + color: #fff; + } + + > div { + padding: 32px; + background: var(--panel); + + > p { + margin-top: 0; + } + + > footer { + > * { + margin: 0 auto; + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.timeline.vue b/packages/client/src/pages/welcome.timeline.vue new file mode 100644 index 0000000000..46e3dbb5ed --- /dev/null +++ b/packages/client/src/pages/welcome.timeline.vue @@ -0,0 +1,99 @@ +<template> +<div class="civpbkhh"> + <div class="scrollbox" ref="scroll" v-bind:class="{ scroll: isScrolling }"> + <div v-for="note in notes" class="note"> + <div class="content _panel"> + <div class="body"> + <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> + <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + <div v-if="note.files.length > 0" class="richcontent"> + <XMediaList :media-list="note.files"/> + </div> + <div v-if="note.poll"> + <XPoll :note="note" :readOnly="true" /> + </div> + </div> + <XReactionsViewer :note="note" ref="reactionsViewer"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XReactionsViewer from '@/components/reactions-viewer.vue'; +import XMediaList from '@/components/media-list.vue'; +import XPoll from '@/components/poll.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XReactionsViewer, + XMediaList, + XPoll + }, + + data() { + return { + notes: [], + isScrolling: false, + } + }, + + created() { + os.api('notes/featured').then(notes => { + this.notes = notes; + }); + }, + + updated() { + if (this.$refs.scroll.clientHeight > window.innerHeight) { + this.isScrolling = true; + } + } +}); +</script> + +<style lang="scss" scoped> +@keyframes scroll { + 0% { + transform: translate3d(0, 0, 0); + } + 5% { + transform: translate3d(0, 0, 0); + } + 75% { + transform: translate3d(0, calc(-100% + 90vh), 0); + } + 90% { + transform: translate3d(0, calc(-100% + 90vh), 0); + } +} + +.civpbkhh { + text-align: right; + + > .scrollbox { + &.scroll { + animation: scroll 45s linear infinite; + } + + > .note { + margin: 16px 0 16px auto; + + > .content { + padding: 16px; + margin: 0 0 0 auto; + max-width: max-content; + border-radius: 16px; + + > .richcontent { + min-width: 250px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.vue b/packages/client/src/pages/welcome.vue new file mode 100644 index 0000000000..4c038b5113 --- /dev/null +++ b/packages/client/src/pages/welcome.vue @@ -0,0 +1,38 @@ +<template> +<div v-if="meta"> + <XSetup v-if="meta.requireSetup"/> + <XEntrance v-else/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XSetup from './welcome.setup.vue'; +import XEntrance from './welcome.entrance.a.vue'; +import { instanceName } from '@/config'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XSetup, + XEntrance, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: instanceName, + icon: null + }, + meta: null + } + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + } +}); +</script> |