diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
| commit | 9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch) | |
| tree | ce5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/pages | |
| parent | wip: retention for dashboard (diff) | |
| download | misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2 misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip | |
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/pages')
164 files changed, 22163 insertions, 0 deletions
diff --git a/packages/frontend/src/pages/_empty_.vue b/packages/frontend/src/pages/_empty_.vue new file mode 100644 index 0000000000..000b6decc9 --- /dev/null +++ b/packages/frontend/src/pages/_empty_.vue @@ -0,0 +1,7 @@ +<template> +<div></div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +</script> diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue new file mode 100644 index 0000000000..232d525347 --- /dev/null +++ b/packages/frontend/src/pages/_error_.vue @@ -0,0 +1,89 @@ +<template> +<MkLoading v-if="!loaded"/> +<transition :name="$store.state.animation ? 'zoom' : ''" appear> + <div v-show="loaded" class="mjndxjch"> + <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></p> + <p v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</p> + <p v-else-if="serverIsDead">{{ i18n.ts.serverIsDead }}</p> + <template v-else> + <p>{{ i18n.ts.newVersionOfClientAvailable }}</p> + <p>{{ i18n.ts.youShouldUpgradeClient }}</p> + <MkButton class="button primary" @click="reload">{{ i18n.ts.reload }}</MkButton> + </template> + <p><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.ts.troubleshooting }}</MkA></p> + <p v-if="error" class="error">ERROR: {{ error }}</p> + </div> +</transition> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { version } from '@/config'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = withDefaults(defineProps<{ + error?: Error; +}>(), { +}); + +let loaded = $ref(false); +let serverIsDead = $ref(false); +let meta = $ref<misskey.entities.LiteInstanceMetadata | null>(null); + +os.api('meta', { + detail: false, +}).then(res => { + loaded = true; + serverIsDead = false; + meta = res; + localStorage.setItem('v', res.version); +}, () => { + loaded = true; + serverIsDead = true; +}); + +function reload() { + unisonReload(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.error, + icon: 'ti ti-alert-triangle', +}); +</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/frontend/src/pages/_loading_.vue b/packages/frontend/src/pages/_loading_.vue new file mode 100644 index 0000000000..1dd2e46e10 --- /dev/null +++ b/packages/frontend/src/pages/_loading_.vue @@ -0,0 +1,6 @@ +<template> +<MkLoading/> +</template> + +<script lang="ts" setup> +</script> diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue new file mode 100644 index 0000000000..3ec972bcda --- /dev/null +++ b/packages/frontend/src/pages/about-misskey.vue @@ -0,0 +1,264 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div style="overflow: clip;"> + <MkSpacer :content-max="600" :margin-min="20"> + <div class="_formRoot znqjceqz"> + <div id="debug"></div> + <div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }"> + <img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/> + <div class="misskey">Misskey</div> + <div class="version">v{{ version }}</div> + <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :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> + <div class="_formBlock" style="text-align: center;"> + {{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a> + </div> + <div class="_formBlock" style="text-align: center;"> + <MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly โค]"/> #Misskey</MkButton> + </div> + <FormSection> + <div class="_formLinks"> + <FormLink to="https://github.com/misskey-dev/misskey" external> + <template #icon><i class="ti ti-code"></i></template> + {{ i18n.ts._aboutMisskey.source }} + <template #suffix>GitHub</template> + </FormLink> + <FormLink to="https://crowdin.com/project/misskey" external> + <template #icon><i class="ti ti-language-hiragana"></i></template> + {{ i18n.ts._aboutMisskey.translation }} + <template #suffix>Crowdin</template> + </FormLink> + <FormLink to="https://www.patreon.com/syuilo" external> + <template #icon><i class="ti ti-pig-money"></i></template> + {{ i18n.ts._aboutMisskey.donate }} + <template #suffix>Patreon</template> + </FormLink> + </div> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._aboutMisskey.contributors }}</template> + <div class="_formLinks"> + <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> + </div> + <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template> + </FormSection> + <FormSection> + <template #label><Mfm text="$[jelly โค]"/> {{ i18n.ts._aboutMisskey.patrons }}</template> + <div v-for="patron in patrons" :key="patron">{{ patron }}</div> + <template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template> + </FormSection> + </div> + </MkSpacer> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { nextTick, onBeforeUnmount } from 'vue'; +import { version } from '@/config'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkLink from '@/components/MkLink.vue'; +import { physics } from '@/scripts/physics'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const patrons = [ + 'ใพใฃใกใใจใผใซใ
', + 'mametsuko', + 'noellabo', + 'AureoleArk', + 'Gargron', + 'Nokotaro Takeda', + 'Suji Yan', + 'oi_yekssim', + 'regtan', + 'Hekovic', + 'nenohi', + 'Gitmo Life Services', + 'naga_rus', + 'Efertone', + 'Melilot', + 'motcha', + 'nanami kan', + 'sevvie Rose', + 'Hayato Ishikawa', + 'Puniko', + 'skehmatics', + 'Quinton Macejkovic', + 'YUKIMOCHI', + 'dansup', + 'mewl hayabusa', + 'Emilis', + 'Fristi', + 'makokunsan', + 'chidori ninokura', + 'Peter G.', + '่ฆๅฝใใชใฟ', + 'natalie', + 'Maronu', + 'Steffen K9', + 'takimura', + 'sikyosyounin', + 'Nesakko', + 'YuzuRyo61', + 'blackskye', + 'sheeta.s', + 'osapon', + 'public_yusuke', + 'CG', + 'ๅดๆตฅ', + 't_w', + 'Jerry', + 'nafuchoco', + 'Takumi Sugita', + 'GLaTAN', + 'mkatze', + 'kabo2468y', + 'mydarkstar', + 'Roujo', + 'DignifiedSilence', + 'uroco @99', + 'totokoro', + 'ใใ', + 'kiritan', + 'weepjp', + 'Liaizon Wakest', + 'Duponin', + 'Blue', + 'Naoki Hirayama', + 'wara', + 'Wataru Manji (manji0)', + 'ใฟใชใใพ', + 'kanoy', + 'xianon', + 'Denshi', + 'Osushimaru', + 'ใซใใใธใ', + 'ใใฎใ ใ', + 'Leni', + 'oss', + 'Weeble', + '่ๆฎใใใ', + 'ThatOneCalculator', + 'pixeldesu', +]; + +let easterEggReady = false; +let easterEggEmojis = $ref([]); +let easterEggEngine = $ref(null); +const containerEl = $ref<HTMLElement>(); + +function iconLoaded() { + const emojis = defaultStore.state.reactions; + const containerWidth = containerEl.offsetWidth; + for (let i = 0; i < 32; i++) { + easterEggEmojis.push({ + id: i.toString(), + top: -(128 + (Math.random() * 256)), + left: (Math.random() * containerWidth), + emoji: emojis[Math.floor(Math.random() * emojis.length)], + }); + } + + nextTick(() => { + easterEggReady = true; + }); +} + +function gravity() { + if (!easterEggReady) return; + easterEggReady = false; + easterEggEngine = physics(containerEl); +} + +function iLoveMisskey() { + os.post({ + initialText: 'I $[jelly โค] #Misskey', + instant: true, + }); +} + +onBeforeUnmount(() => { + if (easterEggEngine) { + easterEggEngine.stop(); + } +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.aboutMisskey, + icon: null, +}); +</script> + +<style lang="scss" scoped> +.znqjceqz { + > .about { + position: relative; + text-align: center; + padding: 16px; + border-radius: var(--radius); + + &.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/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue new file mode 100644 index 0000000000..53ce1e4b75 --- /dev/null +++ b/packages/frontend/src/pages/about.emojis.vue @@ -0,0 +1,134 @@ +<template> +<div class="driuhtrh"> + <div class="query"> + <MkInput v-model="q" class="" :placeholder="$ts.search"> + <template #prefix><i class="ti ti-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 v-if="searchEmojis" class="emojis"> + <template #header>{{ $ts.searchResult }}</template> + <div class="zuvgdzyt"> + <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/> + </div> + </MkFolder> + + <MkFolder v-for="category in customEmojiCategories" :key="category" class="emojis"> + <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 XEmoji from './emojis.emoji.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkTab from '@/components/MkTab.vue'; +import * as os from '@/os'; +import { emojiCategories, emojiTags } from '@/instance'; + +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(emoji => emoji.name.includes(this.q) || emoji.aliases.includes(this.q)); + } else { + this.searchEmojis = this.customEmojis.filter(emoji => (emoji.name.includes(this.q) || emoji.aliases.includes(this.q)) && [...this.selectedTags].every(t => emoji.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/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue new file mode 100644 index 0000000000..6c92ab1264 --- /dev/null +++ b/packages/frontend/src/pages/about.federation.vue @@ -0,0 +1,106 @@ +<template> +<div class="taeiyria"> + <div class="query"> + <MkInput v-model="host" :debounce="true" class=""> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + <FormSplit style="margin-top: var(--margin);"> + <MkSelect v-model="state"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="federating">{{ i18n.ts.federating }}</option> + <option value="subscribing">{{ i18n.ts.subscribing }}</option> + <option value="publishing">{{ i18n.ts.publishing }}</option> + <option value="suspended">{{ i18n.ts.suspended }}</option> + <option value="blocked">{{ i18n.ts.blocked }}</option> + <option value="notResponding">{{ i18n.ts.notResponding }}</option> + </MkSelect> + <MkSelect v-model="sort"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-lastCommunicatedAt">{{ i18n.ts.lastCommunication }} ({{ i18n.ts.ascendingOrder }})</option> + </MkSelect> + </FormSplit> + </div> + + <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> + <div class="dqokceoi"> + <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Last communicated: ${new Date(instance.lastCommunicatedAt).toLocaleString()}\nStatus: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`"> + <MkInstanceCardMini :instance="instance"/> + </MkA> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +let host = $ref(''); +let state = $ref('federating'); +let sort = $ref('+pubSub'); +const pagination = { + endpoint: 'federation/instances' as const, + limit: 10, + offsetMode: true, + params: computed(() => ({ + sort: sort, + host: host !== '' ? host : null, + ...( + state === 'federating' ? { federating: true } : + state === 'subscribing' ? { subscribing: true } : + state === 'publishing' ? { publishing: true } : + state === 'suspended' ? { suspended: true } : + state === 'blocked' ? { blocked: true } : + state === 'notResponding' ? { notResponding: true } : + {}), + })), +}; + +function getStatus(instance) { + if (instance.isSuspended) return 'Suspended'; + if (instance.isBlocked) return 'Blocked'; + if (instance.isNotResponding) return 'Error'; + return 'Alive'; +} +</script> + +<style lang="scss" scoped> +.taeiyria { + > .query { + background: var(--bg); + margin-bottom: 16px; + } +} + +.dqokceoi { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; + + > .instance:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue new file mode 100644 index 0000000000..0ed692c5c5 --- /dev/null +++ b/packages/frontend/src/pages/about.vue @@ -0,0 +1,166 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20"> + <div class="_formRoot"> + <div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div class="content"> + <img :src="$instance.iconUrl ?? $instance.faviconUrl ?? '/favicon.ico'" alt="" class="icon"/> + <div class="name"> + <b>{{ $instance.name ?? host }}</b> + </div> + </div> + </div> + + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.description }}</template> + <template #value><div v-html="$instance.description"></div></template> + </MkKeyValue> + + <FormSection> + <MkKeyValue class="_formBlock" :copy="version"> + <template #key>Misskey</template> + <template #value>{{ version }}</template> + </MkKeyValue> + <div class="_formBlock" v-html="i18n.t('poweredByMisskeyDescription', { name: $instance.name ?? host })"> + </div> + <FormLink to="/about-misskey">{{ i18n.ts.aboutMisskey }}</FormLink> + </FormSection> + + <FormSection> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value>{{ $instance.maintainerName }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.contact }}</template> + <template #value>{{ $instance.maintainerEmail }}</template> + </MkKeyValue> + </FormSplit> + <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ i18n.ts.tos }}</FormLink> + </FormSection> + + <FormSuspense :p="initStats"> + <FormSection> + <template #label>{{ i18n.ts.statistics }}</template> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.users }}</template> + <template #value>{{ number(stats.originalUsersCount) }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.notes }}</template> + <template #value>{{ number(stats.originalNotesCount) }}</template> + </MkKeyValue> + </FormSplit> + </FormSection> + </FormSuspense> + + <FormSection> + <template #label>Well-known resources</template> + <div class="_formLinks"> + <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> + </div> + </FormSection> + </div> + </MkSpacer> + <MkSpacer v-else-if="tab === 'emojis'" :content-max="1000" :margin-min="20"> + <XEmojis/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20"> + <XFederation/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20"> + <MkInstanceStats :chart-limit="500" :detailed="true"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import XEmojis from './about.emojis.vue'; +import XFederation from './about.federation.vue'; +import { version, instanceName, host } from '@/config'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSplit from '@/components/form/split.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkInstanceStats from '@/components/MkInstanceStats.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = withDefaults(defineProps<{ + initialTab?: string; +}>(), { + initialTab: 'overview', +}); + +let stats = $ref(null); +let tab = $ref(props.initialTab); + +const initStats = () => os.api('stats', { +}).then((res) => { + stats = res; +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, +}, { + key: 'emojis', + title: i18n.ts.customEmojis, + icon: 'ti ti-mood-happy', +}, { + key: 'federation', + title: i18n.ts.federation, + icon: 'ti ti-whirl', +}, { + key: 'charts', + title: i18n.ts.charts, + icon: 'ti ti-chart-line', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.instanceInfo, + icon: 'ti ti-info-circle', +}))); +</script> + +<style lang="scss" scoped> +.fwhjspax { + text-align: center; + border-radius: 10px; + overflow: clip; + background-size: cover; + background-position: center center; + + > .content { + overflow: hidden; + + > .icon { + display: block; + margin: 16px auto 0 auto; + height: 64px; + border-radius: 8px; + } + + > .name { + display: block; + padding: 16px; + color: #fff; + text-shadow: 0 0 8px #000; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue new file mode 100644 index 0000000000..a11249e75d --- /dev/null +++ b/packages/frontend/src/pages/admin-file.vue @@ -0,0 +1,160 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32"> + <div v-if="tab === 'overview'" class="cxqhhsmd _formRoot"> + <a class="_formBlock thumbnail" :href="file.url" target="_blank"> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + </a> + <div class="_formBlock"> + <MkKeyValue :copy="file.type" oneline style="margin: 1em 0;"> + <template #key>MIME Type</template> + <template #value><span class="_monospace">{{ file.type }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Size</template> + <template #value><span class="_monospace">{{ bytes(file.size) }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="file.id" oneline style="margin: 1em 0;"> + <template #key>ID</template> + <template #value><span class="_monospace">{{ file.id }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="file.md5" oneline style="margin: 1em 0;"> + <template #key>MD5</template> + <template #value><span class="_monospace">{{ file.md5 }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="file.createdAt" mode="detail" style="display: block;"/></span></template> + </MkKeyValue> + </div> + <MkA v-if="file.user" class="user" :to="`/user-info/${file.user.id}`"> + <MkUserCardMini :user="file.user"/> + </MkA> + <div class="_formBlock"> + <MkSwitch v-model="isSensitive" @update:model-value="toggleIsSensitive">NSFW</MkSwitch> + </div> + + <div class="_formBlock"> + <MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </div> + <div v-else-if="tab === 'ip' && info" class="_formRoot"> + <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> + <MkKeyValue v-if="info.requestIp" class="_formBlock _monospace" :copy="info.requestIp" oneline> + <template #key>IP</template> + <template #value>{{ info.requestIp }}</template> + </MkKeyValue> + <FormSection v-if="info.requestHeaders"> + <template #label>Headers</template> + <MkKeyValue v-for="(v, k) in info.requestHeaders" :key="k" class="_formBlock _monospace"> + <template #key>{{ k }}</template> + <template #value>{{ v }}</template> + </MkKeyValue> + </FormSection> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView v-if="info" tall :value="info"> + </MkObjectView> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkObjectView from '@/components/MkObjectView.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormSection from '@/components/form/section.vue'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { acct } from '@/filters/user'; +import { iAmAdmin, iAmModerator } from '@/account'; + +let tab = $ref('overview'); +let file: any = $ref(null); +let info: any = $ref(null); +let isSensitive: boolean = $ref(false); + +const props = defineProps<{ + fileId: string, +}>(); + +async function fetch() { + file = await os.api('drive/files/show', { fileId: props.fileId }); + info = await os.api('admin/drive/show-file', { fileId: props.fileId }); + isSensitive = file.isSensitive; +} + +fetch(); + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: file.name }), + }); + if (canceled) return; + + os.apiWithDialog('drive/files/delete', { + fileId: file.id, + }); +} + +async function toggleIsSensitive(v) { + await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v }); + isSensitive = v; +} + +const headerActions = $computed(() => [{ + text: i18n.ts.openInNewTab, + icon: 'ti ti-external-link', + handler: () => { + window.open(file.url, '_blank'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'ti ti-info-circle', +}, iAmModerator ? { + key: 'ip', + title: 'IP', + icon: 'ti ti-password', +} : null, { + key: 'raw', + title: 'Raw data', + icon: 'ti ti-code', +}]); + +definePageMetadata(computed(() => ({ + title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file, + icon: 'ti ti-file', +}))); +</script> + +<style lang="scss" scoped> +.cxqhhsmd { + > .thumbnail { + display: block; + + > .thumbnail { + height: 300px; + max-width: 100%; + } + } + + > .user { + &:hover { + text-decoration: none; + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue new file mode 100644 index 0000000000..bdb41b2d2c --- /dev/null +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -0,0 +1,292 @@ +<template> +<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick"> + <template v-if="metadata"> + <div class="titleContainer" @click="showTabsPopup"> + <i v-if="metadata.icon" class="icon" :class="metadata.icon"></i> + + <div class="title"> + <div class="title">{{ metadata.title }}</div> + </div> + </div> + <div class="tabs"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> + <i v-if="tab.icon" class="icon" :class="tab.icon"></i> + <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> + </button> + <div ref="tabHighlightEl" class="highlight"></div> + </div> + </template> + <div class="buttons right"> + <template v-if="actions"> + <template v-for="action in actions"> + <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> + <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + </template> + </template> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; +import tinycolor from 'tinycolor2'; +import { popupMenu } from '@/os'; +import { url } from '@/config'; +import { scrollToTop } from '@/scripts/scroll'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; +import { globalEvents } from '@/events'; +import { injectPageMetadata } from '@/scripts/page-metadata'; + +type Tab = { + key?: string | null; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +}; + +const props = defineProps<{ + tabs?: Tab[]; + tab?: string; + actions?: { + text: string; + icon: string; + asFullButton?: boolean; + handler: (ev: MouseEvent) => void; + }[]; + thin?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); +}>(); + +const metadata = injectPageMetadata(); + +const el = ref<HTMLElement>(null); +const tabRefs = {}; +const tabHighlightEl = $ref<HTMLElement | null>(null); +const bg = ref(null); +const height = ref(0); +const hasTabs = computed(() => { + return props.tabs && props.tabs.length > 0; +}); + +const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs.value) return; + ev.preventDefault(); + ev.stopPropagation(); + const menu = props.tabs.map(tab => ({ + text: tab.title, + icon: tab.icon, + active: tab.key != null && tab.key === props.tab, + action: (ev) => { + onTabClick(tab, ev); + }, + })); + popupMenu(menu, ev.currentTarget ?? ev.target); +}; + +const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); +}; + +const onClick = () => { + scrollToTop(el.value, { behavior: 'smooth' }); +}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ใฆใผใถใใชใใฃใฎ่ฆณ็นใใmousedownๆใซใฏonClickใฏๅผใฐใชใ + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(tab: Tab, ev: MouseEvent): void { + if (tab.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + tab.onClick(ev); + } + if (tab.key) { + emit('update:tab', tab.key); + } +} + +const calcBg = () => { + const rawBg = metadata?.bg || 'var(--bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); +}; + +onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + + watch(() => [props.tab, props.tabs], () => { + nextTick(() => { + const tabEl = tabRefs[props.tab]; + if (tabEl && tabHighlightEl) { + // offsetWidth ใ offsetLeft ใฏๅฐๆฐใไธธใใฆใใพใใใ getBoundingClientRect ใไฝฟใๅฟ
่ฆใใใ + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; + } + }); + }, { + immediate: true, + }); +}); + +onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); +}); +</script> + +<style lang="scss" scoped> +.fdidabkc { + --height: 60px; + display: flex; + width: 100%; + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + + > .buttons { + --margin: 8px; + display: flex; + align-items: center; + height: var(--height); + margin: 0 var(--margin); + + &.right { + margin-left: auto; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + max-width: 400px; + overflow: auto; + white-space: nowrap; + text-align: left; + font-weight: bold; + flex-shrink: 0; + margin-left: 24px; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + + > .icon { + margin-right: 8px; + width: 16px; + text-align: center; + } + + > .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; + + > .subtitle { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.activeTab { + text-align: center; + + > .chevron { + display: inline-block; + margin-left: 6px; + } + } + } + } + } + + > .tabs { + position: relative; + margin-left: 16px; + font-size: 0.8em; + overflow: auto; + white-space: nowrap; + + > .tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + > .icon + .title { + margin-left: 8px; + } + } + + > .highlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--accent); + border-radius: 999px; + transition: all 0.2s ease; + pointer-events: none; + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue new file mode 100644 index 0000000000..973ec871ab --- /dev/null +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -0,0 +1,97 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <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>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="unresolved">{{ i18n.ts.unresolved }}</option> + <option value="resolved">{{ i18n.ts.resolved }}</option> + </MkSelect> + <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.reporteeOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.reporterOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.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"> + <span>{{ i18n.ts.username }}</span> + </MkInput> + <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'"> + <span>{{ i18n.ts.host }}</span> + </MkInput> + </div> + --> + + <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);"> + <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> + </MkPagination> + </div> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; + +import XHeader from './_header_.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import XAbuseReport from '@/components/MkAbuseReport.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let reports = $ref<InstanceType<typeof MkPagination>>(); + +let state = $ref('unresolved'); +let reporterOrigin = $ref('combined'); +let targetUserOrigin = $ref('combined'); +let searchUsername = $ref(''); +let searchHost = $ref(''); + +const pagination = { + endpoint: 'admin/abuse-user-reports' as const, + limit: 10, + params: computed(() => ({ + state, + reporterOrigin, + targetUserOrigin, + })), +}; + +function resolved(reportId) { + reports.removeItem(item => item.id === reportId); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.abuseReports, + icon: 'ti ti-exclamation-circle', +}); +</script> + +<style lang="scss" scoped> +.lcixvhis { + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue new file mode 100644 index 0000000000..2ec926c65c --- /dev/null +++ b/packages/frontend/src/pages/admin/ads.vue @@ -0,0 +1,132 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="uqshojas"> + <div v-for="ad in ads" class="_panel _formRoot ad"> + <MkAd v-if="ad.url" :specify="ad"/> + <MkInput v-model="ad.url" type="url" class="_formBlock"> + <template #label>URL</template> + </MkInput> + <MkInput v-model="ad.imageUrl" class="_formBlock"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <FormRadios v-model="ad.place" class="_formBlock"> + <template #label>Form</template> + <option value="square">square</option> + <option value="horizontal">horizontal</option> + <option value="horizontal-big">horizontal-big</option> + </FormRadios> + <!-- + <div style="margin: 32px 0;"> + {{ i18n.ts.priority }} + <MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio> + <MkRadio v-model="ad.priority" value="middle">{{ i18n.ts.middle }}</MkRadio> + <MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio> + </div> + --> + <FormSplit> + <MkInput v-model="ad.ratio" type="number"> + <template #label>{{ i18n.ts.ratio }}</template> + </MkInput> + <MkInput v-model="ad.expiresAt" type="date"> + <template #label>{{ i18n.ts.expiration }}</template> + </MkInput> + </FormSplit> + <MkTextarea v-model="ad.memo" class="_formBlock"> + <template #label>{{ i18n.ts.memo }}</template> + </MkTextarea> + <div class="buttons _formBlock"> + <MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let ads: any[] = $ref([]); + +os.api('admin/ad/list').then(adsResponse => { + ads = adsResponse; +}); + +function add() { + ads.unshift({ + id: null, + memo: '', + place: 'square', + priority: 'middle', + ratio: 1, + url: '', + imageUrl: null, + expiresAt: null, + }); +} + +function remove(ad) { + os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: ad.url }), + }).then(({ canceled }) => { + if (canceled) return; + ads = ads.filter(x => x !== ad); + os.apiWithDialog('admin/ad/delete', { + id: ad.id, + }); + }); +} + +function 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(), + }); + } +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.add, + handler: add, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.ads, + icon: 'ti ti-ad', +}); +</script> + +<style lang="scss" scoped> +.uqshojas { + > .ad { + padding: 32px; + + &:not(:last-child) { + margin-bottom: var(--margin); + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue new file mode 100644 index 0000000000..607ad8aa02 --- /dev/null +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -0,0 +1,112 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="ztgjmzrw"> + <section v-for="announcement in announcements" class="_card _gap announcements"> + <div class="_content announcement"> + <MkInput v-model="announcement.title"> + <template #label>{{ i18n.ts.title }}</template> + </MkInput> + <MkTextarea v-model="announcement.text"> + <template #label>{{ i18n.ts.text }}</template> + </MkTextarea> + <MkInput v-model="announcement.imageUrl"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton class="button" inline @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </div> + </section> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let announcements: any[] = $ref([]); + +os.api('admin/announcements/list').then(announcementResponse => { + announcements = announcementResponse; +}); + +function add() { + announcements.unshift({ + id: null, + title: '', + text: '', + imageUrl: null, + }); +} + +function remove(announcement) { + os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: announcement.title }), + }).then(({ canceled }) => { + if (canceled) return; + announcements = announcements.filter(x => x !== announcement); + os.api('admin/announcements/delete', announcement); + }); +} + +function save(announcement) { + if (announcement.id == null) { + os.api('admin/announcements/create', announcement).then(() => { + os.alert({ + type: 'success', + text: i18n.ts.saved, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); + } else { + os.api('admin/announcements/update', announcement).then(() => { + os.alert({ + type: 'success', + text: i18n.ts.saved, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); + } +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.add, + handler: add, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.announcements, + icon: 'ti ti-speakerphone', +}); +</script> + +<style lang="scss" scoped> +.ztgjmzrw { + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue new file mode 100644 index 0000000000..d03961cf95 --- /dev/null +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -0,0 +1,109 @@ +<template> +<div> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormRadios v-model="provider" class="_formBlock"> + <option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> + <option value="hcaptcha">hCaptcha</option> + <option value="recaptcha">reCAPTCHA</option> + <option value="turnstile">Turnstile</option> + </FormRadios> + + <template v-if="provider === 'hcaptcha'"> + <FormInput v-model="hcaptchaSiteKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.hcaptchaSiteKey }}</template> + </FormInput> + <FormInput v-model="hcaptchaSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.hcaptchaSecretKey }}</template> + </FormInput> + <FormSlot class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> + </FormSlot> + </template> + <template v-else-if="provider === 'recaptcha'"> + <FormInput v-model="recaptchaSiteKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.recaptchaSiteKey }}</template> + </FormInput> + <FormInput v-model="recaptchaSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.recaptchaSecretKey }}</template> + </FormInput> + <FormSlot v-if="recaptchaSiteKey" class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/> + </FormSlot> + </template> + <template v-else-if="provider === 'turnstile'"> + <FormInput v-model="turnstileSiteKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.turnstileSiteKey }}</template> + </FormInput> + <FormInput v-model="turnstileSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>{{ i18n.ts.turnstileSecretKey }}</template> + </FormInput> + <FormSlot class="_formBlock"> + <template #label>{{ i18n.ts.preview }}</template> + <MkCaptcha provider="turnstile" :sitekey="turnstileSiteKey || '1x00000000000000000000AA'"/> + </FormSlot> + </template> + + <FormButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormSuspense> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSlot from '@/components/form/slot.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue')); + +let provider = $ref(null); +let hcaptchaSiteKey: string | null = $ref(null); +let hcaptchaSecretKey: string | null = $ref(null); +let recaptchaSiteKey: string | null = $ref(null); +let recaptchaSecretKey: string | null = $ref(null); +let turnstileSiteKey: string | null = $ref(null); +let turnstileSecretKey: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + hcaptchaSiteKey = meta.hcaptchaSiteKey; + hcaptchaSecretKey = meta.hcaptchaSecretKey; + recaptchaSiteKey = meta.recaptchaSiteKey; + recaptchaSecretKey = meta.recaptchaSecretKey; + turnstileSiteKey = meta.turnstileSiteKey; + turnstileSecretKey = meta.turnstileSecretKey; + + provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableHcaptcha: provider === 'hcaptcha', + hcaptchaSiteKey, + hcaptchaSecretKey, + enableRecaptcha: provider === 'recaptcha', + recaptchaSiteKey, + recaptchaSecretKey, + enableTurnstile: provider === 'turnstile', + turnstileSiteKey, + turnstileSecretKey, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue new file mode 100644 index 0000000000..5a0d3d5e51 --- /dev/null +++ b/packages/frontend/src/pages/admin/database.vue @@ -0,0 +1,35 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory"> + <MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;"> + <template #key>{{ table[0] }}</template> + <template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template> + </MkKeyValue> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.database, + icon: 'ti ti-database', +}); +</script> diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue new file mode 100644 index 0000000000..6c9dee1704 --- /dev/null +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -0,0 +1,126 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableEmail" class="_formBlock"> + <template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template> + <template #caption>{{ i18n.ts.emailConfigInfo }}</template> + </FormSwitch> + + <template v-if="enableEmail"> + <FormInput v-model="email" type="email" class="_formBlock"> + <template #label>{{ i18n.ts.emailAddress }}</template> + </FormInput> + + <FormSection> + <template #label>{{ i18n.ts.smtpConfig }}</template> + <FormSplit :min-width="280"> + <FormInput v-model="smtpHost" class="_formBlock"> + <template #label>{{ i18n.ts.smtpHost }}</template> + </FormInput> + <FormInput v-model="smtpPort" type="number" class="_formBlock"> + <template #label>{{ i18n.ts.smtpPort }}</template> + </FormInput> + </FormSplit> + <FormSplit :min-width="280"> + <FormInput v-model="smtpUser" class="_formBlock"> + <template #label>{{ i18n.ts.smtpUser }}</template> + </FormInput> + <FormInput v-model="smtpPass" type="password" class="_formBlock"> + <template #label>{{ i18n.ts.smtpPass }}</template> + </FormInput> + </FormSplit> + <FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo> + <FormSwitch v-model="smtpSecure" class="_formBlock"> + <template #label>{{ i18n.ts.smtpSecure }}</template> + <template #caption>{{ i18n.ts.smtpSecureInfo }}</template> + </FormSwitch> + </FormSection> + </template> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { fetchInstance, instance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let enableEmail: boolean = $ref(false); +let email: any = $ref(null); +let smtpSecure: boolean = $ref(false); +let smtpHost: string = $ref(''); +let smtpPort: number = $ref(0); +let smtpUser: string = $ref(''); +let smtpPass: string = $ref(''); + +async function init() { + const meta = await os.api('admin/meta'); + enableEmail = meta.enableEmail; + email = meta.email; + smtpSecure = meta.smtpSecure; + smtpHost = meta.smtpHost; + smtpPort = meta.smtpPort; + smtpUser = meta.smtpUser; + smtpPass = meta.smtpPass; +} + +async function testEmail() { + const { canceled, result: destination } = await os.inputText({ + title: i18n.ts.destination, + type: 'email', + placeholder: instance.maintainerEmail, + }); + if (canceled) return; + os.apiWithDialog('admin/send-email', { + to: destination, + subject: 'Test email', + text: 'Yo', + }); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableEmail, + email, + smtpSecure, + smtpHost, + smtpPort, + smtpUser, + smtpPass, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + text: i18n.ts.testEmail, + handler: testEmail, +}, { + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.emailServer, + icon: 'ti ti-mail', +}); +</script> diff --git a/packages/frontend/src/pages/admin/emoji-edit-dialog.vue b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue new file mode 100644 index 0000000000..bd601cb1de --- /dev/null +++ b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue @@ -0,0 +1,106 @@ +<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 v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkInput v-model="category" class="_formBlock" :datalist="categories"> + <template #label>{{ i18n.ts.category }}</template> + </MkInput> + <MkInput v-model="aliases" class="_formBlock"> + <template #label>{{ i18n.ts.tags }}</template> + <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> + </MkInput> + <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XModalWindow from '@/components/MkModalWindow.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; +import { unique } from '@/scripts/array'; +import { i18n } from '@/i18n'; +import { emojiCategories } from '@/instance'; + +const props = defineProps<{ + emoji: any, +}>(); + +let dialog = $ref(null); +let name: string = $ref(props.emoji.name); +let category: string = $ref(props.emoji.category); +let aliases: string = $ref(props.emoji.aliases.join(' ')); +let categories: string[] = $ref(emojiCategories); + +const emit = defineEmits<{ + (ev: 'done', v: { deleted?: boolean, updated?: any }): void, + (ev: 'closed'): void +}>(); + +function ok() { + update(); +} + +async function update() { + await os.apiWithDialog('admin/emoji/update', { + id: props.emoji.id, + name, + category, + aliases: aliases.split(' '), + }); + + emit('done', { + updated: { + id: props.emoji.id, + name, + category, + aliases: aliases.split(' '), + }, + }); + + dialog.close(); +} + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: name }), + }); + if (canceled) return; + + os.api('admin/emoji/delete', { + id: props.emoji.id, + }).then(() => { + emit('done', { + deleted: true, + }); + dialog.close(); + }); +} +</script> + +<style lang="scss" scoped> +.yigymqpb { + > .img { + display: block; + height: 64px; + margin: 0 auto; + } +} +</style> diff --git a/packages/frontend/src/pages/admin/emojis.vue b/packages/frontend/src/pages/admin/emojis.vue new file mode 100644 index 0000000000..14c8466d73 --- /dev/null +++ b/packages/frontend/src/pages/admin/emojis.vue @@ -0,0 +1,398 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="ogwlenmc"> + <div v-if="tab === 'local'" class="local"> + <MkInput v-model="query" :debounce="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> + </MkInput> + <MkSwitch v-model="selectMode" style="margin: 8px 0;"> + <template #label>Select mode</template> + </MkSwitch> + <div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkButton inline @click="selectAll">Select all</MkButton> + <MkButton inline @click="setCategoryBulk">Set category</MkButton> + <MkButton inline @click="addTagBulk">Add tag</MkButton> + <MkButton inline @click="removeTagBulk">Remove tag</MkButton> + <MkButton inline @click="setTagBulk">Set tag</MkButton> + <MkButton inline danger @click="delBulk">Delete</MkButton> + </div> + <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : 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 v-else-if="tab === 'remote'" class="remote"> + <FormSplit> + <MkInput v-model="queryRemote" :debounce="true" type="search"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.search }}</template> + </MkInput> + <MkInput v-model="host" :debounce="true"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </FormSplit> + <MkPagination :pagination="remotePagination"> + <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @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> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormSplit from '@/components/form/split.vue'; +import { selectFile, selectFiles } from '@/scripts/select-file'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>(); + +const tab = ref('local'); +const query = ref(null); +const queryRemote = ref(null); +const host = ref(null); +const selectMode = ref(false); +const selectedEmojis = ref<string[]>([]); + +const pagination = { + endpoint: 'admin/emoji/list' as const, + limit: 30, + params: computed(() => ({ + query: (query.value && query.value !== '') ? query.value : null, + })), +}; + +const remotePagination = { + endpoint: 'admin/emoji/list-remote' as const, + limit: 30, + params: computed(() => ({ + query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null, + host: (host.value && host.value !== '') ? host.value : null, + })), +}; + +const selectAll = () => { + if (selectedEmojis.value.length > 0) { + selectedEmojis.value = []; + } else { + selectedEmojis.value = emojisPaginationComponent.value.items.map(item => item.id); + } +}; + +const toggleSelect = (emoji) => { + if (selectedEmojis.value.includes(emoji.id)) { + selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id); + } else { + selectedEmojis.value.push(emoji.id); + } +}; + +const add = async (ev: MouseEvent) => { + const files = await selectFiles(ev.currentTarget ?? ev.target, null); + + const promise = Promise.all(files.map(file => os.api('admin/emoji/add', { + fileId: file.id, + }))); + promise.then(() => { + emojisPaginationComponent.value.reload(); + }); + os.promiseDialog(promise); +}; + +const edit = (emoji) => { + os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { + emoji: emoji, + }, { + done: result => { + if (result.updated) { + emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ + ...oldEmoji, + ...result.updated, + })); + } else if (result.deleted) { + emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id); + } + }, + }, 'closed'); +}; + +const im = (emoji) => { + os.apiWithDialog('admin/emoji/copy', { + emojiId: emoji.id, + }); +}; + +const remoteMenu = (emoji, ev: MouseEvent) => { + os.popupMenu([{ + type: 'label', + text: ':' + emoji.name + ':', + }, { + text: i18n.ts.import, + icon: 'ti ti-plus', + action: () => { im(emoji); }, + }], ev.currentTarget ?? ev.target); +}; + +const menu = (ev: MouseEvent) => { + os.popupMenu([{ + icon: 'ti ti-download', + text: i18n.ts.export, + action: async () => { + os.api('export-custom-emojis', { + }) + .then(() => { + os.alert({ + type: 'info', + text: i18n.ts.exportRequested, + }); + }).catch((err) => { + os.alert({ + type: 'error', + text: err.message, + }); + }); + }, + }, { + icon: 'ti ti-upload', + text: i18n.ts.import, + action: async () => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('admin/emoji/import-zip', { + fileId: file.id, + }) + .then(() => { + os.alert({ + type: 'info', + text: i18n.ts.importRequested, + }); + }).catch((err) => { + os.alert({ + type: 'error', + text: err.message, + }); + }); + }, + }], ev.currentTarget ?? ev.target); +}; + +const setCategoryBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Category', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-category-bulk', { + ids: selectedEmojis.value, + category: result, + }); + emojisPaginationComponent.value.reload(); +}; + +const addTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/add-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const removeTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/remove-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const setTagBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'Tag', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-aliases-bulk', { + ids: selectedEmojis.value, + aliases: result.split(' '), + }); + emojisPaginationComponent.value.reload(); +}; + +const delBulk = async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/delete-bulk', { + ids: selectedEmojis.value, + }); + emojisPaginationComponent.value.reload(); +}; + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.addEmoji, + handler: add, +}, { + icon: 'ti ti-dots', + handler: menu, +}]); + +const headerTabs = $computed(() => [{ + key: 'local', + title: i18n.ts.local, +}, { + key: 'remote', + title: i18n.ts.remote, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.customEmojis, + icon: 'ti ti-mood-happy', +}))); +</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) 0; + + > .emoji { + display: flex; + align-items: center; + padding: 11px; + text-align: left; + border: solid 1px var(--panel); + + &:hover { + border-color: var(--inputBorderHover); + } + + &.selected { + border-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) 0; + + > .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/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue new file mode 100644 index 0000000000..8ad6bd4fc0 --- /dev/null +++ b/packages/frontend/src/pages/admin/files.vue @@ -0,0 +1,120 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions"/></template> + <MkSpacer :content-max="900"> + <div class="xrmjdkdw"> + <div> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkSelect v-model="origin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </div> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;"> + <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>User ID</template> + </MkInput> + <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>MIME type</template> + </MkInput> + </div> + <MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let origin = $ref('local'); +let type = $ref(null); +let searchHost = $ref(''); +let userId = $ref(''); +let viewMode = $ref('grid'); +const pagination = { + endpoint: 'admin/drive/files' as const, + limit: 10, + params: computed(() => ({ + type: (type && type !== '') ? type : null, + userId: (userId && userId !== '') ? userId : null, + origin: origin, + hostname: (searchHost && searchHost !== '') ? searchHost : null, + })), +}; + +function clear() { + os.confirm({ + type: 'warning', + text: i18n.ts.clearCachedFilesConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/drive/clean-remote-files', {}); + }); +} + +function show(file) { + os.pageWindow(`/admin/file/${file.id}`); +} + +async function find() { + const { canceled, result: q } = await os.inputText({ + title: i18n.ts.fileIdOrUrl, + allowEmpty: false, + }); + if (canceled) return; + + os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { + show(file); + }).catch(err => { + if (err.code === 'NO_SUCH_FILE') { + os.alert({ + type: 'error', + text: i18n.ts.notFound, + }); + } + }); +} + +const headerActions = $computed(() => [{ + text: i18n.ts.lookup, + icon: 'ti ti-search', + handler: find, +}, { + text: i18n.ts.clearCachedFiles, + icon: 'ti ti-trash', + handler: clear, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.files, + icon: 'ti ti-cloud', +}))); +</script> + +<style lang="scss" scoped> +.xrmjdkdw { + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue new file mode 100644 index 0000000000..6c07a87eeb --- /dev/null +++ b/packages/frontend/src/pages/admin/index.vue @@ -0,0 +1,316 @@ +<template> +<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }"> + <div v-if="!narrow || currentPage?.route.name == null" class="nav"> + <MkSpacer :content-max="700" :margin-min="16"> + <div class="lxpfedzu"> + <div class="banner"> + <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> + </div> + + <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo> + <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + + <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> + </div> + </MkSpacer> + </div> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> + <RouterView/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkSuperMenu from '@/components/MkSuperMenu.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { scroll } from '@/scripts/scroll'; +import { instance } from '@/instance'; +import * as os from '@/os'; +import { lookupUser } from '@/scripts/lookup-user'; +import { useRouter } from '@/router'; +import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; + +const isEmpty = (x: string | null) => x == null || x === ''; + +const router = useRouter(); + +const indexInfo = { + title: i18n.ts.controlPanel, + icon: 'ti ti-settings', + hideHeader: true, +}; + +provide('shouldOmitHeaderTitle', false); + +let INFO = $ref(indexInfo); +let childInfo = $ref(null); +let narrow = $ref(false); +let view = $ref(null); +let el = $ref(null); +let pageProps = $ref({}); +let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); +let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile; +let noEmailServer = !instance.enableEmail; +let thereIsUnresolvedAbuseReport = $ref(false); +let currentPage = $computed(() => router.currentRef.value.child); + +os.api('admin/abuse-user-reports', { + state: 'unresolved', + limit: 1, +}).then(reports => { + if (reports.length > 0) thereIsUnresolvedAbuseReport = true; +}); + +const NARROW_THRESHOLD = 600; +const ro = new ResizeObserver((entries, observer) => { + if (entries.length === 0) return; + narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; +}); + +const menuDef = $computed(() => [{ + title: i18n.ts.quickAction, + items: [{ + type: 'button', + icon: 'ti ti-search', + text: i18n.ts.lookup, + action: lookup, + }, ...(instance.disableRegistration ? [{ + type: 'button', + icon: 'ti ti-user', + text: i18n.ts.invite, + action: invite, + }] : [])], +}, { + title: i18n.ts.administration, + items: [{ + icon: 'ti ti-dashboard', + text: i18n.ts.dashboard, + to: '/admin/overview', + active: currentPage?.route.name === 'overview', + }, { + icon: 'ti ti-users', + text: i18n.ts.users, + to: '/admin/users', + active: currentPage?.route.name === 'users', + }, { + icon: 'ti ti-mood-happy', + text: i18n.ts.customEmojis, + to: '/admin/emojis', + active: currentPage?.route.name === 'emojis', + }, { + icon: 'ti ti-whirl', + text: i18n.ts.federation, + to: '/about#federation', + active: currentPage?.route.name === 'federation', + }, { + icon: 'ti ti-clock-play', + text: i18n.ts.jobQueue, + to: '/admin/queue', + active: currentPage?.route.name === 'queue', + }, { + icon: 'ti ti-cloud', + text: i18n.ts.files, + to: '/admin/files', + active: currentPage?.route.name === 'files', + }, { + icon: 'ti ti-speakerphone', + text: i18n.ts.announcements, + to: '/admin/announcements', + active: currentPage?.route.name === 'announcements', + }, { + icon: 'ti ti-ad', + text: i18n.ts.ads, + to: '/admin/ads', + active: currentPage?.route.name === 'ads', + }, { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.abuseReports, + to: '/admin/abuses', + active: currentPage?.route.name === 'abuses', + }], +}, { + title: i18n.ts.settings, + items: [{ + icon: 'ti ti-settings', + text: i18n.ts.general, + to: '/admin/settings', + active: currentPage?.route.name === 'settings', + }, { + icon: 'ti ti-mail', + text: i18n.ts.emailServer, + to: '/admin/email-settings', + active: currentPage?.route.name === 'email-settings', + }, { + icon: 'ti ti-cloud', + text: i18n.ts.objectStorage, + to: '/admin/object-storage', + active: currentPage?.route.name === 'object-storage', + }, { + icon: 'ti ti-lock', + text: i18n.ts.security, + to: '/admin/security', + active: currentPage?.route.name === 'security', + }, { + icon: 'ti ti-planet', + text: i18n.ts.relays, + to: '/admin/relays', + active: currentPage?.route.name === 'relays', + }, { + icon: 'ti ti-share', + text: i18n.ts.integration, + to: '/admin/integrations', + active: currentPage?.route.name === 'integrations', + }, { + icon: 'ti ti-ban', + text: i18n.ts.instanceBlocking, + to: '/admin/instance-block', + active: currentPage?.route.name === 'instance-block', + }, { + icon: 'ti ti-ghost', + text: i18n.ts.proxyAccount, + to: '/admin/proxy-account', + active: currentPage?.route.name === 'proxy-account', + }, { + icon: 'ti ti-adjustments', + text: i18n.ts.other, + to: '/admin/other-settings', + active: currentPage?.route.name === 'other-settings', + }], +}, { + title: i18n.ts.info, + items: [{ + icon: 'ti ti-database', + text: i18n.ts.database, + to: '/admin/database', + active: currentPage?.route.name === 'database', + }], +}]); + +watch(narrow, () => { + if (currentPage?.route.name == null && !narrow) { + router.push('/admin/overview'); + } +}); + +onMounted(() => { + ro.observe(el); + + narrow = el.offsetWidth < NARROW_THRESHOLD; + if (currentPage?.route.name == null && !narrow) { + router.push('/admin/overview'); + } +}); + +onUnmounted(() => { + ro.disconnect(); +}); + +provideMetadataReceiver((info) => { + if (info == null) { + childInfo = null; + } else { + childInfo = info; + } +}); + +const invite = () => { + os.api('admin/invite').then(x => { + os.alert({ + type: 'info', + text: x.code, + }); + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); +}; + +const lookup = (ev) => { + os.popupMenu([{ + text: i18n.ts.user, + icon: 'ti ti-user', + action: () => { + lookupUser(); + }, + }, { + text: i18n.ts.note, + icon: 'ti ti-pencil', + action: () => { + alert('TODO'); + }, + }, { + text: i18n.ts.file, + icon: 'ti ti-cloud', + action: () => { + alert('TODO'); + }, + }, { + text: i18n.ts.instance, + icon: 'ti ti-planet', + action: () => { + alert('TODO'); + }, + }], ev.currentTarget ?? ev.target); +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(INFO); + +defineExpose({ + header: { + title: i18n.ts.controlPanel, + }, +}); +</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/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue new file mode 100644 index 0000000000..1bdd174de4 --- /dev/null +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -0,0 +1,51 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormTextarea v-model="blockedHosts" class="_formBlock"> + <span>{{ i18n.ts.blockedInstances }}</span> + <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> + </FormTextarea> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let blockedHosts: string = $ref(''); + +async function init() { + const meta = await os.api('admin/meta'); + blockedHosts = meta.blockedHosts.join('\n'); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + blockedHosts: blockedHosts.split('\n') || [], + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.instanceBlocking, + icon: 'ti ti-ban', +}); +</script> diff --git a/packages/frontend/src/pages/admin/integrations.discord.vue b/packages/frontend/src/pages/admin/integrations.discord.vue new file mode 100644 index 0000000000..0a69c44c93 --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.discord.vue @@ -0,0 +1,60 @@ +<template> +<FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableDiscordIntegration" class="_formBlock"> + <template #label>{{ i18n.ts.enable }}</template> + </FormSwitch> + + <template v-if="enableDiscordIntegration"> + <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/dc/cb` }}</FormInfo> + + <FormInput v-model="discordClientId" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client ID</template> + </FormInput> + + <FormInput v-model="discordClientSecret" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client Secret</template> + </FormInput> + </template> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> +</FormSuspense> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +let uri: string = $ref(''); +let enableDiscordIntegration: boolean = $ref(false); +let discordClientId: string | null = $ref(null); +let discordClientSecret: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + uri = meta.uri; + enableDiscordIntegration = meta.enableDiscordIntegration; + discordClientId = meta.discordClientId; + discordClientSecret = meta.discordClientSecret; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableDiscordIntegration, + discordClientId, + discordClientSecret, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/integrations.github.vue b/packages/frontend/src/pages/admin/integrations.github.vue new file mode 100644 index 0000000000..66419d5891 --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.github.vue @@ -0,0 +1,60 @@ +<template> +<FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableGithubIntegration" class="_formBlock"> + <template #label>{{ i18n.ts.enable }}</template> + </FormSwitch> + + <template v-if="enableGithubIntegration"> + <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/gh/cb` }}</FormInfo> + + <FormInput v-model="githubClientId" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client ID</template> + </FormInput> + + <FormInput v-model="githubClientSecret" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Client Secret</template> + </FormInput> + </template> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> +</FormSuspense> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +let uri: string = $ref(''); +let enableGithubIntegration: boolean = $ref(false); +let githubClientId: string | null = $ref(null); +let githubClientSecret: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + uri = meta.uri; + enableGithubIntegration = meta.enableGithubIntegration; + githubClientId = meta.githubClientId; + githubClientSecret = meta.githubClientSecret; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableGithubIntegration, + githubClientId, + githubClientSecret, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/integrations.twitter.vue b/packages/frontend/src/pages/admin/integrations.twitter.vue new file mode 100644 index 0000000000..1e8d882b9c --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.twitter.vue @@ -0,0 +1,60 @@ +<template> +<FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableTwitterIntegration" class="_formBlock"> + <template #label>{{ i18n.ts.enable }}</template> + </FormSwitch> + + <template v-if="enableTwitterIntegration"> + <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/tw/cb` }}</FormInfo> + + <FormInput v-model="twitterConsumerKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Consumer Key</template> + </FormInput> + + <FormInput v-model="twitterConsumerSecret" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Consumer Secret</template> + </FormInput> + </template> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> +</FormSuspense> +</template> + +<script lang="ts" setup> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; + +let uri: string = $ref(''); +let enableTwitterIntegration: boolean = $ref(false); +let twitterConsumerKey: string | null = $ref(null); +let twitterConsumerSecret: string | null = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + uri = meta.uri; + enableTwitterIntegration = meta.enableTwitterIntegration; + twitterConsumerKey = meta.twitterConsumerKey; + twitterConsumerSecret = meta.twitterConsumerSecret; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + enableTwitterIntegration, + twitterConsumerKey, + twitterConsumerSecret, + }).then(() => { + fetchInstance(); + }); +} +</script> diff --git a/packages/frontend/src/pages/admin/integrations.vue b/packages/frontend/src/pages/admin/integrations.vue new file mode 100644 index 0000000000..9cc35baefd --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.vue @@ -0,0 +1,57 @@ +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-brand-twitter"></i></template> + <template #label>Twitter</template> + <template #suffix>{{ enableTwitterIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <XTwitter/> + </FormFolder> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-brand-github"></i></template> + <template #label>GitHub</template> + <template #suffix>{{ enableGithubIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <XGithub/> + </FormFolder> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-brand-discord"></i></template> + <template #label>Discord</template> + <template #suffix>{{ enableDiscordIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template> + <XDiscord/> + </FormFolder> + </FormSuspense> +</MkSpacer></MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XTwitter from './integrations.twitter.vue'; +import XGithub from './integrations.github.vue'; +import XDiscord from './integrations.discord.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormFolder from '@/components/form/folder.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let enableTwitterIntegration: boolean = $ref(false); +let enableGithubIntegration: boolean = $ref(false); +let enableDiscordIntegration: boolean = $ref(false); + +async function init() { + const meta = await os.api('admin/meta'); + enableTwitterIntegration = meta.enableTwitterIntegration; + enableGithubIntegration = meta.enableGithubIntegration; + enableDiscordIntegration = meta.enableDiscordIntegration; +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.integration, + icon: 'ti ti-share', +}); +</script> diff --git a/packages/frontend/src/pages/admin/metrics.vue b/packages/frontend/src/pages/admin/metrics.vue new file mode 100644 index 0000000000..db8e448639 --- /dev/null +++ b/packages/frontend/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/MkButton.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkwFederation from '../../widgets/federation.vue'; +import { version, url } from '@/config'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; + +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'; +import { stream } from '@/stream'; + +export default defineComponent({ + components: { + MkButton, + MkSelect, + MkInput, + MkContainer, + MkFolder, + MkwFederation, + }, + + data() { + return { + version, + url, + stats: null, + serverInfo: null, + connection: null, + queueConnection: markRaw(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(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/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue new file mode 100644 index 0000000000..f2ab30eaa5 --- /dev/null +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -0,0 +1,148 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch> + + <template v-if="useObjectStorage"> + <FormInput v-model="objectStorageBaseUrl" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template> + <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageBucket" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageBucket }}</template> + <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template> + </FormInput> + + <FormInput v-model="objectStoragePrefix" class="_formBlock"> + <template #label>{{ i18n.ts.objectStoragePrefix }}</template> + <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageEndpoint" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageEndpoint }}</template> + <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageRegion" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageRegion }}</template> + <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template> + </FormInput> + + <FormSplit :min-width="280"> + <FormInput v-model="objectStorageAccessKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Access key</template> + </FormInput> + + <FormInput v-model="objectStorageSecretKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Secret key</template> + </FormInput> + </FormSplit> + + <FormSwitch v-model="objectStorageUseSSL" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageUseSSL }}</template> + <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageUseProxy" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageUseProxy }}</template> + <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock"> + <template #label>s3ForcePathStyle</template> + </FormSwitch> + </template> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let useObjectStorage: boolean = $ref(false); +let objectStorageBaseUrl: string | null = $ref(null); +let objectStorageBucket: string | null = $ref(null); +let objectStoragePrefix: string | null = $ref(null); +let objectStorageEndpoint: string | null = $ref(null); +let objectStorageRegion: string | null = $ref(null); +let objectStoragePort: number | null = $ref(null); +let objectStorageAccessKey: string | null = $ref(null); +let objectStorageSecretKey: string | null = $ref(null); +let objectStorageUseSSL: boolean = $ref(false); +let objectStorageUseProxy: boolean = $ref(false); +let objectStorageSetPublicRead: boolean = $ref(false); +let objectStorageS3ForcePathStyle: boolean = $ref(true); + +async function init() { + const meta = await os.api('admin/meta'); + useObjectStorage = meta.useObjectStorage; + objectStorageBaseUrl = meta.objectStorageBaseUrl; + objectStorageBucket = meta.objectStorageBucket; + objectStoragePrefix = meta.objectStoragePrefix; + objectStorageEndpoint = meta.objectStorageEndpoint; + objectStorageRegion = meta.objectStorageRegion; + objectStoragePort = meta.objectStoragePort; + objectStorageAccessKey = meta.objectStorageAccessKey; + objectStorageSecretKey = meta.objectStorageSecretKey; + objectStorageUseSSL = meta.objectStorageUseSSL; + objectStorageUseProxy = meta.objectStorageUseProxy; + objectStorageSetPublicRead = meta.objectStorageSetPublicRead; + objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + useObjectStorage, + objectStorageBaseUrl, + objectStorageBucket, + objectStoragePrefix, + objectStorageEndpoint, + objectStorageRegion, + objectStoragePort, + objectStorageAccessKey, + objectStorageSecretKey, + objectStorageUseSSL, + objectStorageUseProxy, + objectStorageSetPublicRead, + objectStorageS3ForcePathStyle, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.objectStorage, + icon: 'ti ti-cloud', +}); +</script> diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue new file mode 100644 index 0000000000..62dff6ce7f --- /dev/null +++ b/packages/frontend/src/pages/admin/other-settings.vue @@ -0,0 +1,44 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + none + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +async function init() { + await os.api('admin/meta'); +} + +function save() { + os.apiWithDialog('admin/update-meta').then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.other, + icon: 'ti ti-adjustments', +}); +</script> diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue new file mode 100644 index 0000000000..c3ce5ac901 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -0,0 +1,217 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root" class="_panel"> + <canvas ref="chartEl"></canvas> + </div> +</div> +</template> + +<script lang="ts" setup> +import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import { enUS } from 'date-fns/locale'; +import tinycolor from 'tinycolor2'; +import * as os from '@/os'; +import 'chartjs-adapter-date-fns'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import gradient from 'chartjs-plugin-gradient'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + gradient, +); + +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})`; +}; + +const chartEl = $ref<HTMLCanvasElement>(null); +const now = new Date(); +let chartInstance: Chart = null; +const chartLimit = 7; +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +async function renderChart() { + if (chartInstance) { + chartInstance.destroy(); + } + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + return new Date(y, m, d - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); + }; + + const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + // ใใฉใณใใซใฉใผ + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + const colorRead = '#3498db'; + const colorWrite = '#2ecc71'; + + const max = Math.max(...raw.read); + + chartInstance = new Chart(chartEl, { + type: 'bar', + data: { + datasets: [{ + parsing: false, + label: 'Read', + data: format(raw.read).slice().reverse(), + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: colorRead, + barPercentage: 0.7, + categoryPercentage: 0.5, + fill: true, + }, { + parsing: false, + label: 'Write', + data: format(raw.write).slice().reverse(), + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: colorWrite, + barPercentage: 0.7, + categoryPercentage: 0.5, + fill: true, + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + offset: true, + time: { + stepSize: 1, + unit: 'day', + }, + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + maxRotation: 0, + autoSkipPadding: 8, + }, + adapters: { + date: { + locale: enUS, + }, + }, + }, + y: { + position: 'left', + suggestedMax: 10, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + //mirror: true, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + fetching = false; +} + +onMounted(async () => { + renderChart(); +}); +</script> + +<style lang="scss" module> +.root { + padding: 20px; +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue new file mode 100644 index 0000000000..024ffdc245 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -0,0 +1,346 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root"> + <div class="charts _panel"> + <div class="chart"> + <canvas ref="chartEl2"></canvas> + </div> + <div class="chart"> + <canvas ref="chartEl"></canvas> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import gradient from 'chartjs-plugin-gradient'; +import { enUS } from 'date-fns/locale'; +import tinycolor from 'tinycolor2'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import { i18n } from '@/i18n'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; +import { defaultStore } from '@/store'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + gradient, +); + +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})`; +}; + +const chartLimit = 50; +const chartEl = $ref<HTMLCanvasElement>(); +const chartEl2 = $ref<HTMLCanvasElement>(); +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); +const { handler: externalTooltipHandler2 } = useChartTooltip(); + +onMounted(async () => { + const now = new Date(); + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + return new Date(y, m, d - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); + }; + + const formatMinus = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: -v, + })); + }; + + const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + const succColor = '#87e000'; + const failColor = '#ff4400'; + + const succMax = Math.max(...raw.deliverSucceeded); + const failMax = Math.max(...raw.deliverFailed); + + // ใใฉใณใใซใฉใผ + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + new Chart(chartEl, { + type: 'line', + data: { + datasets: [{ + stack: 'a', + parsing: false, + label: 'Out: Succ', + data: format(raw.deliverSucceeded).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + borderColor: succColor, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: alpha(succColor, 0.35), + fill: true, + clip: 8, + }, { + stack: 'a', + parsing: false, + label: 'Out: Fail', + data: formatMinus(raw.deliverFailed).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + borderColor: failColor, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: alpha(failColor, 0.35), + fill: true, + clip: 8, + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + stacked: true, + offset: false, + time: { + stepSize: 1, + unit: 'day', + }, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + maxRotation: 0, + autoSkipPadding: 16, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(chartLimit).getTime(), + }, + y: { + stacked: true, + position: 'left', + suggestedMax: 10, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: true, + //mirror: true, + callback: (value, index, values) => value < 0 ? -value : value, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + elements: { + point: { + hoverRadius: 5, + hoverBorderWidth: 2, + }, + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + new Chart(chartEl2, { + type: 'bar', + data: { + datasets: [{ + parsing: false, + label: 'In', + data: format(raw.inboxReceived).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: '#0cc2d6', + barPercentage: 0.8, + categoryPercentage: 0.9, + fill: true, + clip: 8, + }], + }, + options: { + aspectRatio: 5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + offset: false, + time: { + stepSize: 1, + unit: 'day', + }, + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxRotation: 0, + autoSkipPadding: 16, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(chartLimit).getTime(), + }, + y: { + position: 'left', + suggestedMax: 10, + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + elements: { + point: { + hoverRadius: 5, + hoverBorderWidth: 2, + }, + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler2, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .charts { + > .chart { + padding: 16px; + + &:first-child { + border-bottom: solid 0.5px var(--divider); + } + } + } + } +} +</style> + diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue new file mode 100644 index 0000000000..71f5a054b4 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -0,0 +1,185 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root"> + <div v-if="topSubInstancesForPie && topPubInstancesForPie" class="pies"> + <div class="pie deliver _panel"> + <div class="title">Sub</div> + <XPie :data="topSubInstancesForPie" class="chart"/> + <div class="subTitle">Top 10</div> + </div> + <div class="pie inbox _panel"> + <div class="title">Pub</div> + <XPie :data="topPubInstancesForPie" class="chart"/> + <div class="subTitle">Top 10</div> + </div> + </div> + <div v-if="!fetching" class="items"> + <div class="item _panel sub"> + <div class="icon"><i class="ti ti-world-download"></i></div> + <div class="body"> + <div class="value"> + {{ number(federationSubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff> + </div> + <div class="label">Sub</div> + </div> + </div> + <div class="item _panel pub"> + <div class="icon"><i class="ti ti-world-upload"></i></div> + <div class="body"> + <div class="value"> + {{ number(federationPubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff> + </div> + <div class="label">Pub</div> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import XPie from './overview.pie.vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import { i18n } from '@/i18n'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +let topSubInstancesForPie: any = $ref(null); +let topPubInstancesForPie: any = $ref(null); +let federationPubActive = $ref<number | null>(null); +let federationPubActiveDiff = $ref<number | null>(null); +let federationSubActive = $ref<number | null>(null); +let federationSubActiveDiff = $ref<number | null>(null); +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +onMounted(async () => { + const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' }); + federationPubActive = chart.pubActive[0]; + federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1]; + federationSubActive = chart.subActive[0]; + federationSubActiveDiff = chart.subActive[0] - chart.subActive[1]; + + os.apiGet('federation/stats', { limit: 10 }).then(res => { + topSubInstancesForPie = res.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]); + topPubInstancesForPie = res.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]); + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + + &:global { + > .pies { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin-bottom: 12px; + + > .pie { + position: relative; + padding: 12px; + + > .title { + position: absolute; + top: 20px; + left: 20px; + font-size: 90%; + } + + > .chart { + max-height: 150px; + } + + > .subTitle { + position: absolute; + bottom: 20px; + right: 20px; + font-size: 85%; + } + } + } + + > .items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + + > .item { + display: flex; + box-sizing: border-box; + padding: 12px; + + > .icon { + display: grid; + place-items: center; + height: 100%; + aspect-ratio: 1; + margin-right: 12px; + background: var(--accentedBg); + color: var(--accent); + border-radius: 10px; + } + + &.sub { + > .icon { + background: #d5ba0026; + color: #dfc300; + } + } + + &.pub { + > .icon { + background: #00cf2326; + color: #00cd5b; + } + } + + > .body { + padding: 2px 0; + + > .value { + font-size: 1.25em; + font-weight: bold; + + > .diff { + font-size: 0.65em; + font-weight: normal; + } + } + + > .label { + font-size: 0.8em; + opacity: 0.5; + } + } + } + } + } +} +</style> + diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue new file mode 100644 index 0000000000..16d1c83b9f --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.heatmap.vue @@ -0,0 +1,15 @@ +<template> +<div class="_panel" :class="$style.root"> + <MkActiveUsersHeatmap/> +</div> +</template> + +<script lang="ts" setup> +import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue'; +</script> + +<style lang="scss" module> +.root { + padding: 20px; +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue new file mode 100644 index 0000000000..29848bf03b --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -0,0 +1,50 @@ +<template> +<div class="wbrkwale"> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else class="instances"> + <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" class="instance"> + <MkInstanceCardMini :instance="instance"/> + </MkA> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; + +const instances = ref([]); +const fetching = ref(true); + +const fetch = async () => { + const fetchedInstances = await os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 6, + }); + instances.value = fetchedInstances; + fetching.value = false; +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.wbrkwale { + > .instances { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + grid-gap: 12px; + + > .instance:hover { + text-decoration: none; + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue new file mode 100644 index 0000000000..a1f63c8711 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.moderators.vue @@ -0,0 +1,55 @@ +<template> +<div> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.root" class="_panel"> + <MkA v-for="user in moderators" :key="user.id" class="user" :to="`/user-info/${user.id}`"> + <MkAvatar :user="user" class="avatar" :show-indicator="true" :disable-link="true"/> + </MkA> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; + +let moderators: any = $ref(null); +let fetching = $ref(true); + +onMounted(async () => { + moderators = await os.api('admin/show-users', { + sort: '+lastActiveDate', + state: 'adminOrModerator', + limit: 30, + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(30px, 40px)); + grid-gap: 12px; + place-content: center; + padding: 12px; + + &:global { + > .user { + width: 100%; + height: 100%; + aspect-ratio: 1; + + > .avatar { + width: 100%; + height: 100%; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue new file mode 100644 index 0000000000..94509cf006 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -0,0 +1,110 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + DoughnutController, +} from 'chart.js'; +import number from '@/filters/number'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + data: { name: string; value: number; color: string; onClick?: () => void }[]; +}>(); + +const chartEl = ref<HTMLCanvasElement>(null); + +// ใใฉใณใใซใฉใผ +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip({ + position: 'middle', +}); + +let chartInstance: Chart; + +onMounted(() => { + chartInstance = new Chart(chartEl.value, { + type: 'doughnut', + data: { + labels: props.data.map(x => x.name), + datasets: [{ + backgroundColor: props.data.map(x => x.color), + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'), + borderWidth: 2, + hoverOffset: 0, + data: props.data.map(x => x.value), + }], + }, + options: { + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 16, + }, + }, + onClick: (ev) => { + const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (hit && props.data[hit.index].onClick) { + props.data[hit.index].onClick(); + } + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue new file mode 100644 index 0000000000..1e095bddaa --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -0,0 +1,186 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { watch, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + type: string; +}>(); + +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})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// ใใฉใณใใซใฉใผ +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData(values) { + if (chartInstance == null) return; + for (const value of values) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + } + chartInstance.update(); +} + +function pushData(value) { + if (chartInstance == null) return; + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + chartInstance.update(); +} + +const label = + props.type === 'process' ? 'Process' : + props.type === 'active' ? 'Active' : + props.type === 'delayed' ? 'Delayed' : + props.type === 'waiting' ? 'Waiting' : + '?' as never; + +const color = + props.type === 'process' ? '#00E396' : + props.type === 'active' ? '#00BCD4' : + props.type === 'delayed' ? '#E53935' : + props.type === 'waiting' ? '#FFB300' : + '?' as never; + +onMounted(() => { + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: label, + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: color, + backgroundColor: alpha(color, 0.2), + fill: true, + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + plugins: [chartVLine(vLineColor)], + }); +}); + +defineExpose({ + setData, + pushData, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue new file mode 100644 index 0000000000..72ebddc72f --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -0,0 +1,127 @@ +<template> +<div :class="$style.root"> + <div class="_table status"> + <div class="_row"> + <div class="_cell" style="text-align: center;"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell" style="text-align: center;"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell" style="text-align: center;"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell" style="text-align: center;"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class="charts"> + <div class="chart"> + <div class="title">Process</div> + <XChart ref="chartProcess" type="process"/> + </div> + <div class="chart"> + <div class="title">Active</div> + <XChart ref="chartActive" type="active"/> + </div> + <div class="chart"> + <div class="title">Delayed</div> + <XChart ref="chartDelayed" type="delayed"/> + </div> + <div class="chart"> + <div class="title">Waiting</div> + <XChart ref="chartWaiting" type="waiting"/> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { markRaw, onMounted, onUnmounted, ref } from 'vue'; +import XChart from './overview.queue.chart.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const connection = markRaw(stream.useChannel('queueStats')); + +const activeSincePrevTick = ref(0); +const active = ref(0); +const delayed = ref(0); +const waiting = ref(0); +let chartProcess = $ref<InstanceType<typeof XChart>>(); +let chartActive = $ref<InstanceType<typeof XChart>>(); +let chartDelayed = $ref<InstanceType<typeof XChart>>(); +let chartWaiting = $ref<InstanceType<typeof XChart>>(); + +const props = defineProps<{ + domain: string; +}>(); + +const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + delayed.value = stats[props.domain].delayed; + waiting.value = stats[props.domain].waiting; + + chartProcess.pushData(stats[props.domain].activeSincePrevTick); + chartActive.pushData(stats[props.domain].active); + chartDelayed.pushData(stats[props.domain].delayed); + chartWaiting.pushData(stats[props.domain].waiting); +}; + +const onStatsLog = (statsLog) => { + const dataProcess = []; + const dataActive = []; + const dataDelayed = []; + const dataWaiting = []; + + for (const stats of [...statsLog].reverse()) { + dataProcess.push(stats[props.domain].activeSincePrevTick); + dataActive.push(stats[props.domain].active); + dataDelayed.push(stats[props.domain].delayed); + dataWaiting.push(stats[props.domain].waiting); + } + + chartProcess.setData(dataProcess); + chartActive.setData(dataActive); + chartDelayed.setData(dataDelayed); + chartWaiting.setData(dataWaiting); +}; + +onMounted(() => { + connection.on('stats', onStats); + connection.on('statsLog', onStatsLog); + connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 100, + }); +}); + +onUnmounted(() => { + connection.off('stats', onStats); + connection.off('statsLog', onStatsLog); + connection.dispose(); +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .status { + padding: 0 0 16px 0; + } + + > .charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + + > .chart { + min-width: 0; + padding: 16px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + font-size: 0.85em; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.retention.vue b/packages/frontend/src/pages/admin/overview.retention.vue new file mode 100644 index 0000000000..feac6f8118 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.retention.vue @@ -0,0 +1,49 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.root"> + <div v-for="row in retention" class="row"> + <div v-for="value in getValues(row)" v-tooltip="value.percentage" class="cell"> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; + +let retention: any = $ref(null); +let fetching = $ref(true); + +function getValues(row) { + const data = []; + for (const key in row.data) { + data.push({ + date: new Date(key), + value: number(row.data[key]), + percentage: `${Math.ceil(row.data[key] / row.users) * 100}%`, + }); + } + data.sort((a, b) => a.date > b.date); + return data; +} + +onMounted(async () => { + retention = await os.apiGet('retention', {}); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + + &:global { + + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue new file mode 100644 index 0000000000..4dcf7e751a --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -0,0 +1,155 @@ +<template> +<div> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.root"> + <div class="item _panel users"> + <div class="icon"><i class="ti ti-users"></i></div> + <div class="body"> + <div class="value"> + {{ number(stats.originalUsersCount) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff> + </div> + <div class="label">Users</div> + </div> + </div> + <div class="item _panel notes"> + <div class="icon"><i class="ti ti-pencil"></i></div> + <div class="body"> + <div class="value"> + {{ number(stats.originalNotesCount) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff> + </div> + <div class="label">Notes</div> + </div> + </div> + <div class="item _panel instances"> + <div class="icon"><i class="ti ti-planet"></i></div> + <div class="body"> + <div class="value"> + {{ number(stats.instances) }} + </div> + <div class="label">Instances</div> + </div> + </div> + <div class="item _panel online"> + <div class="icon"><i class="ti ti-access-point"></i></div> + <div class="body"> + <div class="value"> + {{ number(onlineUsersCount) }} + </div> + <div class="label">Online</div> + </div> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import MkMiniChart from '@/components/MkMiniChart.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import MkNumberDiff from '@/components/MkNumberDiff.vue'; +import { i18n } from '@/i18n'; + +let stats: any = $ref(null); +let usersComparedToThePrevDay = $ref<number>(); +let notesComparedToThePrevDay = $ref<number>(); +let onlineUsersCount = $ref(0); +let fetching = $ref(true); + +onMounted(async () => { + const [_stats, _onlineUsersCount] = await Promise.all([ + os.api('stats', {}), + os.api('get-online-users-count').then(res => res.count), + ]); + stats = _stats; + onlineUsersCount = _onlineUsersCount; + + os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => { + usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1]; + }); + + os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => { + notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1]; + }); + + fetching = false; +}); +</script> + +<style lang="scss" module> +.root { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + + &:global { + > .item { + display: flex; + box-sizing: border-box; + padding: 12px; + + > .icon { + display: grid; + place-items: center; + height: 100%; + aspect-ratio: 1; + margin-right: 12px; + background: var(--accentedBg); + color: var(--accent); + border-radius: 10px; + } + + &.users { + > .icon { + background: #0088d726; + color: #3d96c1; + } + } + + &.notes { + > .icon { + background: #86b30026; + color: #86b300; + } + } + + &.instances { + > .icon { + background: #e96b0026; + color: #d76d00; + } + } + + &.online { + > .icon { + background: #8a00d126; + color: #c01ac3; + } + } + + > .body { + padding: 2px 0; + + > .value { + font-size: 1.25em; + font-weight: bold; + + > .diff { + font-size: 0.65em; + font-weight: normal; + } + } + + > .label { + font-size: 0.8em; + opacity: 0.5; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue new file mode 100644 index 0000000000..5d4be11742 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -0,0 +1,57 @@ +<template> +<div :class="$style.root"> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <MkLoading v-if="fetching"/> + <div v-else class="users"> + <MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/user-info/${user.id}`" class="user"> + <MkUserCardMini :user="user"/> + </MkA> + </div> + </transition> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; + +let newUsers = $ref(null); +let fetching = $ref(true); + +const fetch = async () => { + const _newUsers = await os.api('admin/show-users', { + limit: 5, + sort: '+createdAt', + origin: 'local', + }); + newUsers = _newUsers; + fetching = false; +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" module> +.root { + &:global { + > .users { + .chart-move { + transition: transform 1s ease; + } + + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-gap: 12px; + + > .user:hover { + text-decoration: none; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue new file mode 100644 index 0000000000..d656e55200 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.vue @@ -0,0 +1,190 @@ +<template> +<MkSpacer :content-max="1000"> + <div ref="rootEl" class="edbbcaef"> + <MkFolder class="item"> + <template #header>Stats</template> + <XStats/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Active users</template> + <XActiveUsers/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Heatmap</template> + <XHeatmap/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Retention rate</template> + <XRetention/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Moderators</template> + <XModerators/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Federation</template> + <XFederation/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Instances</template> + <XInstances/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Ap requests</template> + <XApRequests/> + </MkFolder> + + <MkFolder class="item"> + <template #header>New users</template> + <XUsers/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Deliver queue</template> + <XQueue domain="deliver"/> + </MkFolder> + + <MkFolder class="item"> + <template #header>Inbox queue</template> + <XQueue domain="inbox"/> + </MkFolder> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; +import XFederation from './overview.federation.vue'; +import XInstances from './overview.instances.vue'; +import XQueue from './overview.queue.vue'; +import XApRequests from './overview.ap-requests.vue'; +import XUsers from './overview.users.vue'; +import XActiveUsers from './overview.active-users.vue'; +import XStats from './overview.stats.vue'; +import XRetention from './overview.retention.vue'; +import XModerators from './overview.moderators.vue'; +import XHeatmap from './overview.heatmap.vue'; +import MkTagCloud from '@/components/MkTagCloud.vue'; +import { version, url } from '@/config'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import 'chartjs-adapter-date-fns'; +import { defaultStore } from '@/store'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; +import MkFolder from '@/components/MkFolder.vue'; + +const rootEl = $ref<HTMLElement>(); +let serverInfo: any = $ref(null); +let topSubInstancesForPie: any = $ref(null); +let topPubInstancesForPie: any = $ref(null); +let federationPubActive = $ref<number | null>(null); +let federationPubActiveDiff = $ref<number | null>(null); +let federationSubActive = $ref<number | null>(null); +let federationSubActiveDiff = $ref<number | null>(null); +let newUsers = $ref(null); +let activeInstances = $shallowRef(null); +const queueStatsConnection = markRaw(stream.useChannel('queueStats')); +const now = new Date(); +const filesPagination = { + endpoint: 'admin/drive/files' as const, + limit: 9, + noPaging: true, +}; + +function onInstanceClick(i) { + os.pageWindow(`/instance-info/${i.host}`); +} + +onMounted(async () => { + /* + const magicGrid = new MagicGrid({ + container: rootEl, + static: true, + animate: true, + }); + + magicGrid.listen(); + */ + + os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => { + federationPubActive = chart.pubActive[0]; + federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1]; + federationSubActive = chart.subActive[0]; + federationSubActiveDiff = chart.subActive[0] - chart.subActive[1]; + }); + + os.apiGet('federation/stats', { limit: 10 }).then(res => { + topSubInstancesForPie = res.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]); + topPubInstancesForPie = res.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]); + }); + + os.api('admin/server-info').then(serverInfoResponse => { + serverInfo = serverInfoResponse; + }); + + os.api('admin/show-users', { + limit: 5, + sort: '+createdAt', + }).then(res => { + newUsers = res; + }); + + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 25, + }).then(res => { + activeInstances = res; + }); + + nextTick(() => { + queueStatsConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 100, + }); + }); +}); + +onBeforeUnmount(() => { + queueStatsConnection.dispose(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.dashboard, + icon: 'ti ti-dashboard', +}); +</script> + +<style lang="scss" scoped> +.edbbcaef { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + grid-gap: 16px; +} +</style> diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue new file mode 100644 index 0000000000..5d0d67980e --- /dev/null +++ b/packages/frontend/src/pages/admin/proxy-account.vue @@ -0,0 +1,62 @@ +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.proxyAccount }}</template> + <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template> + </MkKeyValue> + + <FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton> + </FormSuspense> +</MkSpacer></MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let proxyAccount: any = $ref(null); +let proxyAccountId: any = $ref(null); + +async function init() { + const meta = await os.api('admin/meta'); + proxyAccountId = meta.proxyAccountId; + if (proxyAccountId) { + proxyAccount = await os.api('users/show', { userId: proxyAccountId }); + } +} + +function chooseProxyAccount() { + os.selectUser().then(user => { + proxyAccount = user; + proxyAccountId = user.id; + save(); + }); +} + +function save() { + os.apiWithDialog('admin/update-meta', { + proxyAccountId: proxyAccountId, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.proxyAccount, + icon: 'ti ti-ghost', +}); +</script> diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue new file mode 100644 index 0000000000..5777674ae3 --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue @@ -0,0 +1,186 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { watch, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + type: string; +}>(); + +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})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// ใใฉใณใใซใฉใผ +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData(values) { + if (chartInstance == null) return; + for (const value of values) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + } + chartInstance.update(); +} + +function pushData(value) { + if (chartInstance == null) return; + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + chartInstance.update(); +} + +const label = + props.type === 'process' ? 'Process' : + props.type === 'active' ? 'Active' : + props.type === 'delayed' ? 'Delayed' : + props.type === 'waiting' ? 'Waiting' : + '?' as never; + +const color = + props.type === 'process' ? '#00E396' : + props.type === 'active' ? '#00BCD4' : + props.type === 'delayed' ? '#E53935' : + props.type === 'waiting' ? '#FFB300' : + '?' as never; + +onMounted(() => { + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: label, + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: color, + backgroundColor: alpha(color, 0.2), + fill: true, + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + plugins: [chartVLine(vLineColor)], + }); +}); + +defineExpose({ + setData, + pushData, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue new file mode 100644 index 0000000000..186a22c43e --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -0,0 +1,149 @@ +<template> +<div class="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="charts"> + <div class="chart"> + <div class="title">Process</div> + <XChart ref="chartProcess" type="process"/> + </div> + <div class="chart"> + <div class="title">Active</div> + <XChart ref="chartActive" type="active"/> + </div> + <div class="chart"> + <div class="title">Delayed</div> + <XChart ref="chartDelayed" type="delayed"/> + </div> + <div class="chart"> + <div class="title">Waiting</div> + <XChart ref="chartWaiting" type="waiting"/> + </div> + </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;">{{ i18n.ts.noJobs }}</span> + </div> +</div> +</template> + +<script lang="ts" setup> +import { markRaw, onMounted, onUnmounted, ref } from 'vue'; +import XChart from './queue.chart.chart.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const connection = markRaw(stream.useChannel('queueStats')); + +const activeSincePrevTick = ref(0); +const active = ref(0); +const delayed = ref(0); +const waiting = ref(0); +const jobs = ref([]); +let chartProcess = $ref<InstanceType<typeof XChart>>(); +let chartActive = $ref<InstanceType<typeof XChart>>(); +let chartDelayed = $ref<InstanceType<typeof XChart>>(); +let chartWaiting = $ref<InstanceType<typeof XChart>>(); + +const props = defineProps<{ + domain: string; +}>(); + +const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + delayed.value = stats[props.domain].delayed; + waiting.value = stats[props.domain].waiting; + + chartProcess.pushData(stats[props.domain].activeSincePrevTick); + chartActive.pushData(stats[props.domain].active); + chartDelayed.pushData(stats[props.domain].delayed); + chartWaiting.pushData(stats[props.domain].waiting); +}; + +const onStatsLog = (statsLog) => { + const dataProcess = []; + const dataActive = []; + const dataDelayed = []; + const dataWaiting = []; + + for (const stats of [...statsLog].reverse()) { + dataProcess.push(stats[props.domain].activeSincePrevTick); + dataActive.push(stats[props.domain].active); + dataDelayed.push(stats[props.domain].delayed); + dataWaiting.push(stats[props.domain].waiting); + } + + chartProcess.setData(dataProcess); + chartActive.setData(dataActive); + chartDelayed.setData(dataDelayed); + chartWaiting.setData(dataWaiting); +}; + +onMounted(() => { + os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => { + jobs.value = result; + }); + + connection.on('stats', onStats); + connection.on('statsLog', onStatsLog); + connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200, + }); +}); + +onUnmounted(() => { + connection.off('stats', onStats); + connection.off('statsLog', onStatsLog); + connection.dispose(); +}); +</script> + +<style lang="scss" scoped> +.pumxzjhg { + > .status { + padding: 16px; + } + + > .charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + + > .chart { + min-width: 0; + padding: 16px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + margin-bottom: 8px; + } + } + } + + > .jobs { + margin-top: 16px; + padding: 16px; + max-height: 180px; + overflow: auto; + background: var(--panel); + border-radius: var(--radius); + } + +} +</style> diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue new file mode 100644 index 0000000000..8d19b49fc5 --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.vue @@ -0,0 +1,56 @@ +<template> +<MkStickyContainer> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XQueue v-if="tab === 'deliver'" domain="deliver"/> + <XQueue v-else-if="tab === 'inbox'" domain="inbox"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue'; +import XQueue from './queue.chart.vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import * as config from '@/config'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let tab = $ref('deliver'); + +function clear() { + os.confirm({ + type: 'warning', + title: i18n.ts.clearQueueConfirmTitle, + text: i18n.ts.clearQueueConfirmText, + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/queue/clear'); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-external-link', + text: i18n.ts.dashboard, + handler: () => { + window.open(config.url + '/queue', '_blank'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'deliver', + title: 'Deliver', +}, { + key: 'inbox', + title: 'Inbox', +}]); + +definePageMetadata({ + title: i18n.ts.jobQueue, + icon: 'ti ti-clock-play', +}); +</script> diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue new file mode 100644 index 0000000000..4768ae67b1 --- /dev/null +++ b/packages/frontend/src/pages/admin/relays.vue @@ -0,0 +1,103 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;"> + <div>{{ relay.inbox }}</div> + <div class="status"> + <i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i> + <i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i> + <i v-else class="ti ti-clock icon requesting"></i> + <span>{{ $t(`_relayStatus.${relay.status}`) }}</span> + </div> + <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let relays: any[] = $ref([]); + +async function addRelay() { + const { canceled, result: inbox } = await os.inputText({ + title: i18n.ts.addRelay, + type: 'url', + placeholder: i18n.ts.inboxUrl, + }); + if (canceled) return; + os.api('admin/relays/add', { + inbox, + }).then((relay: any) => { + refresh(); + }).catch((err: any) => { + os.alert({ + type: 'error', + text: err.message || err, + }); + }); +} + +function remove(inbox: string) { + os.api('admin/relays/remove', { + inbox, + }).then(() => { + refresh(); + }).catch((err: any) => { + os.alert({ + type: 'error', + text: err.message || err, + }); + }); +} + +function refresh() { + os.api('admin/relays/list').then((relayList: any) => { + relays = relayList; + }); +} + +refresh(); + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.addRelay, + handler: addRelay, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.relays, + icon: 'ti ti-planet', +}); +</script> + +<style lang="scss" scoped> +.relaycxt { + > .status { + margin: 8px 0; + + > .icon { + width: 1em; + margin-right: 0.75em; + + &.accepted { + color: var(--success); + } + + &.rejected { + color: var(--error); + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue new file mode 100644 index 0000000000..2682bda337 --- /dev/null +++ b/packages/frontend/src/pages/admin/security.vue @@ -0,0 +1,179 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-shield"></i></template> + <template #label>{{ i18n.ts.botProtection }}</template> + <template v-if="enableHcaptcha" #suffix>hCaptcha</template> + <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template> + <template v-else-if="enableTurnstile" #suffix>Turnstile</template> + <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> + + <XBotProtection/> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #icon><i class="ti ti-eye-off"></i></template> + <template #label>{{ i18n.ts.sensitiveMediaDetection }}</template> + <template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template> + <template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template> + <template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template> + <template v-else #suffix>{{ i18n.ts.none }}</template> + + <div class="_formRoot"> + <span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span> + + <FormRadios v-model="sensitiveMediaDetection" class="_formBlock"> + <option value="none">{{ i18n.ts.none }}</option> + <option value="all">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.localOnly }}</option> + <option value="remote">{{ i18n.ts.remoteOnly }}</option> + </FormRadios> + + <FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template> + </FormRange> + + <FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template> + </FormSwitch> + + <FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template> + </FormSwitch> + + <!-- ็พ็ถ false positive ใๅคใใใฆๅฎ็จใซ่ใใชใ + <FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template> + </FormSwitch> + --> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Active Email Validation</template> + <template v-if="enableActiveEmailValidation" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + + <div class="_formRoot"> + <span class="_formBlock">{{ i18n.ts.activeEmailValidationDescription }}</span> + <FormSwitch v-model="enableActiveEmailValidation" class="_formBlock" @update:model-value="save"> + <template #label>Enable</template> + </FormSwitch> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Log IP address</template> + <template v-if="enableIpLogging" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + + <div class="_formRoot"> + <FormSwitch v-model="enableIpLogging" class="_formBlock" @update:model-value="save"> + <template #label>Enable</template> + </FormSwitch> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Summaly Proxy</template> + + <div class="_formRoot"> + <FormInput v-model="summalyProxy" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>Summaly Proxy URL</template> + </FormInput> + + <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormFolder> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XBotProtection from './bot-protection.vue'; +import XHeader from './_header_.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormRange from '@/components/form/range.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let summalyProxy: string = $ref(''); +let enableHcaptcha: boolean = $ref(false); +let enableRecaptcha: boolean = $ref(false); +let enableTurnstile: boolean = $ref(false); +let sensitiveMediaDetection: string = $ref('none'); +let sensitiveMediaDetectionSensitivity: number = $ref(0); +let setSensitiveFlagAutomatically: boolean = $ref(false); +let enableSensitiveMediaDetectionForVideos: boolean = $ref(false); +let enableIpLogging: boolean = $ref(false); +let enableActiveEmailValidation: boolean = $ref(false); + +async function init() { + const meta = await os.api('admin/meta'); + summalyProxy = meta.summalyProxy; + enableHcaptcha = meta.enableHcaptcha; + enableRecaptcha = meta.enableRecaptcha; + enableTurnstile = meta.enableTurnstile; + sensitiveMediaDetection = meta.sensitiveMediaDetection; + sensitiveMediaDetectionSensitivity = + meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 : + meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 : + meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 : + meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 : + meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0; + setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically; + enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos; + enableIpLogging = meta.enableIpLogging; + enableActiveEmailValidation = meta.enableActiveEmailValidation; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + summalyProxy, + sensitiveMediaDetection, + sensitiveMediaDetectionSensitivity: + sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' : + sensitiveMediaDetectionSensitivity === 1 ? 'low' : + sensitiveMediaDetectionSensitivity === 2 ? 'medium' : + sensitiveMediaDetectionSensitivity === 3 ? 'high' : + sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' : + 0, + setSensitiveFlagAutomatically, + enableSensitiveMediaDetectionForVideos, + enableIpLogging, + enableActiveEmailValidation, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.security, + icon: 'ti ti-lock', +}); +</script> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue new file mode 100644 index 0000000000..460eb92694 --- /dev/null +++ b/packages/frontend/src/pages/admin/settings.vue @@ -0,0 +1,262 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.instanceName }}</template> + </FormInput> + + <FormTextarea v-model="description" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDescription }}</template> + </FormTextarea> + + <FormInput v-model="tosUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.tosUrl }}</template> + </FormInput> + + <FormSplit :min-width="300"> + <FormInput v-model="maintainerName" class="_formBlock"> + <template #label>{{ i18n.ts.maintainerName }}</template> + </FormInput> + + <FormInput v-model="maintainerEmail" type="email" class="_formBlock"> + <template #prefix><i class="ti ti-mail"></i></template> + <template #label>{{ i18n.ts.maintainerEmail }}</template> + </FormInput> + </FormSplit> + + <FormTextarea v-model="pinnedUsers" class="_formBlock"> + <template #label>{{ i18n.ts.pinnedUsers }}</template> + <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template> + </FormTextarea> + + <FormSection> + <FormSwitch v-model="enableRegistration" class="_formBlock"> + <template #label>{{ i18n.ts.enableRegistration }}</template> + </FormSwitch> + + <FormSwitch v-model="emailRequiredForSignup" class="_formBlock"> + <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> + </FormSwitch> + </FormSection> + + <FormSection> + <FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch> + <FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch> + <FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.theme }}</template> + + <FormInput v-model="iconUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.iconUrl }}</template> + </FormInput> + + <FormInput v-model="bannerUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.bannerUrl }}</template> + </FormInput> + + <FormInput v-model="backgroundImageUrl" class="_formBlock"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.backgroundImageUrl }}</template> + </FormInput> + + <FormInput v-model="themeColor" class="_formBlock"> + <template #prefix><i class="ti ti-palette"></i></template> + <template #label>{{ i18n.ts.themeColor }}</template> + <template #caption>#RRGGBB</template> + </FormInput> + + <FormTextarea v-model="defaultLightTheme" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </FormTextarea> + + <FormTextarea v-model="defaultDarkTheme" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </FormTextarea> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.files }}</template> + + <FormSwitch v-model="cacheRemoteFiles" class="_formBlock"> + <template #label>{{ i18n.ts.cacheRemoteFiles }}</template> + <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template> + </FormSwitch> + + <FormSplit :min-width="280"> + <FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock"> + <template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template> + <template #suffix>MB</template> + <template #caption>{{ i18n.ts.inMb }}</template> + </FormInput> + + <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock"> + <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template> + <template #suffix>MB</template> + <template #caption>{{ i18n.ts.inMb }}</template> + </FormInput> + </FormSplit> + </FormSection> + + <FormSection> + <template #label>ServiceWorker</template> + + <FormSwitch v-model="enableServiceWorker" class="_formBlock"> + <template #label>{{ i18n.ts.enableServiceworker }}</template> + <template #caption>{{ i18n.ts.serviceworkerInfo }}</template> + </FormSwitch> + + <template v-if="enableServiceWorker"> + <FormInput v-model="swPublicKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Public key</template> + </FormInput> + + <FormInput v-model="swPrivateKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Private key</template> + </FormInput> + </template> + </FormSection> + + <FormSection> + <template #label>DeepL Translation</template> + + <FormInput v-model="deeplAuthKey" class="_formBlock"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>DeepL Auth Key</template> + </FormInput> + <FormSwitch v-model="deeplIsPro" class="_formBlock"> + <template #label>Pro account</template> + </FormSwitch> + </FormSection> + </div> + </FormSuspense> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XHeader from './_header_.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormInput from '@/components/form/input.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import * as os from '@/os'; +import { fetchInstance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let name: string | null = $ref(null); +let description: string | null = $ref(null); +let tosUrl: string | null = $ref(null); +let maintainerName: string | null = $ref(null); +let maintainerEmail: string | null = $ref(null); +let iconUrl: string | null = $ref(null); +let bannerUrl: string | null = $ref(null); +let backgroundImageUrl: string | null = $ref(null); +let themeColor: any = $ref(null); +let defaultLightTheme: any = $ref(null); +let defaultDarkTheme: any = $ref(null); +let enableLocalTimeline: boolean = $ref(false); +let enableGlobalTimeline: boolean = $ref(false); +let pinnedUsers: string = $ref(''); +let cacheRemoteFiles: boolean = $ref(false); +let localDriveCapacityMb: any = $ref(0); +let remoteDriveCapacityMb: any = $ref(0); +let enableRegistration: boolean = $ref(false); +let emailRequiredForSignup: boolean = $ref(false); +let enableServiceWorker: boolean = $ref(false); +let swPublicKey: any = $ref(null); +let swPrivateKey: any = $ref(null); +let deeplAuthKey: string = $ref(''); +let deeplIsPro: boolean = $ref(false); + +async function init() { + const meta = await os.api('admin/meta'); + name = meta.name; + description = meta.description; + tosUrl = meta.tosUrl; + iconUrl = meta.iconUrl; + bannerUrl = meta.bannerUrl; + backgroundImageUrl = meta.backgroundImageUrl; + themeColor = meta.themeColor; + defaultLightTheme = meta.defaultLightTheme; + defaultDarkTheme = meta.defaultDarkTheme; + maintainerName = meta.maintainerName; + maintainerEmail = meta.maintainerEmail; + enableLocalTimeline = !meta.disableLocalTimeline; + enableGlobalTimeline = !meta.disableGlobalTimeline; + pinnedUsers = meta.pinnedUsers.join('\n'); + cacheRemoteFiles = meta.cacheRemoteFiles; + localDriveCapacityMb = meta.driveCapacityPerLocalUserMb; + remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb; + enableRegistration = !meta.disableRegistration; + emailRequiredForSignup = meta.emailRequiredForSignup; + enableServiceWorker = meta.enableServiceWorker; + swPublicKey = meta.swPublickey; + swPrivateKey = meta.swPrivateKey; + deeplAuthKey = meta.deeplAuthKey; + deeplIsPro = meta.deeplIsPro; +} + +function save() { + os.apiWithDialog('admin/update-meta', { + name, + description, + tosUrl, + iconUrl, + bannerUrl, + backgroundImageUrl, + themeColor: themeColor === '' ? null : themeColor, + defaultLightTheme: defaultLightTheme === '' ? null : defaultLightTheme, + defaultDarkTheme: defaultDarkTheme === '' ? null : defaultDarkTheme, + maintainerName, + maintainerEmail, + disableLocalTimeline: !enableLocalTimeline, + disableGlobalTimeline: !enableGlobalTimeline, + pinnedUsers: pinnedUsers.split('\n'), + cacheRemoteFiles, + localDriveCapacityMb: parseInt(localDriveCapacityMb, 10), + remoteDriveCapacityMb: parseInt(remoteDriveCapacityMb, 10), + disableRegistration: !enableRegistration, + emailRequiredForSignup, + enableServiceWorker, + swPublicKey, + swPrivateKey, + deeplAuthKey, + deeplIsPro, + }).then(() => { + fetchInstance(); + }); +} + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.general, + icon: 'ti ti-settings', +}); +</script> diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue new file mode 100644 index 0000000000..d466e21907 --- /dev/null +++ b/packages/frontend/src/pages/admin/users.vue @@ -0,0 +1,170 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="lknzcolw"> + <div class="users"> + <div class="inputs"> + <MkSelect v-model="sort" style="flex: 1;"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option> + </MkSelect> + <MkSelect v-model="state" style="flex: 1;"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="available">{{ i18n.ts.normal }}</option> + <option value="admin">{{ i18n.ts.administrator }}</option> + <option value="moderator">{{ i18n.ts.moderator }}</option> + <option value="silenced">{{ i18n.ts.silence }}</option> + <option value="suspended">{{ i18n.ts.suspend }}</option> + </MkSelect> + <MkSelect v-model="origin" style="flex: 1;"> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + </div> + <div class="inputs"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ i18n.ts.username }}</template> + </MkInput> + <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </div> + + <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </MkPagination> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XHeader from './_header_.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { lookupUser } from '@/scripts/lookup-user'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; + +let paginationComponent = $ref<InstanceType<typeof MkPagination>>(); + +let sort = $ref('+createdAt'); +let state = $ref('all'); +let origin = $ref('local'); +let searchUsername = $ref(''); +let searchHost = $ref(''); +const pagination = { + endpoint: 'admin/show-users' as const, + limit: 10, + params: computed(() => ({ + sort: sort, + state: state, + origin: origin, + username: searchUsername, + hostname: searchHost, + })), + offsetMode: true, +}; + +function searchUser() { + os.selectUser().then(user => { + show(user); + }); +} + +async function addUser() { + const { canceled: canceled1, result: username } = await os.inputText({ + title: i18n.ts.username, + }); + if (canceled1) return; + + const { canceled: canceled2, result: password } = await os.inputText({ + title: i18n.ts.password, + type: 'password', + }); + if (canceled2) return; + + os.apiWithDialog('admin/accounts/create', { + username: username, + password: password, + }).then(res => { + paginationComponent.reload(); + }); +} + +function show(user) { + os.pageWindow(`/user-info/${user.id}`); +} + +const headerActions = $computed(() => [{ + icon: 'ti ti-search', + text: i18n.ts.search, + handler: searchUser, +}, { + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts.addUser, + handler: addUser, +}, { + asFullButton: true, + icon: 'ti ti-search', + text: i18n.ts.lookup, + handler: lookupUser, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.users, + icon: 'ti ti-users', +}))); +</script> + +<style lang="scss" scoped> +.lknzcolw { + > .users { + + > .inputs { + display: flex; + margin-bottom: 16px; + + > * { + margin-right: 16px; + + &:last-child { + margin-right: 0; + } + } + } + + > .users { + margin-top: var(--margin); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; + + > .user:hover { + text-decoration: none; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue new file mode 100644 index 0000000000..6a93b3b9fa --- /dev/null +++ b/packages/frontend/src/pages/announcements.vue @@ -0,0 +1,69 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content"> + <section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement"> + <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 v-if="$i && !announcement.isRead" class="_footer"> + <MkButton primary @click="read(items, announcement, i)"><i class="ti ti-check"></i> {{ $ts.gotIt }}</MkButton> + </div> + </section> + </MkPagination> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'announcements' as const, + limit: 10, +}; + +// TODO: ใใใฏๅฎ่ณช็ใซ่ฆชใณใณใใผใใณใใใๅญใณใณใใผใใณใใฎใใญใใใฃใๅคๆดใใฆใใฎใงใชใใจใใใใ +function read(items, announcement, i) { + items[i] = { + ...announcement, + isRead: true, + }; + os.api('i/read-announcement', { announcementId: announcement.id }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.announcements, + icon: 'ti ti-speakerphone', +}); +</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/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue new file mode 100644 index 0000000000..0b2c284c99 --- /dev/null +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -0,0 +1,128 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks"> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tlEl" :key="antennaId" + class="tl" + src="antenna" + :antenna="antennaId" + :sound="true" + @queue="queueUpdated" + /> + </div> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +const props = defineProps<{ + antennaId: string; +}>(); + +let antenna = $ref(null); +let queue = $ref(0); +let rootEl = $ref<HTMLElement>(); +let tlEl = $ref<InstanceType<typeof XTimeline>>(); +const keymap = $computed(() => ({ + 't': focus, +})); + +function queueUpdated(q) { + queue = q; +} + +function top() { + scroll(rootEl, { top: 0 }); +} + +async function timetravel() { + const { canceled, result: date } = await os.inputDate({ + title: i18n.ts.date, + }); + if (canceled) return; + + tlEl.timetravel(date); +} + +function settings() { + router.push(`/my/antennas/${props.antennaId}`); +} + +function focus() { + tlEl.focus(); +} + +watch(() => props.antennaId, async () => { + antenna = await os.api('antennas/show', { + antennaId: props.antennaId, + }); +}, { immediate: true }); + +const headerActions = $computed(() => antenna ? [{ + icon: 'fas fa-calendar-alt', + text: i18n.ts.jumpToSpecifiedDate, + handler: timetravel, +}, { + icon: 'ti ti-settings', + text: i18n.ts.settings, + handler: settings, +}] : []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => antenna ? { + title: antenna.name, + icon: 'ti ti-antenna', +} : null)); +</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; + } +} + +@container (min-width: 800px) { + .tqmomfks { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue new file mode 100644 index 0000000000..1d5339b44c --- /dev/null +++ b/packages/frontend/src/pages/api-console.vue @@ -0,0 +1,89 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="_formRoot"> + <div class="_formBlock"> + <MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:model-value="onEndpointChange()"> + <template #label>Endpoint</template> + </MkInput> + <MkTextarea v-model="body" class="_formBlock" code> + <template #label>Params (JSON or JSON5)</template> + </MkTextarea> + <MkSwitch v-model="withCredential" class="_formBlock"> + With credential + </MkSwitch> + <MkButton class="_formBlock" primary :disabled="sending" @click="send"> + <template v-if="sending"><MkEllipsis/></template> + <template v-else><i class="ti ti-send"></i> Send</template> + </MkButton> + </div> + <div v-if="res" class="_formBlock"> + <MkTextarea v-model="res" code readonly tall> + <template #label>Response</template> + </MkTextarea> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import JSON5 from 'json5'; +import { Endpoints } from 'misskey-js'; +import MkButton from '@/components/MkButton.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 { definePageMetadata } from '@/scripts/page-metadata'; + +const body = ref('{}'); +const endpoint = ref(''); +const endpoints = ref<any[]>([]); +const sending = ref(false); +const res = ref(''); +const withCredential = ref(true); + +os.api('endpoints').then(endpointResponse => { + endpoints.value = endpointResponse; +}); + +function send() { + sending.value = true; + const requestBody = JSON5.parse(body.value); + os.api(endpoint.value as keyof Endpoints, requestBody, requestBody.i || (withCredential.value ? undefined : null)).then(resp => { + sending.value = false; + res.value = JSON5.stringify(resp, null, 2); + }, err => { + sending.value = false; + res.value = JSON5.stringify(err, null, 2); + }); +} + +function onEndpointChange() { + os.api('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => { + const endpointBody = {}; + for (const p of resp.params) { + endpointBody[p.name] = + p.type === 'String' ? '' : + p.type === 'Number' ? 0 : + p.type === 'Boolean' ? false : + p.type === 'Array' ? [] : + p.type === 'Object' ? {} : + null; + } + body.value = JSON5.stringify(endpointBody, null, 2); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'API console', + icon: 'ti ti-terminal-2', +}); +</script> diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue new file mode 100644 index 0000000000..1546735266 --- /dev/null +++ b/packages/frontend/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 inline @click="cancel">{{ $ts.cancel }}</MkButton> + <MkButton inline primary @click="accept">{{ $ts.accept }}</MkButton> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/MkButton.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/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue new file mode 100644 index 0000000000..bb55881a22 --- /dev/null +++ b/packages/frontend/src/pages/auth.vue @@ -0,0 +1,91 @@ +<template> +<div v-if="$i && fetching" class=""> + <MkLoading/> +</div> +<div v-else-if="$i"> + <XForm + v-if="state == 'waiting'" + ref="form" + class="form" + :session="session" + @denied="state = 'denied'" + @accepted="accepted" + /> + <div v-if="state == 'denied'" class="denied"> + <h1>{{ $ts._auth.denied }}</h1> + </div> + <div v-if="state == 'accepted'" class="accepted"> + <h1>{{ session.app.isAuthorized ? $t('already-authorized') : $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 v-if="state == 'fetch-session-error'" class="error"> + <p>{{ $ts.somethingHappened }}</p> + </div> +</div> +<div v-else class="signin"> + <MkSignin @login="onLogin"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XForm from './auth.form.vue'; +import MkSignin from '@/components/MkSignin.vue'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + XForm, + MkSignin, + }, + props: ['token'], + data() { + return { + state: null, + session: null, + fetching: true, + }; + }, + 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/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue new file mode 100644 index 0000000000..5ae7e63f99 --- /dev/null +++ b/packages/frontend/src/pages/channel-editor.vue @@ -0,0 +1,122 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="_formRoot"> + <MkInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + + <MkTextarea v-model="description" class="_formBlock"> + <template #label>{{ i18n.ts.description }}</template> + </MkTextarea> + + <div class="banner"> + <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton> + <div v-else-if="bannerUrl"> + <img :src="bannerUrl" style="width: 100%;"/> + <MkButton @click="removeBannerImage()"><i class="ti ti-trash"></i> {{ i18n.ts._channel.removeBanner }}</MkButton> + </div> + </div> + <div class="_formBlock"> + <MkButton primary @click="save()"><i class="ti ti-device-floppy"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +const props = defineProps<{ + channelId?: string; +}>(); + +let channel = $ref(null); +let name = $ref(null); +let description = $ref(null); +let bannerUrl = $ref<string | null>(null); +let bannerId = $ref<string | null>(null); + +watch(() => bannerId, async () => { + if (bannerId == null) { + bannerUrl = null; + } else { + bannerUrl = (await os.api('drive/files/show', { + fileId: bannerId, + })).url; + } +}); + +async function fetchChannel() { + if (props.channelId == null) return; + + channel = await os.api('channels/show', { + channelId: props.channelId, + }); + + name = channel.name; + description = channel.description; + bannerId = channel.bannerId; + bannerUrl = channel.bannerUrl; +} + +fetchChannel(); + +function save() { + const params = { + name: name, + description: description, + bannerId: bannerId, + }; + + if (props.channelId) { + params.channelId = props.channelId; + os.api('channels/update', params).then(() => { + os.success(); + }); + } else { + os.api('channels/create', params).then(created => { + os.success(); + router.push(`/channels/${created.id}`); + }); + } +} + +function setBannerImage(evt) { + selectFile(evt.currentTarget ?? evt.target, null).then(file => { + bannerId = file.id; + }); +} + +function removeBannerImage() { + bannerId = null; +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => props.channelId ? { + title: i18n.ts._channel.edit, + icon: 'ti ti-device-tv', +} : { + title: i18n.ts._channel.create, + icon: 'ti ti-device-tv', +})); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue new file mode 100644 index 0000000000..f271bb270f --- /dev/null +++ b/packages/frontend/src/pages/channel.vue @@ -0,0 +1,184 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div v-if="channel"> + <div class="wpgynlbz _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="ti ti-chevron-up"></i></template> + <template v-else><i class="ti ti-chevron-down"></i></template> + </button> + <div v-if="!showBanner" class="hideOverlay"> + </div> + <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner"> + <div class="status"> + <div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> + <div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.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 v-if="channel.description" class="description"> + <Mfm :text="channel.description" :is-note="false" :i="$i"/> + </div> + </div> + + <XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/> + + <XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; +import MkContainer from '@/components/MkContainer.vue'; +import XPostForm from '@/components/MkPostForm.vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import XChannelFollowButton from '@/components/MkChannelFollowButton.vue'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const router = useRouter(); + +const props = defineProps<{ + channelId: string; +}>(); + +let channel = $ref(null); +let showBanner = $ref(true); +const pagination = { + endpoint: 'channels/timeline' as const, + limit: 10, + params: computed(() => ({ + channelId: props.channelId, + })), +}; + +watch(() => props.channelId, async () => { + channel = await os.api('channels/show', { + channelId: props.channelId, + }); +}, { immediate: true }); + +function edit() { + router.push(`/channels/${channel.id}/edit`); +} + +const headerActions = $computed(() => channel && channel.userId ? [{ + icon: 'ti ti-settings', + text: i18n.ts.edit, + handler: edit, +}] : null); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => channel ? { + title: channel.name, + icon: 'ti ti-device-tv', +} : null)); +</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/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue new file mode 100644 index 0000000000..34e9dac196 --- /dev/null +++ b/packages/frontend/src/pages/channels.vue @@ -0,0 +1,79 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div v-if="tab === 'featured'" class="_content grwlizim featured"> + <MkPagination v-slot="{items}" :pagination="featuredPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'following'" class="_content grwlizim following"> + <MkPagination v-slot="{items}" :pagination="followingPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'owned'" class="_content grwlizim owned"> + <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="ownedPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, defineComponent, inject } from 'vue'; +import MkChannelPreview from '@/components/MkChannelPreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +let tab = $ref('featured'); + +const featuredPagination = { + endpoint: 'channels/featured' as const, + noPaging: true, +}; +const followingPagination = { + endpoint: 'channels/followed' as const, + limit: 5, +}; +const ownedPagination = { + endpoint: 'channels/owned' as const, + limit: 5, +}; + +function create() { + router.push('/channels/new'); +} + +const headerActions = $computed(() => [{ + icon: 'ti ti-plus', + text: i18n.ts.create, + handler: create, +}]); + +const headerTabs = $computed(() => [{ + key: 'featured', + title: i18n.ts._channel.featured, + icon: 'ti ti-comet', +}, { + key: 'following', + title: i18n.ts._channel.following, + icon: 'ti ti-heart', +}, { + key: 'owned', + title: i18n.ts._channel.owned, + icon: 'ti ti-edit', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.channel, + icon: 'ti ti-device-tv', +}))); +</script> diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue new file mode 100644 index 0000000000..e0fbcb6bed --- /dev/null +++ b/packages/frontend/src/pages/clip.vue @@ -0,0 +1,129 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions"/></template> + <MkSpacer :content-max="800"> + <div v-if="clip"> + <div class="okzinsic _panel"> + <div v-if="clip.description" class="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 :pagination="pagination" :detail="true"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch, provide } from 'vue'; +import * as misskey from 'misskey-js'; +import XNotes from '@/components/MkNotes.vue'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + clipId: string, +}>(); + +let clip: misskey.entities.Clip = $ref<misskey.entities.Clip>(); +const pagination = { + endpoint: 'clips/notes' as const, + limit: 10, + params: computed(() => ({ + clipId: props.clipId, + })), +}; + +const isOwned: boolean | null = $computed<boolean | null>(() => $i && clip && ($i.id === clip.userId)); + +watch(() => props.clipId, async () => { + clip = await os.api('clips/show', { + clipId: props.clipId, + }); +}, { + immediate: true, +}); + +provide('currentClipPage', $$(clip)); + +const headerActions = $computed(() => clip && isOwned ? [{ + icon: 'ti ti-pencil', + text: i18n.ts.edit, + handler: async (): Promise<void> => { + const { canceled, result } = await os.form(clip.name, { + name: { + type: 'string', + label: i18n.ts.name, + default: clip.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + default: clip.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: clip.isPublic, + }, + }); + if (canceled) return; + + os.apiWithDialog('clips/update', { + clipId: clip.id, + ...result, + }); + }, +}, { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + handler: async (): Promise<void> => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('deleteAreYouSure', { x: clip.name }), + }); + if (canceled) return; + + await os.apiWithDialog('clips/delete', { + clipId: clip.id, + }); + }, +}] : null); + +definePageMetadata(computed(() => clip ? { + title: clip.name, + icon: 'ti ti-paperclip', +} : null)); +</script> + +<style lang="scss" scoped> +.okzinsic { + position: relative; + margin-bottom: var(--margin); + + > .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/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue new file mode 100644 index 0000000000..04ade5c207 --- /dev/null +++ b/packages/frontend/src/pages/drive.vue @@ -0,0 +1,25 @@ +<template> +<div> + <XDrive ref="drive" @cd="x => folder = x"/> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XDrive from '@/components/MkDrive.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let folder = $ref(null); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: folder ? folder.name : i18n.ts.drive, + icon: 'ti ti-cloud', + hideHeader: true, +}))); +</script> diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue new file mode 100644 index 0000000000..40fe496520 --- /dev/null +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -0,0 +1,72 @@ +<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" setup> +import { } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + emoji: Record<string, unknown>; // TODO +}>(); + +function menu(ev) { + os.popupMenu([{ + type: 'label', + text: ':' + props.emoji.name + ':', + }, { + text: i18n.ts.copy, + icon: 'ti ti-copy', + action: () => { + copyToClipboard(`:${props.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; + + &:hover { + border-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; + font-size: 0.9em; + text-overflow: ellipsis; + overflow: hidden; + } + } +} +</style> diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue new file mode 100644 index 0000000000..18a371a086 --- /dev/null +++ b/packages/frontend/src/pages/explore.featured.vue @@ -0,0 +1,30 @@ +<template> +<MkSpacer :content-max="800"> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="notes">{{ i18n.ts.notes }}</option> + <option value="polls">{{ i18n.ts.poll }}</option> + </MkTab> + <XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> + <XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> +</MkSpacer> +</template> + +<script lang="ts" setup> +import XNotes from '@/components/MkNotes.vue'; +import MkTab from '@/components/MkTab.vue'; +import { i18n } from '@/i18n'; + +const paginationForNotes = { + endpoint: 'notes/featured' as const, + limit: 10, + offsetMode: true, +}; + +const paginationForPolls = { + endpoint: 'notes/polls/recommendation' as const, + limit: 10, + offsetMode: true, +}; + +let tab = $ref('notes'); +</script> diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue new file mode 100644 index 0000000000..bfee0a6c07 --- /dev/null +++ b/packages/frontend/src/pages/explore.users.vue @@ -0,0 +1,148 @@ +<template> +<MkSpacer :content-max="1200"> + <MkTab v-model="origin" style="margin-bottom: var(--margin);"> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkTab> + <div v-if="origin === 'local'"> + <template v-if="tag == null"> + <MkFolder class="_gap" persist-key="explore-pinned-users"> + <template #header><i class="fas fa-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template> + <XUserList :pagination="pinnedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-popular-users"> + <template #header><i class="fas fa-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> + <XUserList :pagination="popularUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-updated-users"> + <template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-registered-users"> + <template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsers"/> + </MkFolder> + </template> + </div> + <div v-else> + <MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap"> + <template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template> + + <div class="vxjfqztj"> + <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> + <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA> + </div> + </MkFolder> + + <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap"> + <template #header><i class="ti ti-hash ti-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 ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template> + <XUserList :pagination="popularUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsersF"/> + </MkFolder> + </template> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import XUserList from '@/components/MkUserList.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkTab from '@/components/MkTab.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; + +const props = defineProps<{ + tag?: string; +}>(); + +let origin = $ref('local'); +let tagsEl = $ref<InstanceType<typeof MkFolder>>(); +let tagsLocal = $ref([]); +let tagsRemote = $ref([]); + +watch(() => props.tag, () => { + if (tagsEl) tagsEl.toggleContent(props.tag == null); +}); + +const tagUsers = $computed(() => ({ + endpoint: 'hashtags/users' as const, + limit: 30, + params: { + tag: props.tag, + origin: 'combined', + sort: '+follower', + }, +})); + +const pinnedUsers = { endpoint: 'pinned-users' }; +const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'local', + sort: '+follower', +} }; +const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + sort: '+updatedAt', +} }; +const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + state: 'alive', + sort: '+createdAt', +} }; +const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'remote', + sort: '+follower', +} }; +const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+updatedAt', +} }; +const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+createdAt', +} }; + +os.api('hashtags/list', { + sort: '+attachedLocalUsers', + attachedToLocalUserOnly: true, + limit: 30, +}).then(tags => { + tagsLocal = tags; +}); +os.api('hashtags/list', { + sort: '+attachedRemoteUsers', + attachedToRemoteUserOnly: true, + limit: 30, +}).then(tags => { + tagsRemote = tags; +}); +</script> + +<style lang="scss" scoped> +.vxjfqztj { + > * { + margin-right: 16px; + + &.local { + font-weight: bold; + } + } +} +</style> diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue new file mode 100644 index 0000000000..6b0bcdaf62 --- /dev/null +++ b/packages/frontend/src/pages/explore.vue @@ -0,0 +1,87 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <div class="lznhrdub"> + <div v-if="tab === 'featured'"> + <XFeatured/> + </div> + <div v-else-if="tab === 'users'"> + <XUsers/> + </div> + <div v-else-if="tab === 'search'"> + <MkSpacer :content-max="1200"> + <div> + <MkInput v-model="searchQuery" :debounce="true" type="search" class="_formBlock"> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.searchUser }}</template> + </MkInput> + <MkRadios v-model="searchOrigin" class="_formBlock"> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkRadios> + </div> + + <XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/> + </MkSpacer> + </div> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import XFeatured from './explore.featured.vue'; +import XUsers from './explore.users.vue'; +import MkFolder from '@/components/MkFolder.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 { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import XUserList from '@/components/MkUserList.vue'; + +const props = defineProps<{ + tag?: string; +}>(); + +let tab = $ref('featured'); +let tagsEl = $ref<InstanceType<typeof MkFolder>>(); +let searchQuery = $ref(null); +let searchOrigin = $ref('combined'); + +watch(() => props.tag, () => { + if (tagsEl) tagsEl.toggleContent(props.tag == null); +}); + +const searchPagination = { + endpoint: 'users/search' as const, + limit: 10, + params: computed(() => (searchQuery && searchQuery !== '') ? { + query: searchQuery, + origin: searchOrigin, + } : null), +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'featured', + icon: 'ti ti-bolt', + title: i18n.ts.featured, +}, { + key: 'users', + icon: 'ti ti-users', + title: i18n.ts.users, +}, { + key: 'search', + title: i18n.ts.search, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.explore, + icon: 'ti ti-hash', +}))); +</script> diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue new file mode 100644 index 0000000000..ab47efec71 --- /dev/null +++ b/packages/frontend/src/pages/favorites.vue @@ -0,0 +1,49 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="800"> + <MkPagination ref="pagingComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noNotes }}</div> + </div> + </template> + + <template #default="{ items }"> + <XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> + <XNote :key="item.id" :note="item.note" :class="$style.note"/> + </XList> + </template> + </MkPagination> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import XNote from '@/components/MkNote.vue'; +import XList from '@/components/MkDateSeparatedList.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'i/favorites' as const, + limit: 10, +}; + +const pagingComponent = ref<InstanceType<typeof MkPagination>>(); + +definePageMetadata({ + title: i18n.ts.favorites, + icon: 'ti ti-star', +}); +</script> + +<style lang="scss" module> +.note { + background: var(--panel); + border-radius: var(--radius); +} +</style> diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue new file mode 100644 index 0000000000..b20679ccc1 --- /dev/null +++ b/packages/frontend/src/pages/follow-requests.vue @@ -0,0 +1,153 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="800"> + <MkPagination ref="paginationComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noFollowRequests }}</div> + </div> + </template> + <template #default="{items}"> + <div class="mk-follow-requests"> + <div v-for="req in items" :key="req.id" class="user _panel"> + <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> + <div class="body"> + <div class="name"> + <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> + <p class="acct">@{{ acct(req.follower) }}</p> + </div> + <div v-if="req.follower.description" class="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="ti ti-check"></i></button> + <button class="_button" @click="reject(req.follower)"><i class="ti ti-x"></i></button> + </div> + </div> + </div> + </div> + </template> + </MkPagination> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import { userPage, acct } from '@/filters/user'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const paginationComponent = ref<InstanceType<typeof MkPagination>>(); + +const pagination = { + endpoint: 'following/requests/list' as const, + limit: 10, +}; + +function accept(user) { + os.api('following/requests/accept', { userId: user.id }).then(() => { + paginationComponent.value.reload(); + }); +} + +function reject(user) { + os.api('following/requests/reject', { userId: user.id }).then(() => { + paginationComponent.value.reload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.followRequests, + icon: 'ti ti-user-plus', +}))); +</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/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue new file mode 100644 index 0000000000..828246d678 --- /dev/null +++ b/packages/frontend/src/pages/follow.vue @@ -0,0 +1,62 @@ +<template> +<div class="mk-follow-page"> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; +import { i18n } from '@/i18n'; + +async function follow(user): Promise<void> { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('followConfirm', { name: user.name || user.username }), + }); + + if (canceled) { + window.close(); + return; + } + + os.apiWithDialog('following/create', { + userId: user.id, + }); +} + +const acct = new URL(location.href).searchParams.get('acct'); +if (acct == null) { + throw new Error('acct required'); +} + +let promise; + +if (acct.startsWith('https://')) { + promise = os.api('ap/show', { + uri: acct, + }); + promise.then(res => { + if (res.type === 'User') { + follow(res.object); + } else if (res.type === 'Note') { + mainRouter.push(`/notes/${res.object.id}`); + } else { + os.alert({ + type: 'error', + text: 'Not a user', + }).then(() => { + window.close(); + }); + } + }); +} else { + promise = os.api('users/show', Acct.parse(acct)); + promise.then(user => { + follow(user); + }); +} + +os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); +</script> diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue new file mode 100644 index 0000000000..c8111d7890 --- /dev/null +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -0,0 +1,149 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormInput v-model="title"> + <template #label>{{ i18n.ts.title }}</template> + </FormInput> + + <FormTextarea v-model="description" :max="500"> + <template #label>{{ i18n.ts.description }}</template> + </FormTextarea> + + <div class=""> + <div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> + <div class="name">{{ file.name }}</div> + <button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="ti ti-x"></i></button> + </div> + <FormButton primary @click="selectFile"><i class="ti ti-plus"></i> {{ i18n.ts.attachFile }}</FormButton> + </div> + + <FormSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</FormSwitch> + + <FormButton v-if="postId" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton> + <FormButton v-else primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.publish }}</FormButton> + + <FormButton v-if="postId" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</FormButton> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInput from '@/components/form/input.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import { selectFiles } from '@/scripts/select-file'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +const props = defineProps<{ + postId?: string; +}>(); + +let init = $ref(null); +let files = $ref([]); +let description = $ref(null); +let title = $ref(null); +let isSensitive = $ref(false); + +function selectFile(evt) { + selectFiles(evt.currentTarget ?? evt.target, null).then(selected => { + files = files.concat(selected); + }); +} + +function remove(file) { + files = files.filter(f => f.id !== file.id); +} + +async function save() { + if (props.postId) { + await os.apiWithDialog('gallery/posts/update', { + postId: props.postId, + title: title, + description: description, + fileIds: files.map(file => file.id), + isSensitive: isSensitive, + }); + router.push(`/gallery/${props.postId}`); + } else { + const created = await os.apiWithDialog('gallery/posts/create', { + title: title, + description: description, + fileIds: files.map(file => file.id), + isSensitive: isSensitive, + }); + router.push(`/gallery/${created.id}`); + } +} + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }); + if (canceled) return; + await os.apiWithDialog('gallery/posts/delete', { + postId: props.postId, + }); + router.push('/gallery'); +} + +watch(() => props.postId, () => { + init = () => props.postId ? os.api('gallery/posts/show', { + postId: props.postId, + }).then(post => { + files = post.files; + title = post.title; + description = post.description; + isSensitive = post.isSensitive; + }) : Promise.resolve(null); +}, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => props.postId ? { + title: i18n.ts.edit, + icon: 'ti ti-pencil', +} : { + title: i18n.ts.postToGallery, + icon: 'ti ti-pencil', +})); +</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/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue new file mode 100644 index 0000000000..24a634bab5 --- /dev/null +++ b/packages/frontend/src/pages/gallery/index.vue @@ -0,0 +1,139 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1400"> + <div class="_root"> + <div v-if="tab === 'explore'"> + <MkFolder class="_gap"> + <template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="ti ti-comet"></i>{{ i18n.ts.popularPosts }}</template> + <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkFolder> + </div> + <div v-else-if="tab === 'liked'"> + <MkPagination v-slot="{items}" :pagination="likedPostsPagination"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> + </div> + </MkPagination> + </div> + <div v-else-if="tab === 'my'"> + <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="ti ti-plus"></i> {{ i18n.ts.postToGallery }}</MkA> + <MkPagination v-slot="{items}" :pagination="myPostsPagination"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, defineComponent, watch } from 'vue'; +import XUserList from '@/components/MkUserList.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkInput from '@/components/form/input.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { useRouter } from '@/router'; + +const router = useRouter(); + +const props = defineProps<{ + tag?: string; +}>(); + +let tab = $ref('explore'); +let tags = $ref([]); +let tagsRef = $ref(); + +const recentPostsPagination = { + endpoint: 'gallery/posts' as const, + limit: 6, +}; +const popularPostsPagination = { + endpoint: 'gallery/featured' as const, + limit: 5, +}; +const myPostsPagination = { + endpoint: 'i/gallery/posts' as const, + limit: 5, +}; +const likedPostsPagination = { + endpoint: 'i/gallery/likes' as const, + limit: 5, +}; + +const tagUsersPagination = $computed(() => ({ + endpoint: 'hashtags/users' as const, + limit: 30, + params: { + tag: this.tag, + origin: 'combined', + sort: '+follower', + }, +})); + +watch(() => props.tag, () => { + if (tagsRef) tagsRef.tags.toggleContent(props.tag == null); +}); + +const headerActions = $computed(() => [{ + icon: 'ti ti-plus', + text: i18n.ts.create, + handler: () => { + router.push('/gallery/new'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'explore', + title: i18n.ts.gallery, + icon: 'ti ti-icons', +}, { + key: 'liked', + title: i18n.ts._gallery.liked, + icon: 'ti ti-heart', +}, { + key: 'my', + title: i18n.ts._gallery.my, + icon: 'ti ti-edit', +}]); + +definePageMetadata({ + title: i18n.ts.gallery, + icon: 'ti ti-icons', +}); +</script> + +<style lang="scss" scoped> +.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/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue new file mode 100644 index 0000000000..85ab1048be --- /dev/null +++ b/packages/frontend/src/pages/gallery/post.vue @@ -0,0 +1,265 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000" :margin-min="16" :margin-max="32"> + <div class="_root"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="post" class="rkxwuolj"> + <div class="files"> + <div v-for="file in post.files" :key="file.id" class="file"> + <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="ti ti-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton v-if="post.isLiked" v-tooltip="i18n.ts._gallery.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts._gallery.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button> + <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> + <button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-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="ti ti-clock"></i> {{ i18n.ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> + <div class="sdrarzaf"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkContainer> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, defineComponent, inject, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import { url } from '@/config'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const router = useRouter(); + +const props = defineProps<{ + postId: string; +}>(); + +let post = $ref(null); +let error = $ref(null); +const otherPostsPagination = { + endpoint: 'users/gallery/posts' as const, + limit: 6, + params: computed(() => ({ + userId: post.user.id, + })), +}; + +function fetchPost() { + post = null; + os.api('gallery/posts/show', { + postId: props.postId, + }).then(_post => { + post = _post; + }).catch(_error => { + error = _error; + }); +} + +function share() { + navigator.share({ + title: post.title, + text: post.description, + url: `${url}/gallery/${post.id}`, + }); +} + +function shareWithNote() { + os.post({ + initialText: `${post.title} ${url}/gallery/${post.id}`, + }); +} + +function like() { + os.apiWithDialog('gallery/posts/like', { + postId: props.postId, + }).then(() => { + post.isLiked = true; + post.likedCount++; + }); +} + +async function unlike() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('gallery/posts/unlike', { + postId: props.postId, + }).then(() => { + post.isLiked = false; + post.likedCount--; + }); +} + +function edit() { + router.push(`/gallery/${post.id}/edit`); +} + +watch(() => props.postId, fetchPost, { immediate: true }); + +const headerActions = $computed(() => [{ + icon: 'ti ti-pencil', + text: i18n.ts.edit, + handler: edit, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => post ? { + title: post.title, + avatar: post.user, +} : 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; +} + +.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/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue new file mode 100644 index 0000000000..a2a1254360 --- /dev/null +++ b/packages/frontend/src/pages/instance-info.vue @@ -0,0 +1,258 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32"> + <div v-if="tab === 'overview'" class="_formRoot"> + <div class="fnfelxur"> + <img :src="faviconUrl" alt="" class="icon"/> + <span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span> + </div> + <MkKeyValue :copy="host" oneline style="margin: 1em 0;"> + <template #key>Host</template> + <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${i18n.ts.unknown})` }} / {{ instance.softwareVersion || `(${i18n.ts.unknown})` }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value>{{ instance.maintainerName || `(${i18n.ts.unknown})` }} ({{ instance.maintainerEmail || `(${i18n.ts.unknown})` }})</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ instance.description }}</template> + </MkKeyValue> + + <FormSection v-if="iAmModerator"> + <template #label>Moderation</template> + <FormSwitch v-model="suspended" class="_formBlock" @update:model-value="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</FormSwitch> + <FormSwitch v-model="isBlocked" class="_formBlock" @update:model-value="toggleBlock">{{ i18n.ts.blockThisInstance }}</FormSwitch> + <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> + </FormSection> + + <FormSection> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.registeredAt }}</template> + <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.latestRequestSentAt }}</template> + <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.latestStatus }}</template> + <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.latestRequestReceivedAt }}</template> + <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> + </MkKeyValue> + </FormSection> + + <FormSection> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Following (Pub)</template> + <template #value>{{ number(instance.followingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Followers (Sub)</template> + <template #value>{{ number(instance.followersCount) }}</template> + </MkKeyValue> + </FormSection> + + <FormSection> + <template #label>Well-known resources</template> + <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink> + <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink> + <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink> + <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink> + <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> + </FormSection> + </div> + <div v-else-if="tab === 'chart'" class="_formRoot"> + <div class="cmhjzshl"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> + <option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option> + </MkSelect> + </div> + <div class="charts"> + <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> + <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> + </div> + </div> + </div> + <div v-else-if="tab === 'users'" class="_formRoot"> + <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </MkPagination> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView tall :value="instance"> + </MkObjectView> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import MkChart from '@/components/MkChart.vue'; +import MkObjectView from '@/components/MkObjectView.vue'; +import FormLink from '@/components/form/link.vue'; +import MkLink from '@/components/MkLink.vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkSelect from '@/components/form/select.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import { iAmModerator } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; + +const props = defineProps<{ + host: string; +}>(); + +let tab = $ref('overview'); +let chartSrc = $ref('instance-requests'); +let meta = $ref<misskey.entities.DetailedInstanceMetadata | null>(null); +let instance = $ref<misskey.entities.Instance | null>(null); +let suspended = $ref(false); +let isBlocked = $ref(false); +let faviconUrl = $ref(null); + +const usersPagination = { + endpoint: iAmModerator ? 'admin/show-users' : 'users' as const, + limit: 10, + params: { + sort: '+updatedAt', + state: 'all', + hostname: props.host, + }, + offsetMode: true, +}; + +async function fetch() { + instance = await os.api('federation/show-instance', { + host: props.host, + }); + suspended = instance.isSuspended; + isBlocked = instance.isBlocked; + faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview'); +} + +async function toggleBlock(ev) { + if (meta == null) return; + await os.api('admin/update-meta', { + blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host), + }); +} + +async function toggleSuspend(v) { + await os.api('admin/federation/update-instance', { + host: instance.host, + isSuspended: suspended, + }); +} + +function refreshMetadata() { + os.api('admin/federation/refresh-remote-instance-metadata', { + host: instance.host, + }); + os.alert({ + text: 'Refresh requested', + }); +} + +fetch(); + +const headerActions = $computed(() => [{ + text: `https://${props.host}`, + icon: 'ti ti-external-link', + handler: () => { + window.open(`https://${props.host}`, '_blank'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'ti ti-info-circle', +}, { + key: 'chart', + title: i18n.ts.charts, + icon: 'ti ti-chart-line', +}, { + key: 'users', + title: i18n.ts.users, + icon: 'ti ti-users', +}, { + key: 'raw', + title: 'Raw', + icon: 'ti ti-code', +}]); + +definePageMetadata({ + title: props.host, + icon: 'ti ti-server', +}); +</script> + +<style lang="scss" scoped> +.fnfelxur { + display: flex; + align-items: center; + + > .icon { + display: block; + margin: 0 16px 0 0; + height: 64px; + border-radius: 8px; + } + + > .name { + word-break: break-all; + } +} + +.cmhjzshl { + > .selects { + display: flex; + margin: 0 0 16px 0; + } + + > .charts { + > .label { + margin-bottom: 12px; + font-weight: bold; + } + } +} +</style> diff --git a/packages/frontend/src/pages/messaging/index.vue b/packages/frontend/src/pages/messaging/index.vue new file mode 100644 index 0000000000..0d30998330 --- /dev/null +++ b/packages/frontend/src/pages/messaging/index.vue @@ -0,0 +1,327 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-size="{ max: [400] }" class="yweeujhr"> + <MkButton primary class="start" @click="start"><i class="ti ti-plus"></i> {{ $ts.startMessaging }}</MkButton> + + <div v-if="messages.length > 0" class="history"> + <MkA + v-for="(message, i) in messages" + :key="message.id" + v-anim="i" + 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" + > + <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 v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p> + </div> + </div> + </MkA> + </div> + <div v-if="!fetching && messages.length == 0" class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noHistory }}</div> + </div> + <MkLoading v-if="fetching"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkButton from '@/components/MkButton.vue'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; + +const router = useRouter(); + +let fetching = $ref(true); +let moreFetching = $ref(false); +let messages = $ref([]); +let connection = $ref(null); + +const getAcct = Acct.toString; + +function isMe(message) { + return message.userId === $i.id; +} + +function onMessage(message) { + if (message.recipientId) { + messages = messages.filter(m => !( + (m.recipientId === message.recipientId && m.userId === message.userId) || + (m.recipientId === message.userId && m.userId === message.recipientId))); + + messages.unshift(message); + } else if (message.groupId) { + messages = messages.filter(m => m.groupId !== message.groupId); + messages.unshift(message); + } +} + +function onRead(ids) { + for (const id of ids) { + const found = messages.find(m => m.id === id); + if (found) { + if (found.recipientId) { + found.isRead = true; + } else if (found.groupId) { + found.reads.push($i.id); + } + } + } +} + +function start(ev) { + os.popupMenu([{ + text: i18n.ts.messagingWithUser, + icon: 'ti ti-user', + action: () => { startUser(); }, + }, { + text: i18n.ts.messagingWithGroup, + icon: 'ti ti-users', + action: () => { startGroup(); }, + }], ev.currentTarget ?? ev.target); +} + +async function startUser() { + os.selectUser().then(user => { + router.push(`/my/messaging/${Acct.toString(user)}`); + }); +} + +async function 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.alert({ + type: 'warning', + title: i18n.ts.youHaveNoGroups, + text: i18n.ts.joinOrCreateGroup, + }); + return; + } + const { canceled, result: group } = await os.select({ + title: i18n.ts.group, + items: groups1.concat(groups2).map(group => ({ + value: group, text: group.name, + })), + }); + if (canceled) return; + router.push(`/my/messaging/group/${group.id}`); +} + +onMounted(() => { + connection = markRaw(stream.useChannel('messagingIndex')); + + connection.on('message', onMessage); + connection.on('read', 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()); + messages = _messages; + fetching = false; + }); + }); +}); + +onUnmounted(() => { + if (connection) connection.dispose(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.messaging, + icon: 'ti ti-messages', +}); +</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; + } + } + } + } + } +} + +@container (max-width: 400px) { + .yweeujhr { + > .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/frontend/src/pages/messaging/messaging-room.form.vue b/packages/frontend/src/pages/messaging/messaging-room.form.vue new file mode 100644 index 0000000000..84572815c0 --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.form.vue @@ -0,0 +1,364 @@ +<template> +<div + class="pemppnzi _block" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + ref="textEl" + v-model="text" + :placeholder="i18n.ts.inputMessageHere" + @keydown="onKeydown" + @compositionupdate="onCompositionUpdate" + @paste="onPaste" + ></textarea> + <footer> + <div v-if="file" class="file" @click="file = null">{{ file.name }}</div> + <div class="buttons"> + <button class="_button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button> + <button class="_button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + <button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> + <template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template> + </button> + </div> + </footer> + <input ref="fileEl" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import autosize from 'autosize'; +//import insertTextAtCursor from 'insert-text-at-cursor'; +import { throttle } from 'throttle-debounce'; +import { formatTimeString } from '@/scripts/format-time-string'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +//import { Autocomplete } from '@/scripts/autocomplete'; +import { uploadFile } from '@/scripts/upload'; + +const props = defineProps<{ + user?: Misskey.entities.UserDetailed | null; + group?: Misskey.entities.UserGroup | null; +}>(); + +let textEl = $ref<HTMLTextAreaElement>(); +let fileEl = $ref<HTMLInputElement>(); + +let text = $ref<string>(''); +let file = $ref<Misskey.entities.DriveFile | null>(null); +let sending = $ref(false); +const typing = throttle(3000, () => { + stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id }); +}); + +let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id); +let canSend = $computed(() => (text != null && text !== '') || file != null); + +watch([$$(text), $$(file)], saveDraft); + +async function onPaste(ev: ClipboardEvent) { + if (!ev.clipboardData) return; + + const clipboardData = ev.clipboardData; + const items = clipboardData.items; + + if (items.length === 1) { + if (items[0].kind === 'file') { + const pastedFile = items[0].getAsFile(); + if (!pastedFile) return; + const lio = pastedFile.name.lastIndexOf('.'); + const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; + const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext; + if (formatted) upload(pastedFile, formatted); + } + } else { + if (items[0].kind === 'file') { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + } + } +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + ev.preventDefault(); + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } + } +} + +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ใใกใคใซใ ใฃใใ + if (ev.dataTransfer.files.length === 1) { + ev.preventDefault(); + upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + ev.preventDefault(); + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ใใฉใคใใฎใใกใคใซ + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + file = JSON.parse(driveFile); + ev.preventDefault(); + } + //#endregion +} + +function onKeydown(ev: KeyboardEvent) { + typing(); + if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) { + send(); + } +} + +function onCompositionUpdate() { + typing(); +} + +function chooseFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => { + file = selectedFile; + }); +} + +function onChangeFile() { + if (fileEl.files![0]) upload(fileEl.files[0]); +} + +function upload(fileToUpload: File, name?: string) { + uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => { + file = res; + }); +} + +function send() { + sending = true; + os.api('messaging/messages/create', { + userId: props.user ? props.user.id : undefined, + groupId: props.group ? props.group.id : undefined, + text: text ? text : undefined, + fileId: file ? file.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending = false; + }); +} + +function clear() { + text = ''; + file = null; + deleteDraft(); +} + +function saveDraft() { + const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + drafts[draftKey] = { + updatedAt: new Date(), + // eslint-disable-next-line id-denylist + data: { + text: text, + file: file, + }, + }; + + localStorage.setItem('message_drafts', JSON.stringify(drafts)); +} + +function deleteDraft() { + const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + delete drafts[draftKey]; + + localStorage.setItem('message_drafts', JSON.stringify(drafts)); +} + +async function insertEmoji(ev: MouseEvent) { + os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl); +} + +onMounted(() => { + autosize(textEl); + + // TODO: detach when unmount + // TODO + //new Autocomplete(textEl, this, { model: 'text' }); + + // ๆธใใใใฎๆ็จฟใๅพฉๅ
+ const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey]; + if (draft) { + text = draft.data.text; + file = draft.data.file; + } +}); + +defineExpose({ + file, + upload, +}); +</script> + +<style lang="scss" scoped> +.pemppnzi { + position: relative; + + > textarea { + cursor: auto; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-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); + } + + footer { + position: sticky; + bottom: 0; + background: var(--panel); + + > .file { + padding: 8px; + color: var(--fg); + background: transparent; + cursor: pointer; + } + } + + .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; + } + } + } + + .buttons { + display: flex; + + ._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; + } + } + + > .send { + margin-left: auto; + color: var(--accent); + + &:hover { + color: var(--accentLighten); + } + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } + } + } + + input[type=file] { + display: none; + } +} +</style> diff --git a/packages/frontend/src/pages/messaging/messaging-room.message.vue b/packages/frontend/src/pages/messaging/messaging-room.message.vue new file mode 100644 index 0000000000..dbf0e37b73 --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.message.vue @@ -0,0 +1,367 @@ +<template> +<div v-size="{ max: [400, 500] }" class="thvuemwp" :class="{ isMe }"> + <MkAvatar class="avatar" :user="message.user" :show-indicator="true"/> + <div class="content"> + <div class="balloon" :class="{ noText: message.text == null }"> + <button v-if="isMe" class="delete-button" :title="$ts.delete" @click="del"> + <img src="/client-assets/remove.png" alt="Delete"/> + </button> + <div v-if="!message.isDeleted" class="content"> + <Mfm v-if="message.text" ref="text" class="text" :text="message.text" :i="$i"/> + <div v-if="message.file" class="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 v-else class="content"> + <p class="is-deleted">{{ $ts.deleted }}</p> + </div> + </div> + <div></div> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/> + <footer> + <template v-if="isGroup"> + <span v-if="message.reads.length > 0" class="read">{{ $ts.messageRead }} {{ message.reads.length }}</span> + </template> + <template v-else> + <span v-if="isMe && message.isRead" class="read">{{ $ts.messageRead }}</span> + </template> + <MkTime :time="message.createdAt"/> + <template v-if="message.is_edited"><i class="ti ti-pencil"></i></template> + </footer> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; + +const props = defineProps<{ + message: Misskey.entities.MessagingMessage; + isGroup?: boolean; +}>(); + +const isMe = $computed(() => props.message.userId === $i?.id); +const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); + +function del(): void { + os.api('messaging/messages/delete', { + messageId: props.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); + right: var(--margin); // ๅ้คๆใซposition: absoluteใซใชใฃใใจใใซไฝฟใ + + > .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; + } + } + } + } + } +} + +@container (max-width: 400px) { + .thvuemwp { + > .avatar { + width: 48px; + height: 48px; + } + + > .content { + > .balloon { + > .content { + > .text { + font-size: 0.9em; + } + } + } + } + } +} + +@container (max-width: 500px) { + .thvuemwp { + > .content { + > .balloon { + > .content { + > .text { + padding: 8px 16px; + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/messaging/messaging-room.vue b/packages/frontend/src/pages/messaging/messaging-room.vue new file mode 100644 index 0000000000..b6eeb9260e --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.vue @@ -0,0 +1,411 @@ +<template> +<div + ref="rootEl" + class="_section" + @dragover.prevent.stop="onDragover" + @drop.prevent.stop="onDrop" +> + <div class="_content mk-messaging-room"> + <div class="body"> + <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noMessagesYet }}</div> + </div> + </template> + + <template #default="{ items: messages, fetching: pFetching }"> + <XList + v-if="messages.length > 0" + v-slot="{ item: message }" + :class="{ messages: true, 'deny-move-transition': pFetching }" + :items="messages" + direction="up" + reversed + > + <XMessage :key="message.id" :message="message" :is-group="group != null"/> + </XList> + </template> + </MkPagination> + </div> + <footer> + <div v-if="typers.length > 0" class="typers"> + <I18n :src="i18n.ts.typingUsers" text-tag="span" class="users"> + <template #users> + <b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b> + </template> + </I18n> + <MkEllipsis/> + </div> + <transition :name="animation ? 'fade' : ''"> + <div v-show="showIndicator" class="new-message"> + <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas ti-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button> + </div> + </transition> + <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> + </footer> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; +import XMessage from './messaging-room.message.vue'; +import XForm from './messaging-room.form.vue'; +import XList from '@/components/MkDateSeparatedList.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import * as sound from '@/scripts/sound'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + userAcct?: string; + groupId?: string; +}>(); + +let rootEl = $ref<HTMLDivElement>(); +let formEl = $ref<InstanceType<typeof XForm>>(); +let pagingComponent = $ref<InstanceType<typeof MkPagination>>(); + +let fetching = $ref(true); +let user: Misskey.entities.UserDetailed | null = $ref(null); +let group: Misskey.entities.UserGroup | null = $ref(null); +let typers: Misskey.entities.User[] = $ref([]); +let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null); +let showIndicator = $ref(false); +const { + animation, +} = defaultStore.reactiveState; + +let pagination: Paging | null = $ref(null); + +watch([() => props.userAcct, () => props.groupId], () => { + if (connection) connection.dispose(); + fetch(); +}); + +async function fetch() { + fetching = true; + + if (props.userAcct) { + const acct = Acct.parse(props.userAcct); + user = await os.api('users/show', { username: acct.username, host: acct.host || undefined }); + group = null; + + pagination = { + endpoint: 'messaging/messages', + limit: 20, + params: { + userId: user.id, + }, + reversed: true, + pageEl: $$(rootEl).value, + }; + connection = stream.useChannel('messaging', { + otherparty: user.id, + }); + } else { + user = null; + group = await os.api('users/groups/show', { groupId: props.groupId }); + + pagination = { + endpoint: 'messaging/messages', + limit: 20, + params: { + groupId: group?.id, + }, + reversed: true, + pageEl: $$(rootEl).value, + }; + connection = stream.useChannel('messaging', { + group: group?.id, + }); + } + + connection.on('message', onMessage); + connection.on('read', onRead); + connection.on('deleted', onDeleted); + connection.on('typers', _typers => { + typers = _typers.filter(u => u.id !== $i?.id); + }); + + document.addEventListener('visibilitychange', onVisibilitychange); + + nextTick(() => { + thisScrollToBottom(); + window.setTimeout(() => { + fetching = false; + }, 300); + }); +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + + if (isFile || isDriveFile) { + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } + } else { + ev.dataTransfer.dropEffect = 'none'; + } +} + +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ใใกใคใซใ ใฃใใ + if (ev.dataTransfer.files.length === 1) { + formEl.upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ใใฉใคใใฎใใกใคใซ + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + const file = JSON.parse(driveFile); + formEl.file = file; + } + //#endregion +} + +function onMessage(message) { + sound.play('chat'); + + const _isBottom = isBottomVisible(rootEl, 64); + + pagingComponent.prepend(message); + if (message.userId !== $i?.id && !document.hidden) { + connection?.send('read', { + id: message.id, + }); + } + + if (_isBottom) { + // Scroll to bottom + nextTick(() => { + thisScrollToBottom(); + }); + } else if (message.userId !== $i?.id) { + // Notify + notifyNewMessage(); + } +} + +function onRead(x) { + if (user) { + if (!Array.isArray(x)) x = [x]; + for (const id of x) { + if (pagingComponent.items.some(y => y.id === id)) { + const exist = pagingComponent.items.map(y => y.id).indexOf(id); + pagingComponent.items[exist] = { + ...pagingComponent.items[exist], + isRead: true, + }; + } + } + } else if (group) { + for (const id of x.ids) { + if (pagingComponent.items.some(y => y.id === id)) { + const exist = pagingComponent.items.map(y => y.id).indexOf(id); + pagingComponent.items[exist] = { + ...pagingComponent.items[exist], + reads: [...pagingComponent.items[exist].reads, x.userId], + }; + } + } + } +} + +function onDeleted(id) { + const msg = pagingComponent.items.find(m => m.id === id); + if (msg) { + pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id); + } +} + +function thisScrollToBottom() { + scrollToBottom($$(rootEl).value, { behavior: 'smooth' }); +} + +function onIndicatorClick() { + showIndicator = false; + thisScrollToBottom(); +} + +let scrollRemove: (() => void) | null = $ref(null); + +function notifyNewMessage() { + showIndicator = true; + + scrollRemove = onScrollBottom(rootEl, () => { + showIndicator = false; + scrollRemove = null; + }); +} + +function onVisibilitychange() { + if (document.hidden) return; + for (const message of pagingComponent.items) { + if (message.userId !== $i?.id && !message.isRead) { + connection?.send('read', { + id: message.id, + }); + } + } +} + +onMounted(() => { + fetch(); +}); + +onBeforeUnmount(() => { + connection?.dispose(); + document.removeEventListener('visibilitychange', onVisibilitychange); + if (scrollRemove) scrollRemove(); +}); + +definePageMetadata(computed(() => !fetching ? user ? { + userName: user, + avatar: user, +} : { + title: group?.name, + icon: 'ti ti-users', +} : null)); +</script> + +<style lang="scss" scoped> +.mk-messaging-room { + position: relative; + overflow: auto; + + > .body { + .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 { + padding: 8px 0; + + > ::v-deep(*) { + margin-bottom: 16px; + } + } + } + + > footer { + width: 100%; + position: sticky; + z-index: 2; + bottom: 0; + padding-top: 8px; + bottom: calc(env(safe-area-inset-bottom, 0px) + 8px); + + > .new-message { + width: 100%; + padding-bottom: 8px; + text-align: center; + + > button { + display: inline-block; + margin: 0; + padding: 0 12px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; + + > i { + display: inline-block; + margin-right: 8px; + } + } + } + + > .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 { + max-height: 12em; + overflow-y: scroll; + 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/frontend/src/pages/mfm-cheat-sheet.vue b/packages/frontend/src/pages/mfm-cheat-sheet.vue new file mode 100644 index 0000000000..7c85dfb7ad --- /dev/null +++ b/packages/frontend/src/pages/mfm-cheat-sheet.vue @@ -0,0 +1,387 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="800"> + <div class="mwysmxbg"> + <div>{{ i18n.ts._mfm.intro }}</div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.mention }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.hashtag }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.url }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.link }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.emoji }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.bold }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.small }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.quote }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.center }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.inlineCode }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.blockCode }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.inlineMath }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.inlineMathDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineMath"/> + <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <!-- deprecated + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.search }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.flip }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.font }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.x2 }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.x3 }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.x4 }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.blur }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.jelly }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.tada }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.jump }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.bounce }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.spin }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.shake }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.twitch }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.rainbow }}</div> + <div class="content"> + <p>{{ i18n.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">{{ i18n.ts._mfm.sparkle }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.sparkleDescription }}</p> + <div class="preview"> + <Mfm :text="preview_sparkle"/> + <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.rotate }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.rotateDescription }}</p> + <div class="preview"> + <Mfm :text="preview_rotate"/> + <MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ i18n.ts._mfm.plain }}</div> + <div class="content"> + <p>{{ i18n.ts._mfm.plainDescription }}</p> + <div class="preview"> + <Mfm :text="preview_plain"/> + <MkTextarea v-model="preview_plain"><span>MFM</span></MkTextarea> + </div> + </div> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineComponent } from 'vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; + +let preview_mention = $ref('@example'); +let preview_hashtag = $ref('#test'); +let preview_url = $ref('https://example.com'); +let preview_link = $ref(`[${i18n.ts._mfm.dummy}](https://example.com)`); +let preview_emoji = $ref(instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:'); +let preview_bold = $ref(`**${i18n.ts._mfm.dummy}**`); +let preview_small = $ref(`<small>${i18n.ts._mfm.dummy}</small>`); +let preview_center = $ref(`<center>${i18n.ts._mfm.dummy}</center>`); +let preview_inlineCode = $ref('`<: "Hello, world!"`'); +let preview_blockCode = $ref('```\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```'); +let preview_inlineMath = $ref('\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)'); +let preview_quote = $ref(`> ${i18n.ts._mfm.dummy}`); +let preview_search = $ref(`${i18n.ts._mfm.dummy} ๆค็ดข`); +let preview_jelly = $ref('$[jelly ๐ฎ] $[jelly.speed=5s ๐ฎ]'); +let preview_tada = $ref('$[tada ๐ฎ] $[tada.speed=5s ๐ฎ]'); +let preview_jump = $ref('$[jump ๐ฎ] $[jump.speed=5s ๐ฎ]'); +let preview_bounce = $ref('$[bounce ๐ฎ] $[bounce.speed=5s ๐ฎ]'); +let preview_shake = $ref('$[shake ๐ฎ] $[shake.speed=5s ๐ฎ]'); +let preview_twitch = $ref('$[twitch ๐ฎ] $[twitch.speed=5s ๐ฎ]'); +let preview_spin = $ref('$[spin ๐ฎ] $[spin.left ๐ฎ] $[spin.alternate ๐ฎ]\n$[spin.x ๐ฎ] $[spin.x,left ๐ฎ] $[spin.x,alternate ๐ฎ]\n$[spin.y ๐ฎ] $[spin.y,left ๐ฎ] $[spin.y,alternate ๐ฎ]\n\n$[spin.speed=5s ๐ฎ]'); +let preview_flip = $ref(`$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`); +let preview_font = $ref(`$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`); +let preview_x2 = $ref('$[x2 ๐ฎ]'); +let preview_x3 = $ref('$[x3 ๐ฎ]'); +let preview_x4 = $ref('$[x4 ๐ฎ]'); +let preview_blur = $ref(`$[blur ${i18n.ts._mfm.dummy}]`); +let preview_rainbow = $ref('$[rainbow ๐ฎ] $[rainbow.speed=5s ๐ฎ]'); +let preview_sparkle = $ref('$[sparkle ๐ฎ]'); +let preview_rotate = $ref('$[rotate ๐ฎ]'); +let preview_plain = $ref('<plain>**bold** @mention #hashtag `code` $[x2 ๐ฎ]</plain>'); + +definePageMetadata({ + title: i18n.ts._mfm.cheatSheet, + icon: 'ti ti-question-circle', +}); +</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/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue new file mode 100644 index 0000000000..5de072cbfa --- /dev/null +++ b/packages/frontend/src/pages/miauth.vue @@ -0,0 +1,90 @@ +<template> +<MkSpacer :content-max="800"> + <div v-if="$i"> + <div v-if="state == 'waiting'" class="waiting _section"> + <div class="_content"> + <MkLoading/> + </div> + </div> + <div v-if="state == 'denied'" class="denied _section"> + <div class="_content"> + <p>{{ i18n.ts._auth.denied }}</p> + </div> + </div> + <div v-else-if="state == 'accepted'" class="accepted _section"> + <div class="_content"> + <p v-if="callback">{{ i18n.ts._auth.callback }}<MkEllipsis/></p> + <p v-else>{{ i18n.ts._auth.pleaseGoBack }}</p> + </div> + </div> + <div v-else class="_section"> + <div v-if="name" class="_title">{{ $t('_auth.shareAccess', { name: name }) }}</div> + <div v-else class="_title">{{ i18n.ts._auth.shareAccessAsk }}</div> + <div class="_content"> + <p>{{ i18n.ts._auth.permissionAsk }}</p> + <ul> + <li v-for="p in _permissions" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </div> + <div class="_footer"> + <MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton> + <MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton> + </div> + </div> + </div> + <div v-else class="signin"> + <MkSignin @login="onLogin"/> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkSignin from '@/components/MkSignin.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { $i, login } from '@/account'; +import { appendQuery, query } from '@/scripts/url'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + session: string; + callback?: string; + name: string; + icon: string; + permission: string; // ใณใณใๅบๅใ +}>(); + +const _permissions = props.permission.split(','); + +let state = $ref<string | null>(null); + +async function accept(): Promise<void> { + state = 'waiting'; + await os.api('miauth/gen-token', { + session: props.session, + name: props.name, + iconUrl: props.icon, + permission: _permissions, + }); + + state = 'accepted'; + if (props.callback) { + location.href = appendQuery(props.callback, query({ + session: props.session, + })); + } +} + +function deny(): void { + state = 'denied'; +} + +function onLogin(res): void { + login(res.i); +} +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue new file mode 100644 index 0000000000..005b036696 --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -0,0 +1,46 @@ +<template> +<div class="geegznzt"> + <XAntenna :antenna="draft" @created="onAntennaCreated"/> +</div> +</template> + +<script lang="ts" setup> +import { inject } from 'vue'; +import XAntenna from './editor.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { useRouter } from '@/router'; + +const router = useRouter(); + +let draft = $ref({ + name: '', + src: 'all', + userListId: null, + userGroupId: null, + users: [], + keywords: [], + excludeKeywords: [], + withReplies: false, + caseSensitive: false, + withFile: false, + notify: false, +}); + +function onAntennaCreated() { + router.push('/my/antennas'); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageAntennas, + icon: 'ti ti-antenna', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue new file mode 100644 index 0000000000..cb583faaeb --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -0,0 +1,43 @@ +<template> +<div class=""> + <XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/> +</div> +</template> + +<script lang="ts" setup> +import { inject, watch } from 'vue'; +import XAntenna from './editor.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const router = useRouter(); + +let antenna: any = $ref(null); + +const props = defineProps<{ + antennaId: string +}>(); + +function onAntennaUpdated() { + router.push('/my/antennas'); +} + +os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => { + antenna = antennaResponse; +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageAntennas, + icon: 'ti ti-antenna', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue new file mode 100644 index 0000000000..a409a734b5 --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/editor.vue @@ -0,0 +1,155 @@ +<template> +<div class="shaynizk"> + <div class="form"> + <MkInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + <MkSelect v-model="src" class="_formBlock"> + <template #label>{{ i18n.ts.antennaSource }}</template> + <option value="all">{{ i18n.ts._antennaSources.all }}</option> + <!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>--> + <option value="users">{{ i18n.ts._antennaSources.users }}</option> + <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>--> + <!--<option value="group">{{ i18n.ts._antennaSources.userGroup }}</option>--> + </MkSelect> + <MkSelect v-if="src === 'list'" v-model="userListId" class="_formBlock"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option> + </MkSelect> + <MkSelect v-else-if="src === 'group'" v-model="userGroupId" class="_formBlock"> + <template #label>{{ i18n.ts.userGroup }}</template> + <option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option> + </MkSelect> + <MkTextarea v-else-if="src === 'users'" v-model="users" class="_formBlock"> + <template #label>{{ i18n.ts.users }}</template> + <template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template> + </MkTextarea> + <MkSwitch v-model="withReplies" class="_formBlock">{{ i18n.ts.withReplies }}</MkSwitch> + <MkTextarea v-model="keywords" class="_formBlock"> + <template #label>{{ i18n.ts.antennaKeywords }}</template> + <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkTextarea v-model="excludeKeywords" class="_formBlock"> + <template #label>{{ i18n.ts.antennaExcludeKeywords }}</template> + <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkSwitch v-model="caseSensitive" class="_formBlock">{{ i18n.ts.caseSensitive }}</MkSwitch> + <MkSwitch v-model="withFile" class="_formBlock">{{ i18n.ts.withFileAntenna }}</MkSwitch> + <MkSwitch v-model="notify" class="_formBlock">{{ i18n.ts.notifyAntenna }}</MkSwitch> + </div> + <div class="actions"> + <MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkButton from '@/components/MkButton.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 os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + antenna: any +}>(); + +const emit = defineEmits<{ + (ev: 'created'): void, + (ev: 'updated'): void, + (ev: 'deleted'): void, +}>(); + +let name: string = $ref(props.antenna.name); +let src: string = $ref(props.antenna.src); +let userListId: any = $ref(props.antenna.userListId); +let userGroupId: any = $ref(props.antenna.userGroupId); +let users: string = $ref(props.antenna.users.join('\n')); +let keywords: string = $ref(props.antenna.keywords.map(x => x.join(' ')).join('\n')); +let excludeKeywords: string = $ref(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n')); +let caseSensitive: boolean = $ref(props.antenna.caseSensitive); +let withReplies: boolean = $ref(props.antenna.withReplies); +let withFile: boolean = $ref(props.antenna.withFile); +let notify: boolean = $ref(props.antenna.notify); +let userLists: any = $ref(null); +let userGroups: any = $ref(null); + +watch(() => src, async () => { + if (src === 'list' && userLists === null) { + userLists = await os.api('users/lists/list'); + } + + if (src === 'group' && userGroups === null) { + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); + + userGroups = [...groups1, ...groups2]; + } +}); + +async function saveAntenna() { + const antennaData = { + name, + src, + userListId, + userGroupId, + withReplies, + withFile, + notify, + caseSensitive, + users: users.trim().split('\n').map(x => x.trim()), + keywords: keywords.trim().split('\n').map(x => x.trim().split(' ')), + excludeKeywords: excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')), + }; + + if (props.antenna.id == null) { + await os.apiWithDialog('antennas/create', antennaData); + emit('created'); + } else { + antennaData['antennaId'] = props.antenna.id; + await os.apiWithDialog('antennas/update', antennaData); + emit('updated'); + } +} + +async function deleteAntenna() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: props.antenna.name }), + }); + if (canceled) return; + + await os.api('antennas/delete', { + antennaId: props.antenna.id, + }); + + os.success(); + emit('deleted'); +} + +function addUser() { + os.selectUser().then(user => { + users = users.trim(); + users += '\n@' + Acct.toString(user as any); + users = 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/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue new file mode 100644 index 0000000000..9daf23f9b5 --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -0,0 +1,64 @@ +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="ieepwinx"> + <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + + <div class=""> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`"> + <div class="name">{{ antenna.name }}</div> + </MkA> + </MkPagination> + </div> + </div> +</MkSpacer></MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'antennas/list' as const, + limit: 10, +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageAntennas, + icon: 'ti ti-antenna', +}); +</script> + +<style lang="scss" scoped> +.ieepwinx { + + > .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/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue new file mode 100644 index 0000000000..dd6b5b3a37 --- /dev/null +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -0,0 +1,100 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="qtcaoidl"> + <MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" 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> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'clips/list' as const, + limit: 10, +}; + +const pagingComponent = $ref<InstanceType<typeof MkPagination>>(); + +async function create() { + const { canceled, result } = await os.form(i18n.ts.createNewClip, { + name: { + type: 'string', + label: i18n.ts.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: false, + }, + }); + if (canceled) return; + + os.apiWithDialog('clips/create', result); + + pagingComponent.reload(); +} + +function onClipCreated() { + pagingComponent.reload(); +} + +function onClipDeleted() { + pagingComponent.reload(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.clip, + icon: 'ti ti-paperclip', + action: { + icon: 'ti ti-plus', + handler: create, + }, +}); +</script> + +<style lang="scss" scoped> +.qtcaoidl { + > .add { + margin: 0 auto 16px auto; + } + + > .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/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue new file mode 100644 index 0000000000..3476436b27 --- /dev/null +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -0,0 +1,82 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="qkcjvfiv"> + <MkButton primary class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton> + + <MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists _content"> + <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> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkAvatars from '@/components/MkAvatars.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagingComponent = $ref<InstanceType<typeof MkPagination>>(); + +const pagination = { + endpoint: 'users/lists/list' as const, + limit: 10, +}; + +async function create() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.enterListName, + }); + if (canceled) return; + await os.apiWithDialog('users/lists/create', { name: name }); + pagingComponent.reload(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageLists, + icon: 'ti ti-list', + action: { + icon: 'ti ti-plus', + handler: create, + }, +}); +</script> + +<style lang="scss" scoped> +.qkcjvfiv { + > .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/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue new file mode 100644 index 0000000000..f6234ffe44 --- /dev/null +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -0,0 +1,162 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="mk-list-page"> + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <div v-if="list" class="_section"> + <div class="_content"> + <MkButton inline @click="addUser()">{{ i18n.ts.addUser }}</MkButton> + <MkButton inline @click="renameList()">{{ i18n.ts.rename }}</MkButton> + <MkButton inline @click="deleteList()">{{ i18n.ts.delete }}</MkButton> + </div> + </div> + </transition> + + <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> + <div v-if="list" class="_section members _gap"> + <div class="_title">{{ i18n.ts.members }}</div> + <div class="_content"> + <div class="users"> + <div v-for="user in users" :key="user.id" class="user _panel"> + <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="ti ti-x"></i></button> + </div> + </div> + </div> + </div> + </div> + </transition> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + listId: string; +}>(); + +let list = $ref(null); +let users = $ref([]); + +function fetchList() { + os.api('users/lists/show', { + listId: props.listId, + }).then(_list => { + list = _list; + os.api('users/show', { + userIds: list.userIds, + }).then(_users => { + users = _users; + }); + }); +} + +function addUser() { + os.selectUser().then(user => { + os.apiWithDialog('users/lists/push', { + listId: list.id, + userId: user.id, + }).then(() => { + users.push(user); + }); + }); +} + +function removeUser(user) { + os.api('users/lists/pull', { + listId: list.id, + userId: user.id, + }).then(() => { + users = users.filter(x => x.id !== user.id); + }); +} + +async function renameList() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.enterListName, + default: list.name, + }); + if (canceled) return; + + await os.api('users/lists/update', { + listId: list.id, + name: name, + }); + + list.name = name; +} + +async function deleteList() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: list.name }), + }); + if (canceled) return; + + await os.api('users/lists/delete', { + listId: list.id, + }); + os.success(); + mainRouter.push('/my/lists'); +} + +watch(() => props.listId, fetchList, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => list ? { + title: list.name, + icon: 'ti ti-list', +} : null)); +</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/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue new file mode 100644 index 0000000000..e58e44ef79 --- /dev/null +++ b/packages/frontend/src/pages/not-found.vue @@ -0,0 +1,22 @@ +<template> +<div class="ipledcug"> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/> + <div>{{ i18n.ts.notFoundDescription }}</div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.notFound, + icon: 'ti ti-alert-triangle', +}); +</script> diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue new file mode 100644 index 0000000000..ba2bb91239 --- /dev/null +++ b/packages/frontend/src/pages/note.vue @@ -0,0 +1,206 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div class="fcuexfpr"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="note" class="note"> + <div v-if="showNext" class="_gap"> + <XNotes class="_content" :pagination="nextPagination" :no-gap="true"/> + </div> + + <div class="main _gap"> + <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton> + <div class="note _gap"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> + <XNoteDetailed :key="note.id" v-model:note="note" class="note"/> + </div> + <div v-if="clips && clips.length > 0" class="_content clips _gap"> + <div class="title">{{ i18n.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="ti ti-chevron-down"></i></MkButton> + </div> + + <div v-if="showPrev" class="_gap"> + <XNotes class="_content" :pagination="prevPagination" :no-gap="true"/> + </div> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, defineComponent, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import XNote from '@/components/MkNote.vue'; +import XNoteDetailed from '@/components/MkNoteDetailed.vue'; +import XNotes from '@/components/MkNotes.vue'; +import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + noteId: string; +}>(); + +let note = $ref<null | misskey.entities.Note>(); +let clips = $ref(); +let hasPrev = $ref(false); +let hasNext = $ref(false); +let showPrev = $ref(false); +let showNext = $ref(false); +let error = $ref(); + +const prevPagination = { + endpoint: 'users/notes' as const, + limit: 10, + params: computed(() => note ? ({ + userId: note.userId, + untilId: note.id, + }) : null), +}; + +const nextPagination = { + reversed: true, + endpoint: 'users/notes' as const, + limit: 10, + params: computed(() => note ? ({ + userId: note.userId, + sinceId: note.id, + }) : null), +}; + +function fetchNote() { + hasPrev = false; + hasNext = false; + showPrev = false; + showNext = false; + note = null; + os.api('notes/show', { + noteId: props.noteId, + }).then(res => { + note = res; + 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]) => { + clips = _clips; + hasPrev = prev.length !== 0; + hasNext = next.length !== 0; + }); + }).catch(err => { + error = err; + }); +} + +watch(() => props.noteId, fetchNote, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => note ? { + title: i18n.ts.note, + subtitle: new Date(note.createdAt).toLocaleString(), + avatar: note.user, + path: `/notes/${note.id}`, + share: { + title: i18n.t('noteOf', { user: note.user.name }), + text: note.text, + }, +} : 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; +} + +.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/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue new file mode 100644 index 0000000000..7106951de2 --- /dev/null +++ b/packages/frontend/src/pages/notifications.vue @@ -0,0 +1,95 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-if="tab === 'all' || tab === 'unread'"> + <XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/> + </div> + <div v-else-if="tab === 'mentions'"> + <XNotes :pagination="mentionsPagination"/> + </div> + <div v-else-if="tab === 'directNotes'"> + <XNotes :pagination="directNotesPagination"/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import XNotifications from '@/components/MkNotifications.vue'; +import XNotes from '@/components/MkNotes.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let tab = $ref('all'); +let includeTypes = $ref<string[] | null>(null); +let unreadOnly = $computed(() => tab === 'unread'); + +const mentionsPagination = { + endpoint: 'notes/mentions' as const, + limit: 10, +}; + +const directNotesPagination = { + endpoint: 'notes/mentions' as const, + limit: 10, + params: { + visibility: 'specified', + }, +}; + +function setFilter(ev) { + const typeItems = notificationTypes.map(t => ({ + text: i18n.t(`_notification._types.${t}`), + active: includeTypes && includeTypes.includes(t), + action: () => { + includeTypes = [t]; + }, + })); + const items = includeTypes != null ? [{ + icon: 'ti ti-x', + text: i18n.ts.clear, + action: () => { + includeTypes = null; + }, + }, null, ...typeItems] : typeItems; + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +const headerActions = $computed(() => [tab === 'all' ? { + text: i18n.ts.filter, + icon: 'ti ti-filter', + highlighted: includeTypes != null, + handler: setFilter, +} : undefined, tab === 'all' ? { + text: i18n.ts.markAllAsRead, + icon: 'ti ti-check', + handler: () => { + os.apiWithDialog('notifications/mark-all-as-read'); + }, +} : undefined].filter(x => x !== undefined)); + +const headerTabs = $computed(() => [{ + key: 'all', + title: i18n.ts.all, +}, { + key: 'unread', + title: i18n.ts.unread, +}, { + key: 'mentions', + title: i18n.ts.mentions, + icon: 'ti ti-at', +}, { + key: 'directNotes', + title: i18n.ts.directNotes, + icon: 'ti ti-mail', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.notifications, + icon: 'ti ti-bell', +}))); +</script> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue new file mode 100644 index 0000000000..a84cb1e80e --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -0,0 +1,63 @@ +<template> +<!-- eslint-disable vue/no-mutating-props --> +<XContainer :draggable="true" @remove="() => $emit('remove')"> + <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 v-if="file" class="preview" :file="file" fit="contain" @click="choose()"/> + </section> +</XContainer> +</template> + +<script lang="ts" setup> +/* eslint-disable vue/no-mutating-props */ +import { onMounted } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import * as os from '@/os'; + +const props = defineProps<{ + modelValue: any +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any): void; +}>(); + +let file: any = $ref(null); + +async function choose() { + os.selectDriveFile(false).then((fileResponse: any) => { + file = fileResponse; + emit('update:modelValue', { + ...props.modelValue, + fileId: fileResponse.id, + }); + }); +} + +onMounted(async () => { + if (props.modelValue.fileId == null) { + await choose(); + } else { + os.api('drive/files/show', { + fileId: props.modelValue.fileId, + }).then(fileResponse => { + file = fileResponse; + }); + } +}); +</script> + +<style lang="scss" scoped> +.oyyftmcf { + > .preview { + height: 150px; + } +} +</style> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue new file mode 100644 index 0000000000..dc2a620c09 --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -0,0 +1,57 @@ +<template> +<!-- eslint-disable vue/no-mutating-props --> +<XContainer :draggable="true" @remove="() => $emit('remove')"> + <template #header><i class="ti ti-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="props.modelValue.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch> + + <XNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/> + <XNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/> + </section> +</XContainer> +</template> + +<script lang="ts" setup> +/* eslint-disable vue/no-mutating-props */ +import { watch } 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/MkNote.vue'; +import XNoteDetailed from '@/components/MkNoteDetailed.vue'; +import * as os from '@/os'; + +const props = defineProps<{ + modelValue: any +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any): void; +}>(); + +let id: any = $ref(props.modelValue.note); +let note: any = $ref(null); + +watch(id, async () => { + if (id && (id.startsWith('http://') || id.startsWith('https://'))) { + emit('update:modelValue', { + ...props.modelValue, + note: (id.endsWith('/') ? id.slice(0, -1) : id).split('/').pop(), + }); + } else { + emit('update:modelValue', { + ...props.modelValue, + note: id, + }); + } + + note = await os.api('notes/show', { noteId: props.modelValue.note }); +}, { + immediate: true, +}); +</script> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue new file mode 100644 index 0000000000..27324bdaef --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue @@ -0,0 +1,97 @@ +<template> +<!-- eslint-disable vue/no-mutating-props --> +<XContainer :draggable="true" @remove="() => $emit('remove')"> + <template #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template> + <template #func> + <button class="_button" @click="rename()"> + <i class="ti ti-pencil"></i> + </button> + </template> + + <section class="ilrvjyvi"> + <XBlocks v-model="children" class="children"/> + <MkButton rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton> + </section> +</XContainer> +</template> + +<script lang="ts" setup> +/* eslint-disable vue/no-mutating-props */ +import { defineAsyncComponent, inject, onMounted, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { deepClone } from '@/scripts/clone'; +import MkButton from '@/components/MkButton.vue'; + +const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue')); + +const props = withDefaults(defineProps<{ + modelValue: any, +}>(), { + modelValue: {}, +}); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any): void; +}>(); + +const children = $ref(deepClone(props.modelValue.children ?? [])); + +watch($$(children), () => { + emit('update:modelValue', { + ...props.modelValue, + children, + }); +}, { + deep: true, +}); + +const getPageBlockList = inject<(any) => any>('getPageBlockList'); + +async function rename() { + const { canceled, result: title } = await os.inputText({ + title: 'Enter title', + default: props.modelValue.title, + }); + if (canceled) return; + emit('update:modelValue', { + ...props.modelValue, + title, + }); +} + +async function add() { + const { canceled, result: type } = await os.select({ + title: i18n.ts._pages.chooseBlock, + items: getPageBlockList(), + }); + if (canceled) return; + + const id = uuid(); + children.push({ id, type }); +} + +onMounted(() => { + if (props.modelValue.title == null) { + rename(); + } +}); +</script> + +<style lang="scss" scoped> +.ilrvjyvi { + > .children { + margin: 16px; + + &:empty { + display: none; + } + } + + > .add { + margin: 16px auto; + } +} +</style> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue new file mode 100644 index 0000000000..6f11e2a08b --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -0,0 +1,54 @@ +<template> +<!-- eslint-disable vue/no-mutating-props --> +<XContainer :draggable="true" @remove="() => $emit('remove')"> + <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.text }}</template> + + <section class="vckmsadr"> + <textarea v-model="text"></textarea> + </section> +</XContainer> +</template> + +<script lang="ts" setup> +/* eslint-disable vue/no-mutating-props */ +import { watch } from 'vue'; +import XContainer from '../page-editor.container.vue'; + +const props = defineProps<{ + modelValue: any +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: any): void; +}>(); + +const text = $ref(props.modelValue.text ?? ''); + +watch($$(text), () => { + emit('update:modelValue', { + ...props.modelValue, + 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/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue new file mode 100644 index 0000000000..f99fcb202f --- /dev/null +++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue @@ -0,0 +1,65 @@ +<template> +<Sortable :model-value="modelValue" tag="div" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swap-threshold="0.5" @update:model-value="v => $emit('update:modelValue', v)"> + <template #item="{element}"> + <div :class="$style.item"> + <!-- divใ็กใใจใจใฉใผใซใชใ https://github.com/SortableJS/vue.draggable.next/issues/189 --> + <component :is="'x-' + element.type" :model-value="element" @update:model-value="updateItem" @remove="() => removeItem(element)"/> + </div> + </template> +</Sortable> +</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 XImage from './els/page-editor.el.image.vue'; +import XNote from './els/page-editor.el.note.vue'; +import * as os from '@/os'; +import { deepClone } from '@/scripts/clone'; + +export default defineComponent({ + components: { + Sortable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + XSection, XText, XImage, XNote, + }, + + props: { + modelValue: { + type: Array, + required: true, + }, + }, + + emits: ['update:modelValue'], + + methods: { + updateItem(v) { + const i = this.modelValue.findIndex(x => x.id === v.id); + const newValue = [ + ...this.modelValue.slice(0, i), + v, + ...this.modelValue.slice(i + 1), + ]; + this.$emit('update:modelValue', newValue); + }, + + removeItem(el) { + const i = this.modelValue.findIndex(x => x.id === el.id); + const newValue = [ + ...this.modelValue.slice(0, i), + ...this.modelValue.slice(i + 1), + ]; + this.$emit('update:modelValue', newValue); + }, + }, +}); +</script> + +<style lang="scss" module> +.item { + & + .item { + margin-top: 16px; + } +} +</style> diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue new file mode 100644 index 0000000000..15cdda5efb --- /dev/null +++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue @@ -0,0 +1,155 @@ +<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" class="_button" @click="remove()"> + <i class="ti ti-trash"></i> + </button> + <button v-if="draggable" class="drag-handle _button"> + <i class="ti ti-menu-2"></i> + </button> + <button class="_button" @click="toggleContent(!showBody)"> + <template v-if="showBody"><i class="ti ti-chevron-up"></i></template> + <template v-else><i class="ti ti-chevron-down"></i></template> + </button> + </div> + </header> + <p v-show="showBody" v-if="error != null" class="error">{{ $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" v-if="warn != null" class="warn">{{ $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: 8px; + + &:hover { + border: solid 2px var(--X13); + } + + &.warn { + border: solid 2px #dec44c; + } + + &.error { + border: solid 2px #f00; + } + + > 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/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue new file mode 100644 index 0000000000..968aa12de2 --- /dev/null +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -0,0 +1,394 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="jqqmcavi"> + <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ $ts._pages.viewPage }}</MkButton> + <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ $ts.save }}</MkButton> + <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ $ts.duplicate }}</MkButton> + <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ $ts.delete }}</MkButton> + </div> + + <div v-if="tab === 'settings'"> + <div 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="ti ti-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton> + <div v-else-if="eyeCatchingImage"> + <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/> + <MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="ti ti-trash"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton> + </div> + </div> + </div> + </div> + + <div v-else-if="tab === 'contents'"> + <div :class="$style.contents"> + <XBlocks v-model="content" class="content"/> + + <MkButton v-if="!readonly" rounded class="add" @click="add()"><i class="ti ti-plus"></i></MkButton> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, provide, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XBlocks from './page-editor.blocks.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkInput from '@/components/form/input.vue'; +import { url } from '@/config'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { mainRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; + +const props = defineProps<{ + initPageId?: string; + initPageName?: string; + initUser?: string; +}>(); + +let tab = $ref('settings'); +let author = $ref($i); +let readonly = $ref(false); +let page = $ref(null); +let pageId = $ref(null); +let currentName = $ref(null); +let title = $ref(''); +let summary = $ref(null); +let name = $ref(Date.now().toString()); +let eyeCatchingImage = $ref(null); +let eyeCatchingImageId = $ref(null); +let font = $ref('sans-serif'); +let content = $ref([]); +let alignCenter = $ref(false); +let hideTitleWhenPinned = $ref(false); + +provide('readonly', readonly); +provide('getPageBlockList', getPageBlockList); + +watch($$(eyeCatchingImageId), async () => { + if (eyeCatchingImageId == null) { + eyeCatchingImage = null; + } else { + eyeCatchingImage = await os.api('drive/files/show', { + fileId: eyeCatchingImageId, + }); + } +}); + +function getSaveOptions() { + return { + title: title.trim(), + name: name.trim(), + summary: summary, + font: font, + script: '', + hideTitleWhenPinned: hideTitleWhenPinned, + alignCenter: alignCenter, + content: content, + variables: [], + eyeCatchingImageId: eyeCatchingImageId, + }; +} + +function save() { + const options = getSaveOptions(); + + const onError = err => { + if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') { + if (err.info.param === 'name') { + os.alert({ + type: 'error', + title: i18n.ts._pages.invalidNameTitle, + text: i18n.ts._pages.invalidNameText, + }); + } + } else if (err.code === 'NAME_ALREADY_EXISTS') { + os.alert({ + type: 'error', + text: i18n.ts._pages.nameAlreadyExists, + }); + } + }; + + if (pageId) { + options.pageId = pageId; + os.api('pages/update', options) + .then(page => { + currentName = name.trim(); + os.alert({ + type: 'success', + text: i18n.ts._pages.updated, + }); + }).catch(onError); + } else { + os.api('pages/create', options) + .then(created => { + pageId = created.id; + currentName = name.trim(); + os.alert({ + type: 'success', + text: i18n.ts._pages.created, + }); + mainRouter.push(`/pages/edit/${pageId}`); + }).catch(onError); + } +} + +function del() { + os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: title.trim() }), + }).then(({ canceled }) => { + if (canceled) return; + os.api('pages/delete', { + pageId: pageId, + }).then(() => { + os.alert({ + type: 'success', + text: i18n.ts._pages.deleted, + }); + mainRouter.push('/pages'); + }); + }); +} + +function duplicate() { + title = title + ' - copy'; + name = name + '-copy'; + os.api('pages/create', getSaveOptions()).then(created => { + pageId = created.id; + currentName = name.trim(); + os.alert({ + type: 'success', + text: i18n.ts._pages.created, + }); + mainRouter.push(`/pages/edit/${pageId}`); + }); +} + +async function add() { + const { canceled, result: type } = await os.select({ + type: null, + title: i18n.ts._pages.chooseBlock, + items: getPageBlockList(), + }); + if (canceled) return; + + const id = uuid(); + content.push({ id, type }); +} + +function getPageBlockList() { + return [ + { value: 'section', text: i18n.ts._pages.blocks.section }, + { value: 'text', text: i18n.ts._pages.blocks.text }, + { value: 'image', text: i18n.ts._pages.blocks.image }, + { value: 'note', text: i18n.ts._pages.blocks.note }, + ]; +} + +function setEyeCatchingImage(img) { + selectFile(img.currentTarget ?? img.target, null).then(file => { + eyeCatchingImageId = file.id; + }); +} + +function removeEyeCatchingImage() { + eyeCatchingImageId = null; +} + +async function init() { + if (props.initPageId) { + page = await os.api('pages/show', { + pageId: props.initPageId, + }); + } else if (props.initPageName && props.initUser) { + page = await os.api('pages/show', { + name: props.initPageName, + username: props.initUser, + }); + readonly = true; + } + + if (page) { + author = page.user; + pageId = page.id; + title = page.title; + name = page.name; + currentName = page.name; + summary = page.summary; + font = page.font; + hideTitleWhenPinned = page.hideTitleWhenPinned; + alignCenter = page.alignCenter; + content = page.content; + eyeCatchingImageId = page.eyeCatchingImageId; + } else { + const id = uuid(); + content = [{ + id, + type: 'text', + text: 'Hello World!', + }]; + } +} + +init(); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'settings', + title: i18n.ts._pages.pageSetting, + icon: 'ti ti-settings', +}, { + key: 'contents', + title: i18n.ts._pages.contents, + icon: 'ti ti-note', +}]); + +definePageMetadata(computed(() => { + let title = i18n.ts._pages.newPage; + if (props.initPageId) { + title = i18n.ts._pages.editPage; + } + else if (props.initPageName && props.initUser) { + title = i18n.ts._pages.readPage; + } + return { + title: title, + icon: 'ti ti-pencil', + }; +})); +</script> + +<style lang="scss" module> +.contents { + &:global { + > .add { + margin: 16px auto 0 auto; + } + } +} +</style> + +<style lang="scss" scoped> +.jqqmcavi { + margin-bottom: 16px; + + > .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/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue new file mode 100644 index 0000000000..a95bfe485c --- /dev/null +++ b/packages/frontend/src/pages/page.vue @@ -0,0 +1,277 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="page" :key="page.id" class="xcukqgmh"> + <div class="_block main"> + <!-- + <div class="header"> + <h1>{{ page.title }}</h1> + </div> + --> + <div class="banner"> + <img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/> + </div> + <div class="content"> + <XPage :page="page"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> + <button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-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">{{ i18n.ts._pages.viewSource }}</MkA> + <template v-if="$i && $i.id === page.userId"> + <MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA> + <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button> + <button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button> + </template> + </div> + </div> + <div class="footer"> + <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> + <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock"></i> {{ i18n.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="ti ti-clock"></i> {{ i18n.ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/> + </MkPagination> + </MkContainer> + </div> + <MkError v-else-if="error" @retry="fetchPage()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import XPage from '@/components/page/page.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { url } from '@/config'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkPagePreview from '@/components/MkPagePreview.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + pageName: string; + username: string; +}>(); + +let page = $ref(null); +let error = $ref(null); +const otherPostsPagination = { + endpoint: 'users/pages' as const, + limit: 6, + params: computed(() => ({ + userId: page.user.id, + })), +}; +const path = $computed(() => props.username + '/' + props.pageName); + +function fetchPage() { + page = null; + os.api('pages/show', { + name: props.pageName, + username: props.username, + }).then(_page => { + page = _page; + }).catch(err => { + error = err; + }); +} + +function share() { + navigator.share({ + title: page.title ?? page.name, + text: page.summary, + url: `${url}/@${page.user.username}/pages/${page.name}`, + }); +} + +function shareWithNote() { + os.post({ + initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`, + }); +} + +function like() { + os.apiWithDialog('pages/like', { + pageId: page.id, + }).then(() => { + page.isLiked = true; + page.likedCount++; + }); +} + +async function unlike() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('pages/unlike', { + pageId: page.id, + }).then(() => { + page.isLiked = false; + page.likedCount--; + }); +} + +function pin(pin) { + os.apiWithDialog('i/update', { + pinnedPageId: pin ? page.id : null, + }); +} + +watch(() => path, fetchPage, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => page ? { + title: computed(() => page.title || page.name), + avatar: page.user, + path: `/@${page.user.username}/pages/${page.name}`, + share: { + title: page.title || page.name, + text: page.summary, + }, +} : 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 { + > .main { + padding: 32px; + + > .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(--margin) 0 var(--margin) 0; + font-size: 85%; + opacity: 0.75; + } +} +</style> diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue new file mode 100644 index 0000000000..b077180df8 --- /dev/null +++ b/packages/frontend/src/pages/pages.vue @@ -0,0 +1,99 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div v-if="tab === 'featured'" class="rknalgpo"> + <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> + <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> + </MkPagination> + </div> + + <div v-else-if="tab === 'my'" class="rknalgpo my"> + <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="myPagesPagination"> + <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> + </MkPagination> + </div> + + <div v-else-if="tab === 'liked'" class="rknalgpo"> + <MkPagination v-slot="{items}" :pagination="likedPagesPagination"> + <MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, inject } from 'vue'; +import MkPagePreview from '@/components/MkPagePreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const router = useRouter(); + +let tab = $ref('featured'); + +const featuredPagesPagination = { + endpoint: 'pages/featured' as const, + noPaging: true, +}; +const myPagesPagination = { + endpoint: 'i/pages' as const, + limit: 5, +}; +const likedPagesPagination = { + endpoint: 'i/page-likes' as const, + limit: 5, +}; + +function create() { + router.push('/pages/new'); +} + +const headerActions = $computed(() => [{ + icon: 'ti ti-plus', + text: i18n.ts.create, + handler: create, +}]); + +const headerTabs = $computed(() => [{ + key: 'featured', + title: i18n.ts._pages.featured, + icon: 'fas fa-fire-alt', +}, { + key: 'my', + title: i18n.ts._pages.my, + icon: 'ti ti-edit', +}, { + key: 'liked', + title: i18n.ts._pages.liked, + icon: 'ti ti-heart', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.pages, + icon: 'ti ti-note', +}))); +</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/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue new file mode 100644 index 0000000000..354f686e46 --- /dev/null +++ b/packages/frontend/src/pages/preview.vue @@ -0,0 +1,27 @@ +<template> +<div class="graojtoi"> + <MkSample/> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkSample from '@/components/MkSample.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.preview, + icon: 'ti ti-eye', +}))); +</script> + +<style lang="scss" scoped> +.graojtoi { + padding: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue new file mode 100644 index 0000000000..f179fbe957 --- /dev/null +++ b/packages/frontend/src/pages/registry.keys.vue @@ -0,0 +1,96 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16"> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.domain }}</template> + <template #value>{{ i18n.ts.system }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </MkKeyValue> + </FormSplit> + + <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> + + <FormSection v-if="keys"> + <template #label>{{ i18n.ts.keys }}</template> + <div class="_formLinks"> + <FormLink v-for="key in keys" :to="`/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink> + </div> + </FormSection> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import JSON5 from 'json5'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormSplit from '@/components/form/split.vue'; + +const props = defineProps<{ + path: string; +}>(); + +const scope = $computed(() => props.path.split('/')); + +let keys = $ref(null); + +function fetchKeys() { + os.api('i/registry/keys-with-type', { + scope: scope, + }).then(res => { + keys = Object.entries(res).sort((a, b) => a[0].localeCompare(b[0])); + }); +} + +async function createKey() { + const { canceled, result } = await os.form(i18n.ts._registry.createKey, { + key: { + type: 'string', + label: i18n.ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: i18n.ts.value, + }, + scope: { + type: 'string', + label: i18n.ts._registry.scope, + default: scope.join('/'), + }, + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + fetchKeys(); + }); +} + +watch(() => props.path, fetchKeys, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.registry, + icon: 'ti ti-adjustments', +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue new file mode 100644 index 0000000000..378420b1ba --- /dev/null +++ b/packages/frontend/src/pages/registry.value.vue @@ -0,0 +1,123 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16"> + <FormInfo warn>{{ i18n.ts.editTheseSettingsMayBreakAccount }}</FormInfo> + + <template v-if="value"> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.domain }}</template> + <template #value>{{ i18n.ts.system }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts._registry.key }}</template> + <template #value>{{ key }}</template> + </MkKeyValue> + </FormSplit> + + <FormTextarea v-model="valueForEditor" tall class="_formBlock _monospace"> + <template #label>{{ i18n.ts.value }} (JSON)</template> + </FormTextarea> + + <MkButton class="_formBlock" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><MkTime :time="value.updatedAt" mode="detail"/></template> + </MkKeyValue> + + <MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </template> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import JSON5 from 'json5'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormInfo from '@/components/MkInfo.vue'; + +const props = defineProps<{ + path: string; +}>(); + +const scope = $computed(() => props.path.split('/').slice(0, -1)); +const key = $computed(() => props.path.split('/').at(-1)); + +let value = $ref(null); +let valueForEditor = $ref(null); + +function fetchValue() { + os.api('i/registry/get-detail', { + scope, + key, + }).then(res => { + value = res; + valueForEditor = JSON5.stringify(res.value, null, '\t'); + }); +} + +async function save() { + try { + JSON5.parse(valueForEditor); + } catch (err) { + os.alert({ + type: 'error', + text: i18n.ts.invalidValue, + }); + return; + } + os.confirm({ + type: 'warning', + text: i18n.ts.saveConfirm, + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope, + key, + value: JSON5.parse(valueForEditor), + }); + }); +} + +function del() { + os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/remove', { + scope, + key, + }); + }); +} + +watch(() => props.path, fetchValue, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.registry, + icon: 'ti ti-adjustments', +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue new file mode 100644 index 0000000000..a2c65294fc --- /dev/null +++ b/packages/frontend/src/pages/registry.vue @@ -0,0 +1,74 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16"> + <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> + + <FormSection v-if="scopes"> + <template #label>{{ i18n.ts.system }}</template> + <div class="_formLinks"> + <FormLink v-for="scope in scopes" :to="`/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink> + </div> + </FormSection> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import JSON5 from 'json5'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; + +let scopes = $ref(null); + +function fetchScopes() { + os.api('i/registry/scopes').then(res => { + scopes = res.slice().sort((a, b) => a.join('/').localeCompare(b.join('/'))); + }); +} + +async function createKey() { + const { canceled, result } = await os.form(i18n.ts._registry.createKey, { + key: { + type: 'string', + label: i18n.ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: i18n.ts.value, + }, + scope: { + type: 'string', + label: i18n.ts._registry.scope, + }, + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + fetchScopes(); + }); +} + +fetchScopes(); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.registry, + icon: 'ti ti-adjustments', +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue new file mode 100644 index 0000000000..8ec15f6425 --- /dev/null +++ b/packages/frontend/src/pages/reset-password.vue @@ -0,0 +1,59 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32"> + <div class="_formRoot"> + <FormInput v-model="password" type="password" class="_formBlock"> + <template #prefix><i class="ti ti-lock"></i></template> + <template #label>{{ i18n.ts.newPassword }}</template> + </FormInput> + + <FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, onMounted } from 'vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + token?: string; +}>(); + +let password = $ref(''); + +async function save() { + await os.apiWithDialog('reset-password', { + token: props.token, + password: password, + }); + mainRouter.push('/'); +} + +onMounted(() => { + if (props.token == null) { + os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {}, 'closed'); + mainRouter.push('/'); + } +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.resetPassword, + icon: 'ti ti-lock', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue new file mode 100644 index 0000000000..edb2d8e18c --- /dev/null +++ b/packages/frontend/src/pages/scratchpad.vue @@ -0,0 +1,137 @@ +<template> +<div class="iltifgqe"> + <div class="editor _panel _gap"> + <PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/> + <MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton> + </div> + + <MkContainer :foldable="true" class="_gap"> + <template #header>{{ i18n.ts.output }}</template> + <div class="bepmlvbi"> + <div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div> + </div> + </MkContainer> + + <div class="_gap"> + {{ i18n.ts.scratchpadDescription }} + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } 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 } from '@syuilo/aiscript'; +import MkContainer from '@/components/MkContainer.vue'; +import MkButton from '@/components/MkButton.vue'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const code = ref(''); +const logs = ref<any[]>([]); + +const saved = localStorage.getItem('scratchpad'); +if (saved) { + code.value = saved; +} + +watch(code, () => { + localStorage.setItem('scratchpad', code.value); +}); + +async function run() { + logs.value = []; + const aiscript = new AiScript(createAiScriptEnv({ + storageKey: 'scratchpad', + token: $i?.token, + }), { + in: (q) => { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + logs.value.push({ + id: Math.random(), + text: value.type === 'str' ? value.value : utils.valToString(value), + print: true, + }); + }, + log: (type, params) => { + switch (type) { + case 'end': logs.value.push({ + id: Math.random(), + text: utils.valToString(params.val, true), + print: false, + }); break; + default: break; + } + }, + }); + + let ast; + try { + ast = parse(code.value); + } catch (error) { + os.alert({ + type: 'error', + text: 'Syntax error :(', + }); + return; + } + try { + await aiscript.exec(ast); + } catch (error: any) { + os.alert({ + type: 'error', + text: error.message, + }); + } +} + +function highlighter(code) { + return highlight(code, languages.js, 'javascript'); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.scratchpad, + icon: 'ti ti-terminal-2', +}); +</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/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue new file mode 100644 index 0000000000..c080b763bb --- /dev/null +++ b/packages/frontend/src/pages/search.vue @@ -0,0 +1,38 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XNotes ref="notes" :pagination="pagination"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XNotes from '@/components/MkNotes.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + query: string; + channel?: string; +}>(); + +const pagination = { + endpoint: 'notes/search' as const, + limit: 10, + params: computed(() => ({ + query: props.query, + channelId: props.channel, + })), +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.t('searchWith', { q: props.query }), + icon: 'ti ti-search', +}))); +</script> diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue new file mode 100644 index 0000000000..1803129aaa --- /dev/null +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -0,0 +1,216 @@ +<template> +<div> + <MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton> + <template v-if="$i.twoFactorEnabled"> + <p>{{ i18n.ts._2fa.alreadyRegistered }}</p> + <MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton> + + <template v-if="supportsCredentials"> + <hr class="totp-method-sep"> + + <h2 class="heading">{{ i18n.ts.securityKey }}</h2> + <p>{{ i18n.ts._2fa.securityKeyInfo }}</p> + <div class="key-list"> + <div v-for="key in $i.securityKeysList" class="key"> + <h3>{{ key.name }}</h3> + <div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> + <MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton> + </div> + </div> + + <MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch> + + <MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo> + <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton> + + <ol v-if="registration && !registration.error"> + <li v-if="registration.stage >= 0"> + {{ i18n.ts.tapSecurityKey }} + <MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/> + </li> + <li v-if="registration.stage >= 1"> + <MkForm :disabled="registration.stage != 1 || registration.saving"> + <MkInput v-model="keyName" :max="30"> + <template #label>{{ i18n.ts.securityKeyName }}</template> + </MkInput> + <MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton> + <MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/> + </MkForm> + </li> + </ol> + </template> + </template> + <div v-if="twoFactorData && !$i.twoFactorEnabled"> + <ol style="margin: 0; padding: 0 0 0 1em;"> + <li> + <I18n :src="i18n.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>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li> + <li> + {{ i18n.ts._2fa.step3 }}<br> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> + <MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton> + </li> + </ol> + <MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import { hostname } from '@/config'; +import { byteify, hexify, stringify } from '@/scripts/2fa'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; + +const twoFactorData = ref<any>(null); +const supportsCredentials = ref(!!navigator.credentials); +const usePasswordLessLogin = ref($i!.usePasswordLessLogin); +const registration = ref<any>(null); +const keyName = ref(''); +const token = ref(null); + +function register() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register', { + password: password, + }).then(data => { + twoFactorData.value = data; + }); + }); +} + +function unregister() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/unregister', { + password: password, + }).then(() => { + usePasswordLessLogin.value = false; + updatePasswordLessLogin(); + }).then(() => { + os.success(); + $i!.twoFactorEnabled = false; + }); + }); +} + +function submit() { + os.api('i/2fa/done', { + token: token.value, + }).then(() => { + os.success(); + $i!.twoFactorEnabled = true; + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); +} + +function registerKey() { + registration.value.saving = true; + os.api('i/2fa/key-done', { + password: registration.value.password, + name: keyName.value, + challengeId: registration.value.challengeId, + // we convert each 16 bits to a string to serialise + clientDataJSON: stringify(registration.value.credential.response.clientDataJSON), + attestationObject: hexify(registration.value.credential.response.attestationObject), + }).then(key => { + registration.value = null; + key.lastUsed = new Date(); + os.success(); + }); +} + +function unregisterKey(key) { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + return os.api('i/2fa/remove-key', { + password, + credentialId: key.id, + }).then(() => { + usePasswordLessLogin.value = false; + updatePasswordLessLogin(); + }).then(() => { + os.success(); + }); + }); +} + +function addSecurityKey() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register-key', { + password, + }).then(reg => { + registration.value = { + password, + challengeId: reg!.challengeId, + stage: 0, + publicKeyOptions: { + challenge: byteify(reg!.challenge, 'base64'), + rp: { + id: hostname, + name: 'Misskey', + }, + user: { + id: byteify($i!.id, 'ascii'), + name: $i!.username, + displayName: $i!.name, + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + timeout: 60000, + attestation: 'direct', + }, + saving: true, + }; + return navigator.credentials.create({ + publicKey: registration.value.publicKeyOptions, + }); + }).then(credential => { + registration.value.credential = credential; + registration.value.saving = false; + registration.value.stage = 1; + }).catch(err => { + console.warn('Error while registering?', err); + registration.value.error = err.message; + registration.value.stage = -1; + }); + }); +} + +async function updatePasswordLessLogin() { + await os.api('i/2fa/password-less', { + value: !!usePasswordLessLogin.value, + }); +} +</script> diff --git a/packages/frontend/src/pages/settings/account-info.vue b/packages/frontend/src/pages/settings/account-info.vue new file mode 100644 index 0000000000..ccd99c162a --- /dev/null +++ b/packages/frontend/src/pages/settings/account-info.vue @@ -0,0 +1,158 @@ +<template> +<div class="_formRoot"> + <MkKeyValue> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $i.id }}</span></template> + </MkKeyValue> + + <FormSection> + <MkKeyValue> + <template #key>{{ i18n.ts.registeredDate }}</template> + <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> + </MkKeyValue> + </FormSection> + + <FormSection v-if="stats"> + <template #label>{{ i18n.ts.statistics }}</template> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.notesCount }}</template> + <template #value>{{ number(stats.notesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.repliesCount }}</template> + <template #value>{{ number(stats.repliesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.renotesCount }}</template> + <template #value>{{ number(stats.renotesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.repliedCount }}</template> + <template #value>{{ number(stats.repliedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.renotedCount }}</template> + <template #value>{{ number(stats.renotedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pollVotesCount }}</template> + <template #value>{{ number(stats.pollVotesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pollVotedCount }}</template> + <template #value>{{ number(stats.pollVotedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.sentReactionsCount }}</template> + <template #value>{{ number(stats.sentReactionsCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.receivedReactionsCount }}</template> + <template #value>{{ number(stats.receivedReactionsCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.noteFavoritesCount }}</template> + <template #value>{{ number(stats.noteFavoritesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followingCount }}</template> + <template #value>{{ number(stats.followingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.local }})</template> + <template #value>{{ number(stats.localFollowingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followersCount }}</template> + <template #value>{{ number(stats.followersCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.local }})</template> + <template #value>{{ number(stats.localFollowersCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowersCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pageLikesCount }}</template> + <template #value>{{ number(stats.pageLikesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pageLikedCount }}</template> + <template #value>{{ number(stats.pageLikedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.driveFilesCount }}</template> + <template #value>{{ number(stats.driveFilesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.driveUsage }}</template> + <template #value>{{ bytes(stats.driveUsage) }}</template> + </MkKeyValue> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.other }}</template> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>emailVerified</template> + <template #value>{{ $i.emailVerified ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>twoFactorEnabled</template> + <template #value>{{ $i.twoFactorEnabled ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>securityKeys</template> + <template #value>{{ $i.securityKeys ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>usePasswordLessLogin</template> + <template #value>{{ $i.usePasswordLessLogin ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>isModerator</template> + <template #value>{{ $i.isModerator ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>isAdmin</template> + <template #value>{{ $i.isAdmin ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import FormSection from '@/components/form/section.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const stats = ref<any>({}); + +onMounted(() => { + os.api('users/stats', { + userId: $i!.id, + }).then(response => { + stats.value = response; + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.accountInfo, + icon: 'ti ti-info-circle', +}); +</script> diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue new file mode 100644 index 0000000000..493d3b2618 --- /dev/null +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -0,0 +1,143 @@ +<template> +<div class="_formRoot"> + <FormSuspense :p="init"> + <FormButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</FormButton> + + <div v-for="account in accounts" :key="account.id" class="_panel _button lcjjdxlm" @click="menu(account, $event)"> + <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> + </FormSuspense> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref } from 'vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const storedAccounts = ref<any>(null); +const accounts = ref<any>(null); + +const init = async () => { + getAccounts().then(accounts => { + storedAccounts.value = accounts.filter(x => x.id !== $i!.id); + + console.log(storedAccounts.value); + + return os.api('users/show', { + userIds: storedAccounts.value.map(x => x.id), + }); + }).then(response => { + accounts.value = response; + console.log(accounts.value); + }); +}; + +function menu(account, ev) { + os.popupMenu([{ + text: i18n.ts.switch, + icon: 'ti ti-switch-horizontal', + action: () => switchAccount(account), + }, { + text: i18n.ts.remove, + icon: 'ti ti-trash', + danger: true, + action: () => removeAccount(account), + }], ev.currentTarget ?? ev.target); +} + +function addAccount(ev) { + os.popupMenu([{ + text: i18n.ts.existingAccount, + action: () => { addExistingAccount(); }, + }, { + text: i18n.ts.createAccount, + action: () => { createAccount(); }, + }], ev.currentTarget ?? ev.target); +} + +function removeAccount(account) { + _removeAccount(account.id); +} + +function addExistingAccount() { + os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: res => { + addAccounts(res.id, res.i); + os.success(); + }, + }, 'closed'); +} + +function createAccount() { + os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: res => { + addAccounts(res.id, res.i); + switchAccountWithToken(res.i); + }, + }, 'closed'); +} + +async function switchAccount(account: any) { + const fetchedAccounts: any[] = await getAccounts(); + const token = fetchedAccounts.find(x => x.id === account.id).token; + switchAccountWithToken(token); +} + +function switchAccountWithToken(token: string) { + login(token); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.accounts, + icon: 'ti ti-users', +}); +</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/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue new file mode 100644 index 0000000000..8d7291cd10 --- /dev/null +++ b/packages/frontend/src/pages/settings/api.vue @@ -0,0 +1,46 @@ +<template> +<div class="_formRoot"> + <FormButton primary class="_formBlock" @click="generateToken">{{ i18n.ts.generateAccessToken }}</FormButton> + <FormLink to="/settings/apps" class="_formBlock">{{ i18n.ts.manageAccessTokens }}</FormLink> + <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null" class="_formBlock">API console</FormLink> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref } from 'vue'; +import FormLink from '@/components/form/link.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const isDesktop = ref(window.innerWidth >= 1100); + +function generateToken() { + os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + os.alert({ + type: 'success', + title: i18n.ts.token, + text: token, + }); + }, + }, 'closed'); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'API', + icon: 'ti ti-api', +}); +</script> diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue new file mode 100644 index 0000000000..05abadff23 --- /dev/null +++ b/packages/frontend/src/pages/settings/apps.vue @@ -0,0 +1,96 @@ +<template> +<div class="_formRoot"> + <FormPagination ref="list" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + </template> + <template #default="{items}"> + <div v-for="token in items" :key="token.id" class="_panel bfomjevm"> + <img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/> + <div class="body"> + <div class="name">{{ token.name }}</div> + <div class="description">{{ token.description }}</div> + <div class="_keyValue"> + <div>{{ i18n.ts.installedDate }}:</div> + <div><MkTime :time="token.createdAt"/></div> + </div> + <div class="_keyValue"> + <div>{{ i18n.ts.lastUsedDate }}:</div> + <div><MkTime :time="token.lastUsedAt"/></div> + </div> + <div class="actions"> + <button class="_button" @click="revoke(token)"><i class="ti ti-trash"></i></button> + </div> + <details> + <summary>{{ i18n.ts.details }}</summary> + <ul> + <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </details> + </div> + </div> + </template> + </FormPagination> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import FormPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const list = ref<any>(null); + +const pagination = { + endpoint: 'i/apps' as const, + limit: 100, + params: { + sort: '+lastUsedAt', + }, +}; + +function revoke(token) { + os.api('i/revoke-token', { tokenId: token.id }).then(() => { + list.value.reload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.installedApps, + icon: 'ti ti-plug', +}); +</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/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue new file mode 100644 index 0000000000..2caad22b7b --- /dev/null +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -0,0 +1,46 @@ +<template> +<div class="_formRoot"> + <FormInfo warn class="_formBlock">{{ i18n.ts.customCssWarn }}</FormInfo> + + <FormTextarea v-model="localCustomCss" manual-save tall class="_monospace _formBlock" style="tab-size: 2;"> + <template #label>CSS</template> + </FormTextarea> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const localCustomCss = ref(localStorage.getItem('customCss') ?? ''); + +async function apply() { + localStorage.setItem('customCss', localCustomCss.value); + + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +watch(localCustomCss, async () => { + await apply(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.customCss, + icon: 'ti ti-code', +}); +</script> diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue new file mode 100644 index 0000000000..82cefe05d5 --- /dev/null +++ b/packages/frontend/src/pages/settings/deck.vue @@ -0,0 +1,39 @@ +<template> +<div class="_formRoot"> + <FormSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</FormSwitch> + + <FormSwitch v-model="alwaysShowMainColumn" class="_formBlock">{{ i18n.ts._deck.alwaysShowMainColumn }}</FormSwitch> + + <FormRadios v-model="columnAlign" class="_formBlock"> + <template #label>{{ i18n.ts._deck.columnAlign }}</template> + <option value="left">{{ i18n.ts.left }}</option> + <option value="center">{{ i18n.ts.center }}</option> + </FormRadios> +</div> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormLink from '@/components/form/link.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormInput from '@/components/form/input.vue'; +import { deckStore } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const navWindow = computed(deckStore.makeGetterSetter('navWindow')); +const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn')); +const columnAlign = computed(deckStore.makeGetterSetter('columnAlign')); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.deck, + icon: 'ti ti-columns', +}); +</script> diff --git a/packages/frontend/src/pages/settings/delete-account.vue b/packages/frontend/src/pages/settings/delete-account.vue new file mode 100644 index 0000000000..8a25ff39f0 --- /dev/null +++ b/packages/frontend/src/pages/settings/delete-account.vue @@ -0,0 +1,52 @@ +<template> +<div class="_formRoot"> + <FormInfo warn class="_formBlock">{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> + <FormInfo class="_formBlock">{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> + <FormButton v-if="!$i.isDeleted" danger class="_formBlock" @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</FormButton> + <FormButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import FormInfo from '@/components/MkInfo.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { signout } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +async function deleteAccount() { + { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAccountConfirm, + }); + if (canceled) return; + } + + const { canceled, result: password } = await os.inputText({ + title: i18n.ts.password, + type: 'password', + }); + if (canceled) return; + + await os.apiWithDialog('i/delete-account', { + password: password, + }); + + await os.alert({ + title: i18n.ts._accountDelete.started, + }); + + await signout(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._accountDelete.accountDelete, + icon: 'ti ti-alert-triangle', +}); +</script> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue new file mode 100644 index 0000000000..2d45b1add8 --- /dev/null +++ b/packages/frontend/src/pages/settings/drive.vue @@ -0,0 +1,145 @@ +<template> +<div class="_formRoot"> + <FormSection v-if="!fetching"> + <template #label>{{ i18n.ts.usageAmount }}</template> + <div class="_formBlock uawsfosz"> + <div class="meter"><div :style="meterStyle"></div></div> + </div> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.capacity }}</template> + <template #value>{{ bytes(capacity, 1) }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.inUse }}</template> + <template #value>{{ bytes(usage, 1) }}</template> + </MkKeyValue> + </FormSplit> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.statistics }}</template> + <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/> + </FormSection> + + <FormSection> + <FormLink @click="chooseUploadFolder()"> + {{ i18n.ts.uploadFolder }} + <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> + <template #suffixIcon><i class="fas fa-folder-open"></i></template> + </FormLink> + <FormSwitch v-model="keepOriginalUploading" class="_formBlock"> + <template #label>{{ i18n.ts.keepOriginalUploading }}</template> + <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> + </FormSwitch> + <FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:model-value="saveProfile()"> + <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> + </FormSwitch> + <FormSwitch v-model="autoSensitive" class="_formBlock" @update:model-value="saveProfile()"> + <template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template> + </FormSwitch> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import tinycolor from 'tinycolor2'; +import FormLink from '@/components/form/link.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSection from '@/components/form/section.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import bytes from '@/filters/bytes'; +import { defaultStore } from '@/store'; +import MkChart from '@/components/MkChart.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; + +const fetching = ref(true); +const usage = ref<any>(null); +const capacity = ref<any>(null); +const uploadFolder = ref<any>(null); +let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw); +let autoSensitive = $ref($i.autoSensitive); + +const meterStyle = computed(() => { + return { + width: `${usage.value / capacity.value * 100}%`, + background: tinycolor({ + h: 180 - (usage.value / capacity.value * 180), + s: 0.7, + l: 0.5, + }), + }; +}); + +const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); + +os.api('drive').then(info => { + capacity.value = info.capacity; + usage.value = info.usage; + fetching.value = false; +}); + +if (defaultStore.state.uploadFolder) { + os.api('drive/folders/show', { + folderId: defaultStore.state.uploadFolder, + }).then(response => { + uploadFolder.value = response; + }); +} + +function chooseUploadFolder() { + os.selectDriveFolder(false).then(async folder => { + defaultStore.set('uploadFolder', folder ? folder.id : null); + os.success(); + if (defaultStore.state.uploadFolder) { + uploadFolder.value = await os.api('drive/folders/show', { + folderId: defaultStore.state.uploadFolder, + }); + } else { + uploadFolder.value = null; + } + }); +} + +function saveProfile() { + os.api('i/update', { + alwaysMarkNsfw: !!alwaysMarkNsfw, + autoSensitive: !!autoSensitive, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.drive, + icon: 'ti ti-cloud', +}); +</script> + +<style lang="scss" scoped> + +@use "sass:math"; + +.uawsfosz { + + > .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/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue new file mode 100644 index 0000000000..3fff8c6b1d --- /dev/null +++ b/packages/frontend/src/pages/settings/email.vue @@ -0,0 +1,111 @@ +<template> +<div class="_formRoot"> + <FormSection> + <template #label>{{ i18n.ts.emailAddress }}</template> + <FormInput v-model="emailAddress" type="email" manual-save> + <template #prefix><i class="ti ti-mail"></i></template> + <template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template> + <template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--success);"></i> {{ i18n.ts.emailVerified }}</template> + </FormInput> + </FormSection> + + <FormSection> + <FormSwitch :model-value="$i.receiveAnnouncementEmail" @update:model-value="onChangeReceiveAnnouncementEmail"> + {{ i18n.ts.receiveAnnouncementFromInstance }} + </FormSwitch> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.emailNotification }}</template> + <FormSwitch v-model="emailNotification_mention" class="_formBlock"> + {{ i18n.ts._notification._types.mention }} + </FormSwitch> + <FormSwitch v-model="emailNotification_reply" class="_formBlock"> + {{ i18n.ts._notification._types.reply }} + </FormSwitch> + <FormSwitch v-model="emailNotification_quote" class="_formBlock"> + {{ i18n.ts._notification._types.quote }} + </FormSwitch> + <FormSwitch v-model="emailNotification_follow" class="_formBlock"> + {{ i18n.ts._notification._types.follow }} + </FormSwitch> + <FormSwitch v-model="emailNotification_receiveFollowRequest" class="_formBlock"> + {{ i18n.ts._notification._types.receiveFollowRequest }} + </FormSwitch> + <FormSwitch v-model="emailNotification_groupInvited" class="_formBlock"> + {{ i18n.ts._notification._types.groupInvited }} + </FormSwitch> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref, watch } from 'vue'; +import FormSection from '@/components/form/section.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const emailAddress = ref($i!.email); + +const onChangeReceiveAnnouncementEmail = (v) => { + os.api('i/update', { + receiveAnnouncementEmail: v, + }); +}; + +const saveEmailAddress = () => { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.apiWithDialog('i/update-email', { + password: password, + email: emailAddress.value, + }); + }); +}; + +const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention')); +const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply')); +const emailNotification_quote = ref($i!.emailNotificationTypes.includes('quote')); +const emailNotification_follow = ref($i!.emailNotificationTypes.includes('follow')); +const emailNotification_receiveFollowRequest = ref($i!.emailNotificationTypes.includes('receiveFollowRequest')); +const emailNotification_groupInvited = ref($i!.emailNotificationTypes.includes('groupInvited')); + +const saveNotificationSettings = () => { + os.api('i/update', { + emailNotificationTypes: [ + ...[emailNotification_mention.value ? 'mention' : null], + ...[emailNotification_reply.value ? 'reply' : null], + ...[emailNotification_quote.value ? 'quote' : null], + ...[emailNotification_follow.value ? 'follow' : null], + ...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null], + ...[emailNotification_groupInvited.value ? 'groupInvited' : null], + ].filter(x => x != null), + }); +}; + +watch([emailNotification_mention, emailNotification_reply, emailNotification_quote, emailNotification_follow, emailNotification_receiveFollowRequest, emailNotification_groupInvited], () => { + saveNotificationSettings(); +}); + +onMounted(() => { + watch(emailAddress, () => { + saveEmailAddress(); + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.email, + icon: 'ti ti-mail', +}); +</script> diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue new file mode 100644 index 0000000000..84d99d2fd7 --- /dev/null +++ b/packages/frontend/src/pages/settings/general.vue @@ -0,0 +1,196 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="lang" class="_formBlock"> + <template #label>{{ i18n.ts.uiLanguage }}</template> + <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> + <template #caption> + <I18n :src="i18n.ts.i18nInfo" tag="span"> + <template #link> + <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> + </template> + </I18n> + </template> + </FormSelect> + + <FormRadios v-model="overridedDeviceKind" class="_formBlock"> + <template #label>{{ i18n.ts.overridedDeviceKind }}</template> + <option :value="null">{{ i18n.ts.auto }}</option> + <option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option> + <option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option> + <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option> + </FormRadios> + + <FormSwitch v-model="showFixedPostForm" class="_formBlock">{{ i18n.ts.showFixedPostForm }}</FormSwitch> + + <FormSection> + <template #label>{{ i18n.ts.behavior }}</template> + <FormSwitch v-model="imageNewTab" class="_formBlock">{{ i18n.ts.openImageInNewTab }}</FormSwitch> + <FormSwitch v-model="enableInfiniteScroll" class="_formBlock">{{ i18n.ts.enableInfiniteScroll }}</FormSwitch> + <FormSwitch v-model="useReactionPickerForContextMenu" class="_formBlock">{{ i18n.ts.useReactionPickerForContextMenu }}</FormSwitch> + <FormSwitch v-model="disablePagesScript" class="_formBlock">{{ i18n.ts.disablePagesScript }}</FormSwitch> + + <FormSelect v-model="serverDisconnectedBehavior" class="_formBlock"> + <template #label>{{ i18n.ts.whenServerDisconnected }}</template> + <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> + <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> + <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> + </FormSelect> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.appearance }}</template> + <FormSwitch v-model="disableAnimatedMfm" class="_formBlock">{{ i18n.ts.disableAnimatedMfm }}</FormSwitch> + <FormSwitch v-model="reduceAnimation" class="_formBlock">{{ i18n.ts.reduceUiAnimation }}</FormSwitch> + <FormSwitch v-model="useBlurEffect" class="_formBlock">{{ i18n.ts.useBlurEffect }}</FormSwitch> + <FormSwitch v-model="useBlurEffectForModal" class="_formBlock">{{ i18n.ts.useBlurEffectForModal }}</FormSwitch> + <FormSwitch v-model="showGapBetweenNotesInTimeline" class="_formBlock">{{ i18n.ts.showGapBetweenNotesInTimeline }}</FormSwitch> + <FormSwitch v-model="loadRawImages" class="_formBlock">{{ i18n.ts.loadRawImages }}</FormSwitch> + <FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch> + <FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch> + <FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch> + <div class="_formBlock"> + <FormRadios v-model="emojiStyle"> + <template #label>{{ i18n.ts.emojiStyle }}</template> + <option value="native">{{ i18n.ts.native }}</option> + <option value="fluentEmoji">Fluent Emoji</option> + <option value="twemoji">Twemoji</option> + </FormRadios> + <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="๐ฎ๐ฆ๐ญ๐ฉ๐ฐ๐ซ๐ฌ๐ฅ๐ช"/></div> + </div> + + <FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch> + + <FormRadios v-model="fontSize" class="_formBlock"> + <template #label>{{ i18n.ts.fontSize }}</template> + <option :value="null"><span style="font-size: 14px;">Aa</span></option> + <option value="1"><span style="font-size: 15px;">Aa</span></option> + <option value="2"><span style="font-size: 16px;">Aa</span></option> + <option value="3"><span style="font-size: 17px;">Aa</span></option> + </FormRadios> + </FormSection> + + <FormSection> + <FormSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</FormSwitch> + </FormSection> + + <FormSelect v-model="instanceTicker" class="_formBlock"> + <template #label>{{ i18n.ts.instanceTicker }}</template> + <option value="none">{{ i18n.ts._instanceTicker.none }}</option> + <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> + <option value="always">{{ i18n.ts._instanceTicker.always }}</option> + </FormSelect> + + <FormSelect v-model="nsfw" class="_formBlock"> + <template #label>{{ i18n.ts.nsfw }}</template> + <option value="respect">{{ i18n.ts._nsfw.respect }}</option> + <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option> + <option value="force">{{ i18n.ts._nsfw.force }}</option> + </FormSelect> + + <FormRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing class="_formBlock"> + <template #label>{{ i18n.ts.numberOfPageCache }}</template> + <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> + </FormRange> + + <FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink> + + <FormLink to="/settings/custom-css" class="_formBlock"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormRange from '@/components/form/range.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import MkLink from '@/components/MkLink.vue'; +import { langs } from '@/config'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const lang = ref(localStorage.getItem('lang')); +const fontSize = ref(localStorage.getItem('fontSize')); +const useSystemFont = ref(localStorage.getItem('useSystemFont') != null); + +async function reloadAsk() { + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind')); +const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); +const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); +const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); +const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect')); +const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline')); +const disableAnimatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v)); +const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); +const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); +const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); +const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); +const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); +const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); +const disablePagesScript = computed(defaultStore.makeGetterSetter('disablePagesScript')); +const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); +const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache')); +const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')); +const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); +const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); +const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); +const aiChanMode = computed(defaultStore.makeGetterSetter('aiChanMode')); + +watch(lang, () => { + localStorage.setItem('lang', lang.value as string); + localStorage.removeItem('locale'); +}); + +watch(fontSize, () => { + if (fontSize.value == null) { + localStorage.removeItem('fontSize'); + } else { + localStorage.setItem('fontSize', fontSize.value); + } +}); + +watch(useSystemFont, () => { + if (useSystemFont.value) { + localStorage.setItem('useSystemFont', 't'); + } else { + localStorage.removeItem('useSystemFont'); + } +}); + +watch([ + lang, + fontSize, + useSystemFont, + enableInfiniteScroll, + squareAvatars, + aiChanMode, + showGapBetweenNotesInTimeline, + instanceTicker, + overridedDeviceKind, +], async () => { + await reloadAsk(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.general, + icon: 'ti ti-adjustments', +}); +</script> diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue new file mode 100644 index 0000000000..7db267c142 --- /dev/null +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -0,0 +1,165 @@ +<template> +<div class="_formRoot"> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.allNotes }}</template> + <FormFolder> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.followingList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <FormSwitch v-model="excludeMutingUsers" class="_formBlock"> + {{ i18n.ts._exportOrImport.excludeMutingUsers }} + </FormSwitch> + <FormSwitch v-model="excludeInactiveUsers" class="_formBlock"> + {{ i18n.ts._exportOrImport.excludeInactiveUsers }} + </FormSwitch> + <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.userLists }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.muteList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.blockingList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const excludeMutingUsers = ref(false); +const excludeInactiveUsers = ref(false); + +const onExportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.exportRequested, + }); +}; + +const onImportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.importRequested, + }); +}; + +const onError = (ev) => { + os.alert({ + type: 'error', + text: ev.message, + }); +}; + +const exportNotes = () => { + os.api('i/export-notes', {}).then(onExportSuccess).catch(onError); +}; + +const exportFollowing = () => { + os.api('i/export-following', { + excludeMuting: excludeMutingUsers.value, + excludeInactive: excludeInactiveUsers.value, + }) + .then(onExportSuccess).catch(onError); +}; + +const exportBlocking = () => { + os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError); +}; + +const exportUserLists = () => { + os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError); +}; + +const exportMuting = () => { + os.api('i/export-mute', {}).then(onExportSuccess).catch(onError); +}; + +const importFollowing = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importUserLists = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importMuting = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importBlocking = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.importAndExport, + icon: 'ti ti-package', +}); +</script> + +<style module> +.button { + margin-right: 16px; +} +</style> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue new file mode 100644 index 0000000000..01436cd554 --- /dev/null +++ b/packages/frontend/src/pages/settings/index.vue @@ -0,0 +1,291 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900" :margin-min="20" :margin-max="32"> + <div ref="el" class="vvcocwet" :class="{ wide: !narrow }"> + <div class="body"> + <div v-if="!narrow || currentPage?.route.name == null" class="nav"> + <div class="baaadecd"> + <MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> + </div> + </div> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> + <div class="bkzroven"> + <RouterView/> + </div> + </div> + </div> + </div> + </MkSpacer> +</mkstickycontainer> +</template> + +<script setup lang="ts"> +import { computed, defineAsyncComponent, inject, nextTick, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkInfo from '@/components/MkInfo.vue'; +import MkSuperMenu from '@/components/MkSuperMenu.vue'; +import { scroll } from '@/scripts/scroll'; +import { signout, $i } from '@/account'; +import { unisonReload } from '@/scripts/unison-reload'; +import { instance } from '@/instance'; +import { useRouter } from '@/router'; +import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import * as os from '@/os'; + +const indexInfo = { + title: i18n.ts.settings, + icon: 'ti ti-settings', + hideHeader: true, +}; +const INFO = ref(indexInfo); +const el = ref<HTMLElement | null>(null); +const childInfo = ref(null); + +const router = useRouter(); + +let narrow = $ref(false); +const NARROW_THRESHOLD = 600; + +let currentPage = $computed(() => router.currentRef.value.child); + +const ro = new ResizeObserver((entries, observer) => { + if (entries.length === 0) return; + narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; +}); + +const menuDef = computed(() => [{ + title: i18n.ts.basicSettings, + items: [{ + icon: 'ti ti-user', + text: i18n.ts.profile, + to: '/settings/profile', + active: currentPage?.route.name === 'profile', + }, { + icon: 'ti ti-lock-open', + text: i18n.ts.privacy, + to: '/settings/privacy', + active: currentPage?.route.name === 'privacy', + }, { + icon: 'ti ti-mood-happy', + text: i18n.ts.reaction, + to: '/settings/reaction', + active: currentPage?.route.name === 'reaction', + }, { + icon: 'ti ti-cloud', + text: i18n.ts.drive, + to: '/settings/drive', + active: currentPage?.route.name === 'drive', + }, { + icon: 'ti ti-bell', + text: i18n.ts.notifications, + to: '/settings/notifications', + active: currentPage?.route.name === 'notifications', + }, { + icon: 'ti ti-mail', + text: i18n.ts.email, + to: '/settings/email', + active: currentPage?.route.name === 'email', + }, { + icon: 'ti ti-share', + text: i18n.ts.integration, + to: '/settings/integration', + active: currentPage?.route.name === 'integration', + }, { + icon: 'ti ti-lock', + text: i18n.ts.security, + to: '/settings/security', + active: currentPage?.route.name === 'security', + }], +}, { + title: i18n.ts.clientSettings, + items: [{ + icon: 'ti ti-adjustments', + text: i18n.ts.general, + to: '/settings/general', + active: currentPage?.route.name === 'general', + }, { + icon: 'ti ti-palette', + text: i18n.ts.theme, + to: '/settings/theme', + active: currentPage?.route.name === 'theme', + }, { + icon: 'ti ti-menu-2', + text: i18n.ts.navbar, + to: '/settings/navbar', + active: currentPage?.route.name === 'navbar', + }, { + icon: 'ti ti-equal-double', + text: i18n.ts.statusbar, + to: '/settings/statusbar', + active: currentPage?.route.name === 'statusbar', + }, { + icon: 'ti ti-music', + text: i18n.ts.sounds, + to: '/settings/sounds', + active: currentPage?.route.name === 'sounds', + }, { + icon: 'ti ti-plug', + text: i18n.ts.plugins, + to: '/settings/plugin', + active: currentPage?.route.name === 'plugin', + }], +}, { + title: i18n.ts.otherSettings, + items: [{ + icon: 'ti ti-package', + text: i18n.ts.importAndExport, + to: '/settings/import-export', + active: currentPage?.route.name === 'import-export', + }, { + icon: 'ti ti-planet-off', + text: i18n.ts.instanceMute, + to: '/settings/instance-mute', + active: currentPage?.route.name === 'instance-mute', + }, { + icon: 'ti ti-ban', + text: i18n.ts.muteAndBlock, + to: '/settings/mute-block', + active: currentPage?.route.name === 'mute-block', + }, { + icon: 'ti ti-message-off', + text: i18n.ts.wordMute, + to: '/settings/word-mute', + active: currentPage?.route.name === 'word-mute', + }, { + icon: 'ti ti-api', + text: 'API', + to: '/settings/api', + active: currentPage?.route.name === 'api', + }, { + icon: 'ti ti-webhook', + text: 'Webhook', + to: '/settings/webhook', + active: currentPage?.route.name === 'webhook', + }, { + icon: 'ti ti-dots', + text: i18n.ts.other, + to: '/settings/other', + active: currentPage?.route.name === 'other', + }], +}, { + items: [{ + icon: 'ti ti-device-floppy', + text: i18n.ts.preferencesBackups, + to: '/settings/preferences-backups', + active: currentPage?.route.name === 'preferences-backups', + }, { + type: 'button', + icon: 'ti ti-trash', + text: i18n.ts.clearCache, + action: () => { + localStorage.removeItem('locale'); + localStorage.removeItem('theme'); + unisonReload(); + }, + }, { + type: 'button', + icon: 'ti ti-power', + text: i18n.ts.logout, + action: async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.logoutConfirm, + }); + if (canceled) return; + signout(); + }, + danger: true, + }], +}]); + +watch($$(narrow), () => { +}); + +onMounted(() => { + ro.observe(el.value); + + narrow = el.value.offsetWidth < NARROW_THRESHOLD; + + if (!narrow && currentPage?.route.name == null) { + router.replace('/settings/profile'); + } +}); + +onActivated(() => { + narrow = el.value.offsetWidth < NARROW_THRESHOLD; + + if (!narrow && currentPage?.route.name == null) { + router.replace('/settings/profile'); + } +}); + +onUnmounted(() => { + ro.disconnect(); +}); + +const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); + +provideMetadataReceiver((info) => { + if (info == null) { + childInfo.value = null; + } else { + childInfo.value = info; + } +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(INFO); +// w 890 +// h 700 +</script> + +<style lang="scss" scoped> +.vvcocwet { + > .body { + > .nav { + .baaadecd { + > .info { + margin: 16px 0; + } + + > .accounts { + > .avatar { + display: block; + width: 50px; + height: 50px; + margin: 8px auto 16px auto; + } + } + } + } + + > .main { + .bkzroven { + } + } + } + + &.wide { + > .body { + display: flex; + height: 100%; + + > .nav { + width: 34%; + padding-right: 32px; + box-sizing: border-box; + } + + > .main { + flex: 1; + min-width: 0; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/instance-mute.vue b/packages/frontend/src/pages/settings/instance-mute.vue new file mode 100644 index 0000000000..54504de188 --- /dev/null +++ b/packages/frontend/src/pages/settings/instance-mute.vue @@ -0,0 +1,53 @@ +<template> +<div class="_formRoot"> + <MkInfo>{{ i18n.ts._instanceMute.title }}</MkInfo> + <FormTextarea v-model="instanceMutes" class="_formBlock"> + <template #label>{{ i18n.ts._instanceMute.heading }}</template> + <template #caption>{{ i18n.ts._instanceMute.instanceMuteDescription }}<br>{{ i18n.ts._instanceMute.instanceMuteDescription2 }}</template> + </FormTextarea> + <MkButton primary :disabled="!changed" class="_formBlock" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const instanceMutes = ref($i!.mutedInstances.join('\n')); +const changed = ref(false); + +async function save() { + let mutes = instanceMutes.value + .trim().split('\n') + .map(el => el.trim()) + .filter(el => el); + + await os.api('i/update', { + mutedInstances: mutes, + }); + + changed.value = false; + + // Refresh filtered list to signal to the user how they've been saved + instanceMutes.value = mutes.join('\n'); +} + +watch(instanceMutes, () => { + changed.value = true; +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.instanceMute, + icon: 'ti ti-planet-off', +}); +</script> diff --git a/packages/frontend/src/pages/settings/integration.vue b/packages/frontend/src/pages/settings/integration.vue new file mode 100644 index 0000000000..557fe778e6 --- /dev/null +++ b/packages/frontend/src/pages/settings/integration.vue @@ -0,0 +1,99 @@ +<template> +<div class="_formRoot"> + <FormSection v-if="instance.enableTwitterIntegration"> + <template #label><i class="ti ti-brand-twitter"></i> Twitter</template> + <p v-if="integrations.twitter">{{ i18n.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" danger @click="disconnectTwitter">{{ i18n.ts.disconnectService }}</MkButton> + <MkButton v-else primary @click="connectTwitter">{{ i18n.ts.connectService }}</MkButton> + </FormSection> + + <FormSection v-if="instance.enableDiscordIntegration"> + <template #label><i class="ti ti-brand-discord"></i> Discord</template> + <p v-if="integrations.discord">{{ i18n.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" danger @click="disconnectDiscord">{{ i18n.ts.disconnectService }}</MkButton> + <MkButton v-else primary @click="connectDiscord">{{ i18n.ts.connectService }}</MkButton> + </FormSection> + + <FormSection v-if="instance.enableGithubIntegration"> + <template #label><i class="ti ti-brand-github"></i> GitHub</template> + <p v-if="integrations.github">{{ i18n.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" danger @click="disconnectGithub">{{ i18n.ts.disconnectService }}</MkButton> + <MkButton v-else primary @click="connectGithub">{{ i18n.ts.connectService }}</MkButton> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import { apiUrl } from '@/config'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import { $i } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const twitterForm = ref<Window | null>(null); +const discordForm = ref<Window | null>(null); +const githubForm = ref<Window | null>(null); + +const integrations = computed(() => $i!.integrations); + +function openWindow(service: string, type: string) { + return window.open(`${apiUrl}/${type}/${service}`, + `${service}_${type}_window`, + 'height=570, width=520', + ); +} + +function connectTwitter() { + twitterForm.value = openWindow('twitter', 'connect'); +} + +function disconnectTwitter() { + openWindow('twitter', 'disconnect'); +} + +function connectDiscord() { + discordForm.value = openWindow('discord', 'connect'); +} + +function disconnectDiscord() { + openWindow('discord', 'disconnect'); +} + +function connectGithub() { + githubForm.value = openWindow('github', 'connect'); +} + +function disconnectGithub() { + openWindow('github', 'disconnect'); +} + +onMounted(() => { + document.cookie = `igi=${$i!.token}; path=/;` + + ' max-age=31536000;' + + (document.location.protocol.startsWith('https') ? ' secure' : ''); + + watch(integrations, () => { + if (integrations.value.twitter) { + if (twitterForm.value) twitterForm.value.close(); + } + if (integrations.value.discord) { + if (discordForm.value) discordForm.value.close(); + } + if (integrations.value.github) { + if (githubForm.value) githubForm.value.close(); + } + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.integration, + icon: 'ti ti-share', +}); +</script> diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue new file mode 100644 index 0000000000..1cf33d34db --- /dev/null +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -0,0 +1,61 @@ +<template> +<div class="_formRoot"> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="mute">{{ i18n.ts.mutedUsers }}</option> + <option value="block">{{ i18n.ts.blockedUsers }}</option> + </MkTab> + <div v-if="tab === 'mute'"> + <MkPagination :pagination="mutingPagination" class="muting"> + <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)"> + <MkAcct :user="mute.mutee"/> + </FormLink> + </template> + </MkPagination> + </div> + <div v-if="tab === 'block'"> + <MkPagination :pagination="blockingPagination" class="blocking"> + <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)"> + <MkAcct :user="block.blockee"/> + </FormLink> + </template> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkTab from '@/components/MkTab.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormLink from '@/components/form/link.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let tab = $ref('mute'); + +const mutingPagination = { + endpoint: 'mute/list' as const, + limit: 10, +}; + +const blockingPagination = { + endpoint: 'blocking/list' as const, + limit: 10, +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.muteAndBlock, + icon: 'ti ti-ban', +}); +</script> diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue new file mode 100644 index 0000000000..0b2776ec90 --- /dev/null +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -0,0 +1,87 @@ +<template> +<div class="_formRoot"> + <FormTextarea v-model="items" tall manual-save class="_formBlock"> + <template #label>{{ i18n.ts.navbar }}</template> + <template #caption><button class="_textButton" @click="addItem">{{ i18n.ts.addItem }}</button></template> + </FormTextarea> + + <FormRadios v-model="menuDisplay" class="_formBlock"> + <template #label>{{ i18n.ts.display }}</template> + <option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option> + <option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option> + <option value="top">{{ i18n.ts._menuDisplay.top }}</option> + <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ i18n.ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: ใตใคใใใผใๅฎๅ
จใซ้ ใใใใใซใใใจใๅฅ้ใใณใใผใฌใผใใฟใณใฎใใใชใใฎใUIใซ่กจ็คบใใๅฟ
่ฆใใใ้ขๅ --> + </FormRadios> + + <FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const items = ref(defaultStore.state.menu.join('\n')); + +const split = computed(() => items.value.trim().split('\n').filter(x => x.trim() !== '')); +const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); + +async function reloadAsk() { + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +async function addItem() { + const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k)); + const { canceled, result: item } = await os.select({ + title: i18n.ts.addItem, + items: [...menu.map(k => ({ + value: k, text: i18n.ts[navbarItemDef[k].title], + })), { + value: '-', text: i18n.ts.divider, + }], + }); + if (canceled) return; + items.value = [...split.value, item].join('\n'); +} + +async function save() { + defaultStore.set('menu', split.value); + await reloadAsk(); +} + +function reset() { + defaultStore.reset('menu'); + items.value = defaultStore.state.menu.join('\n'); +} + +watch(items, async () => { + await save(); +}); + +watch(menuDisplay, async () => { + await reloadAsk(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.navbar, + icon: 'ti ti-list', +}); +</script> diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue new file mode 100644 index 0000000000..e85fede157 --- /dev/null +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -0,0 +1,90 @@ +<template> +<div class="_formRoot"> + <FormLink class="_formBlock" @click="configure"><template #icon><i class="ti ti-settings"></i></template>{{ i18n.ts.notificationSetting }}</FormLink> + <FormSection> + <FormLink class="_formBlock" @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> + <FormLink class="_formBlock" @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink> + <FormLink class="_formBlock" @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormLink> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts.pushNotification }}</template> + <MkPushNotificationAllowButton ref="allowButton" /> + <FormSwitch class="_formBlock" :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:model-value="onChangeSendReadMessage"> + <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> + <template #caption> + <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> + <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template> + </I18n> + </template> + </FormSwitch> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import FormButton from '@/components/MkButton.vue'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; + +let allowButton = $ref<InstanceType<typeof MkPushNotificationAllowButton>>(); +let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer); +let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false); + +async function readAllUnreadNotes() { + await os.api('i/read-all-unread-notes'); +} + +async function readAllMessagingMessages() { + await os.api('i/read-all-messaging-messages'); +} + +async function readAllNotifications() { + await os.api('notifications/mark-all-as-read'); +} + +function configure() { + const includingTypes = notificationTypes.filter(x => !$i!.mutingNotificationTypes.includes(x)); + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.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 => { + $i!.mutingNotificationTypes = i.mutingNotificationTypes; + }); + }, + }, 'closed'); +} + +function onChangeSendReadMessage(v: boolean) { + if (!pushRegistrationInServer) return; + + os.apiWithDialog('sw/update-registration', { + endpoint: pushRegistrationInServer.endpoint, + sendReadMessage: v, + }).then(res => { + if (!allowButton) return; + allowButton.pushRegistrationInServer = res; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.notifications, + icon: 'ti ti-bell', +}); +</script> diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue new file mode 100644 index 0000000000..40bb202789 --- /dev/null +++ b/packages/frontend/src/pages/settings/other.vue @@ -0,0 +1,47 @@ +<template> +<div class="_formRoot"> + <FormSwitch v-model="$i.injectFeaturedNote" class="_formBlock" @update:model-value="onChangeInjectFeaturedNote"> + {{ i18n.ts.showFeaturedNotesInTimeline }} + </FormSwitch> + + <!-- + <FormSwitch v-model="reportError" class="_formBlock">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></FormSwitch> + --> + + <FormLink to="/settings/account-info" class="_formBlock">{{ i18n.ts.accountInfo }}</FormLink> + + <FormLink to="/registry" class="_formBlock"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink> + + <FormLink to="/settings/delete-account" class="_formBlock"><template #icon><i class="ti ti-alert-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormLink from '@/components/form/link.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const reportError = computed(defaultStore.makeGetterSetter('reportError')); + +function onChangeInjectFeaturedNote(v) { + os.api('i/update', { + injectFeaturedNote: v, + }).then((i) => { + $i!.injectFeaturedNote = i.injectFeaturedNote; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.other, + icon: 'ti ti-dots', +}); +</script> diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue new file mode 100644 index 0000000000..550bba242e --- /dev/null +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -0,0 +1,124 @@ +<template> +<div class="_formRoot"> + <FormInfo warn class="_formBlock">{{ i18n.ts._plugin.installWarn }}</FormInfo> + + <FormTextarea v-model="code" tall class="_formBlock"> + <template #label>{{ i18n.ts.code }}</template> + </FormTextarea> + + <div class="_formBlock"> + <FormButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, nextTick, ref } 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 FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const code = ref(null); + +function installPlugin({ id, meta, ast, token }) { + ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ + ...meta, + id, + active: true, + configData: {}, + token: token, + ast: ast, + })); +} + +async function install() { + let ast; + try { + ast = parse(code.value); + } catch (err) { + os.alert({ + type: 'error', + text: 'Syntax error :(', + }); + return; + } + + const meta = AiScript.collectMetadata(ast); + if (meta == null) { + os.alert({ + type: 'error', + text: 'No metadata found :(', + }); + return; + } + + const metadata = meta.get(null); + if (metadata == null) { + os.alert({ + type: 'error', + text: 'No metadata found :(', + }); + return; + } + + const { name, version, author, description, permissions, config } = metadata; + if (name == null || version == null || author == null) { + os.alert({ + type: 'error', + text: 'Required property not found :(', + }); + return; + } + + const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => { + os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { + title: i18n.ts.tokenRequested, + information: i18n.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'); + }); + + installPlugin({ + id: uuid(), + meta: { + name, version, author, description, permissions, config, + }, + token, + ast: serialize(ast), + }); + + os.success(); + + nextTick(() => { + unisonReload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._plugin.install, + icon: 'ti ti-download', +}); +</script> diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue new file mode 100644 index 0000000000..905efd833d --- /dev/null +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -0,0 +1,98 @@ +<template> +<div class="_formRoot"> + <FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink> + + <FormSection> + <template #label>{{ i18n.ts.manage }}</template> + <div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;"> + <span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> + + <FormSwitch class="_formBlock" :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch> + + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ plugin.author }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ plugin.description }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.permission }}</template> + <template #value>{{ plugin.permission }}</template> + </MkKeyValue> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> + <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + </div> + </div> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { nextTick, ref } from 'vue'; +import FormLink from '@/components/form/link.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const plugins = ref(ColdDeviceStorage.get('plugins')); + +function uninstall(plugin) { + ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id)); + os.success(); + nextTick(() => { + unisonReload(); + }); +} + +// TODO: ใใฎๅฆ็ใstoreๅดใซactionใจใใฆ็งปๅใใ่จญๅฎ็ป้ขใ้ใAiScriptAPIใๅฎ่ฃ
ใงใใใใใซใใ +async function 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 coldPlugins = ColdDeviceStorage.get('plugins'); + coldPlugins.find(p => p.id === plugin.id)!.configData = result; + ColdDeviceStorage.set('plugins', coldPlugins); + + nextTick(() => { + location.reload(); + }); +} + +function changeActive(plugin, active) { + const coldPlugins = ColdDeviceStorage.get('plugins'); + coldPlugins.find(p => p.id === plugin.id)!.active = active; + ColdDeviceStorage.set('plugins', coldPlugins); + + nextTick(() => { + location.reload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.plugins, + icon: 'ti ti-plug', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue new file mode 100644 index 0000000000..f427a170c4 --- /dev/null +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -0,0 +1,444 @@ +<template> +<div class="_formRoot"> + <div :class="$style.buttons"> + <MkButton inline primary @click="saveNew">{{ ts._preferencesBackups.saveNew }}</MkButton> + <MkButton inline @click="loadFile">{{ ts._preferencesBackups.loadFile }}</MkButton> + </div> + + <FormSection> + <template #label>{{ ts._preferencesBackups.list }}</template> + <template v-if="profiles && Object.keys(profiles).length > 0"> + <div + v-for="(profile, id) in profiles" + :key="id" + class="_formBlock _panel" + :class="$style.profile" + @click="$event => menu($event, id)" + @contextmenu.prevent.stop="$event => menu($event, id)" + > + <div :class="$style.profileName">{{ profile.name }}</div> + <div :class="$style.profileTime">{{ t('_preferencesBackups.createdAt', { date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div> + <div v-if="profile.updatedAt" :class="$style.profileTime">{{ t('_preferencesBackups.updatedAt', { date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div> + </div> + </template> + <div v-else-if="profiles"> + <MkInfo>{{ ts._preferencesBackups.noBackups }}</MkInfo> + </div> + <MkLoading v-else/> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, useCssModule } from 'vue'; +import { v4 as uuid } from 'uuid'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { stream } from '@/stream'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { version, host } from '@/config'; +import { definePageMetadata } from '@/scripts/page-metadata'; +const { t, ts } = i18n; + +useCssModule(); + +const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ + 'menu', + 'visibility', + 'localOnly', + 'statusbars', + 'widgets', + 'tl', + 'overridedDeviceKind', + 'serverDisconnectedBehavior', + 'nsfw', + 'animation', + 'animatedMfm', + 'loadRawImages', + 'imageNewTab', + 'disableShowingAnimatedImages', + 'disablePagesScript', + 'emojiStyle', + 'disableDrawer', + 'useBlurEffectForModal', + 'useBlurEffect', + 'showFixedPostForm', + 'enableInfiniteScroll', + 'useReactionPickerForContextMenu', + 'showGapBetweenNotesInTimeline', + 'instanceTicker', + 'reactionPickerSize', + 'reactionPickerWidth', + 'reactionPickerHeight', + 'reactionPickerUseDrawerForMobile', + 'defaultSideView', + 'menuDisplay', + 'reportError', + 'squareAvatars', + 'numberOfPageCache', + 'aiChanMode', +]; +const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ + 'lightTheme', + 'darkTheme', + 'syncDeviceDarkMode', + 'plugins', + 'mediaVolume', + 'sound_masterVolume', + 'sound_note', + 'sound_noteMy', + 'sound_notification', + 'sound_chat', + 'sound_chatBg', + 'sound_antenna', + 'sound_channel', +]; + +const scope = ['clientPreferencesProfiles']; + +const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings']; + +type Profile = { + name: string; + createdAt: string; + updatedAt: string | null; + misskeyVersion: string; + host: string; + settings: { + hot: Record<keyof typeof defaultStoreSaveKeys, unknown>; + cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; + fontSize: string | null; + useSystemFont: 't' | null; + wallpaper: string | null; + }; +}; + +const connection = $i && stream.useChannel('main'); + +let profiles = $ref<Record<string, Profile> | null>(null); + +os.api('i/registry/get-all', { scope }) + .then(res => { + profiles = res || {}; + }); + +function isObject(value: unknown): value is Record<string, unknown> { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validate(profile: unknown): void { + if (!isObject(profile)) throw new Error('not an object'); + + // Check if unnecessary properties exist + if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist'); + + if (!profile.name) throw new Error('Missing required prop: name'); + if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion'); + + // Check if createdAt and updatedAt is Date + // https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date + if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt).getTime())) throw new Error('createdAt is falsy or not Date'); + if (profile.updatedAt) { + if (Number.isNaN(new Date(profile.updatedAt).getTime())) { + throw new Error('updatedAt is not Date'); + } + } else if (profile.updatedAt !== null) { + throw new Error('updatedAt is not null'); + } + + if (!profile.settings) throw new Error('Missing required prop: settings'); + if (!isObject(profile.settings)) throw new Error('Invalid prop: settings'); +} + +function getSettings(): Profile['settings'] { + const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>; + for (const key of defaultStoreSaveKeys) { + hot[key] = defaultStore.state[key]; + } + + const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; + for (const key of coldDeviceStorageSaveKeys) { + cold[key] = ColdDeviceStorage.get(key); + } + + return { + hot, + cold, + fontSize: localStorage.getItem('fontSize'), + useSystemFont: localStorage.getItem('useSystemFont') as 't' | null, + wallpaper: localStorage.getItem('wallpaper'), + }; +} + +async function saveNew(): Promise<void> { + if (!profiles) return; + + const { canceled, result: name } = await os.inputText({ + title: ts._preferencesBackups.inputName, + }); + if (canceled) return; + + if (Object.values(profiles).some(x => x.name === name)) { + return os.alert({ + title: ts._preferencesBackups.cannotSave, + text: t('_preferencesBackups.nameAlreadyExists', { name }), + }); + } + + const id = uuid(); + const profile: Profile = { + name, + createdAt: (new Date()).toISOString(), + updatedAt: null, + misskeyVersion: version, + host, + settings: getSettings(), + }; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); +} + +function loadFile(): void { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = false; + input.onchange = async () => { + if (!profiles) return; + if (!input.files || input.files.length === 0) return; + + const file = input.files[0]; + + if (file.type !== 'application/json') { + return os.alert({ + type: 'error', + title: ts._preferencesBackups.cannotLoad, + text: ts._preferencesBackups.invalidFile, + }); + } + + let profile: Profile; + try { + profile = JSON.parse(await file.text()) as unknown as Profile; + validate(profile); + } catch (err) { + return os.alert({ + type: 'error', + title: ts._preferencesBackups.cannotLoad, + text: err?.message, + }); + } + + const id = uuid(); + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); + + // ไธๅฟๅปๆฃ + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari ใงๆญฃๅธธใซๅใใ็บใฎใใพใใชใ + (window as any).__misskey_input_ref__ = input; + + input.click(); +} + +async function applyProfile(id: string): Promise<void> { + if (!profiles) return; + + const profile = profiles[id]; + + const { canceled: cancel1 } = await os.confirm({ + type: 'warning', + title: ts._preferencesBackups.apply, + text: t('_preferencesBackups.applyConfirm', { name: profile.name }), + }); + if (cancel1) return; + + // TODO: ใใผใธใงใณ or ใในใใ้ใฃใใใใใซ่ญฆๅใ่กจ็คบ + + const settings = profile.settings; + + // defaultStore + for (const key of defaultStoreSaveKeys) { + if (settings.hot[key] !== undefined) { + defaultStore.set(key, settings.hot[key]); + } + } + + // coldDeviceStorage + for (const key of coldDeviceStorageSaveKeys) { + if (settings.cold[key] !== undefined) { + ColdDeviceStorage.set(key, settings.cold[key]); + } + } + + // fontSize + if (settings.fontSize) { + localStorage.setItem('fontSize', settings.fontSize); + } else { + localStorage.removeItem('fontSize'); + } + + // useSystemFont + if (settings.useSystemFont) { + localStorage.setItem('useSystemFont', settings.useSystemFont); + } else { + localStorage.removeItem('useSystemFont'); + } + + // wallpaper + if (settings.wallpaper != null) { + localStorage.setItem('wallpaper', settings.wallpaper); + } else { + localStorage.removeItem('wallpaper'); + } + + const { canceled: cancel2 } = await os.confirm({ + type: 'info', + text: ts.reloadToApplySetting, + }); + if (cancel2) return; + + unisonReload(); +} + +async function deleteProfile(id: string): Promise<void> { + if (!profiles) return; + + const { canceled } = await os.confirm({ + type: 'info', + title: ts.delete, + text: t('deleteAreYouSure', { x: profiles[id].name }), + }); + if (canceled) return; + + await os.apiWithDialog('i/registry/remove', { scope, key: id }); + delete profiles[id]; +} + +async function save(id: string): Promise<void> { + if (!profiles) return; + + const { name, createdAt } = profiles[id]; + + const { canceled } = await os.confirm({ + type: 'info', + title: ts._preferencesBackups.save, + text: t('_preferencesBackups.saveConfirm', { name }), + }); + if (canceled) return; + + const profile: Profile = { + name, + createdAt, + updatedAt: (new Date()).toISOString(), + misskeyVersion: version, + host, + settings: getSettings(), + }; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); +} + +async function rename(id: string): Promise<void> { + if (!profiles) return; + + const { canceled: cancel1, result: name } = await os.inputText({ + title: ts._preferencesBackups.inputName, + }); + if (cancel1 || profiles[id].name === name) return; + + if (Object.values(profiles).some(x => x.name === name)) { + return os.alert({ + title: ts._preferencesBackups.cannotSave, + text: t('_preferencesBackups.nameAlreadyExists', { name }), + }); + } + + const registry = Object.assign({}, { ...profiles[id] }); + + const { canceled: cancel2 } = await os.confirm({ + type: 'info', + title: ts._preferencesBackups.rename, + text: t('_preferencesBackups.renameConfirm', { old: registry.name, new: name }), + }); + if (cancel2) return; + + registry.name = name; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry }); +} + +function menu(ev: MouseEvent, profileId: string) { + if (!profiles) return; + + return os.popupMenu([{ + text: ts._preferencesBackups.apply, + icon: 'ti ti-check', + action: () => applyProfile(profileId), + }, { + type: 'a', + text: ts.download, + icon: 'ti ti-download', + href: URL.createObjectURL(new Blob([JSON.stringify(profiles[profileId], null, 2)], { type: 'application/json' })), + download: `${profiles[profileId].name}.json`, + }, null, { + text: ts.rename, + icon: 'ti ti-forms', + action: () => rename(profileId), + }, { + text: ts._preferencesBackups.save, + icon: 'ti ti-device-floppy', + action: () => save(profileId), + }, null, { + text: ts._preferencesBackups.delete, + icon: 'ti ti-trash', + action: () => deleteProfile(profileId), + danger: true, + }], ev.currentTarget ?? ev.target); +} + +onMounted(() => { + // streamingใฎuser storage updateใคใใณใใ็ฃ่ฆใใฆๆดๆฐ + connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => { + if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return; + if (!profiles) return; + + profiles[key] = value; + }); +}); + +onUnmounted(() => { + connection?.off('registryUpdated'); +}); + +definePageMetadata(computed(() => ({ + title: ts.preferencesBackups, + icon: 'ti ti-device-floppy', + bg: 'var(--bg)', +}))); +</script> + +<style lang="scss" module> +.buttons { + display: flex; + gap: var(--margin); + flex-wrap: wrap; +} + +.profile { + padding: 20px; + cursor: pointer; + + &Name { + font-weight: 700; + } + + &Time { + font-size: .85em; + opacity: .7; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue new file mode 100644 index 0000000000..915ca05767 --- /dev/null +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -0,0 +1,100 @@ +<template> +<div class="_formRoot"> + <FormSwitch v-model="isLocked" class="_formBlock" @update:model-value="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></FormSwitch> + <FormSwitch v-if="isLocked" v-model="autoAcceptFollowed" class="_formBlock" @update:model-value="save()">{{ i18n.ts.autoAcceptFollowed }}</FormSwitch> + + <FormSwitch v-model="publicReactions" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.makeReactionsPublic }} + <template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template> + </FormSwitch> + + <FormSelect v-model="ffVisibility" class="_formBlock" @update:model-value="save()"> + <template #label>{{ i18n.ts.ffVisibility }}</template> + <option value="public">{{ i18n.ts._ffVisibility.public }}</option> + <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> + <option value="private">{{ i18n.ts._ffVisibility.private }}</option> + <template #caption>{{ i18n.ts.ffVisibilityDescription }}</template> + </FormSelect> + + <FormSwitch v-model="hideOnlineStatus" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.hideOnlineStatus }} + <template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template> + </FormSwitch> + <FormSwitch v-model="noCrawle" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.noCrawle }} + <template #caption>{{ i18n.ts.noCrawleDescription }}</template> + </FormSwitch> + <FormSwitch v-model="isExplorable" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.makeExplorable }} + <template #caption>{{ i18n.ts.makeExplorableDescription }}</template> + </FormSwitch> + + <FormSection> + <FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:model-value="save()">{{ i18n.ts.rememberNoteVisibility }}</FormSwitch> + <FormFolder v-if="!rememberNoteVisibility" class="_formBlock"> + <template #label>{{ i18n.ts.defaultNoteVisibility }}</template> + <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> + <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> + <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template> + <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template> + + <FormSelect v-model="defaultNoteVisibility" class="_formBlock"> + <option value="public">{{ i18n.ts._visibility.public }}</option> + <option value="home">{{ i18n.ts._visibility.home }}</option> + <option value="followers">{{ i18n.ts._visibility.followers }}</option> + <option value="specified">{{ i18n.ts._visibility.specified }}</option> + </FormSelect> + <FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ i18n.ts._visibility.localOnly }}</FormSwitch> + </FormFolder> + </FormSection> + + <FormSwitch v-model="keepCw" class="_formBlock" @update:model-value="save()">{{ i18n.ts.keepCw }}</FormSwitch> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormSection from '@/components/form/section.vue'; +import FormFolder from '@/components/form/folder.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let isLocked = $ref($i.isLocked); +let autoAcceptFollowed = $ref($i.autoAcceptFollowed); +let noCrawle = $ref($i.noCrawle); +let isExplorable = $ref($i.isExplorable); +let hideOnlineStatus = $ref($i.hideOnlineStatus); +let publicReactions = $ref($i.publicReactions); +let ffVisibility = $ref($i.ffVisibility); + +let defaultNoteVisibility = $computed(defaultStore.makeGetterSetter('defaultNoteVisibility')); +let defaultNoteLocalOnly = $computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly')); +let rememberNoteVisibility = $computed(defaultStore.makeGetterSetter('rememberNoteVisibility')); +let keepCw = $computed(defaultStore.makeGetterSetter('keepCw')); + +function save() { + os.api('i/update', { + isLocked: !!isLocked, + autoAcceptFollowed: !!autoAcceptFollowed, + noCrawle: !!noCrawle, + isExplorable: !!isExplorable, + hideOnlineStatus: !!hideOnlineStatus, + publicReactions: !!publicReactions, + ffVisibility: ffVisibility, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.privacy, + icon: 'ti ti-lock-open', +}); +</script> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue new file mode 100644 index 0000000000..14eeeaaa11 --- /dev/null +++ b/packages/frontend/src/pages/settings/profile.vue @@ -0,0 +1,220 @@ +<template> +<div class="_formRoot"> + <div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> + <div class="avatar"> + <MkAvatar class="avatar" :user="$i" :disable-link="true" @click="changeAvatar"/> + <MkButton primary rounded class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> + </div> + <MkButton primary rounded class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> + </div> + + <FormInput v-model="profile.name" :max="30" manual-save class="_formBlock"> + <template #label>{{ i18n.ts._profile.name }}</template> + </FormInput> + + <FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock"> + <template #label>{{ i18n.ts._profile.description }}</template> + <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> + </FormTextarea> + + <FormInput v-model="profile.location" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.location }}</template> + <template #prefix><i class="ti ti-map-pin"></i></template> + </FormInput> + + <FormInput v-model="profile.birthday" type="date" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.birthday }}</template> + <template #prefix><i class="ti ti-cake"></i></template> + </FormInput> + + <FormSelect v-model="profile.lang" class="_formBlock"> + <template #label>{{ i18n.ts.language }}</template> + <option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option> + </FormSelect> + + <FormSlot class="_formBlock"> + <FormFolder> + <template #icon><i class="ti ti-list"></i></template> + <template #label>{{ i18n.ts._profile.metadataEdit }}</template> + + <div class="_formRoot"> + <FormSplit v-for="(record, i) in fields" :min-width="250" class="_formBlock"> + <FormInput v-model="record.name" small> + <template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template> + </FormInput> + <FormInput v-model="record.value" small> + <template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template> + </FormInput> + </FormSplit> + <MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + </div> + </FormFolder> + <template #caption>{{ i18n.ts._profile.metadataDescription }}</template> + </FormSlot> + + <FormFolder> + <template #label>{{ i18n.ts.advancedSettings }}</template> + + <div class="_formRoot"> + <FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch> + <FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch> + </div> + </FormFolder> + + <FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch> +</div> +</template> + +<script lang="ts" setup> +import { reactive, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import FormInput from '@/components/form/input.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormSlot from '@/components/form/slot.vue'; +import { host } from '@/config'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { langmap } from '@/scripts/langmap'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const profile = reactive({ + name: $i.name, + description: $i.description, + location: $i.location, + birthday: $i.birthday, + lang: $i.lang, + isBot: $i.isBot, + isCat: $i.isCat, + showTimelineReplies: $i.showTimelineReplies, +}); + +watch(() => profile, () => { + save(); +}, { + deep: true, +}); + +const fields = reactive($i.fields.map(field => ({ name: field.name, value: field.value }))); + +function addField() { + fields.push({ + name: '', + value: '', + }); +} + +while (fields.length < 4) { + addField(); +} + +function saveFields() { + os.apiWithDialog('i/update', { + fields: fields.filter(field => field.name !== '' && field.value !== ''), + }); +} + +function save() { + os.apiWithDialog('i/update', { + name: profile.name || null, + description: profile.description || null, + location: profile.location || null, + birthday: profile.birthday || null, + lang: profile.lang || null, + isBot: !!profile.isBot, + isCat: !!profile.isCat, + showTimelineReplies: !!profile.showTimelineReplies, + }); +} + +function changeAvatar(ev) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 1, + }); + } + + const i = await os.apiWithDialog('i/update', { + avatarId: originalOrCropped.id, + }); + $i.avatarId = i.avatarId; + $i.avatarUrl = i.avatarUrl; + }); +} + +function changeBanner(ev) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 2, + }); + } + + const i = await os.apiWithDialog('i/update', { + bannerId: originalOrCropped.id, + }); + $i.bannerId = i.bannerId; + $i.bannerUrl = i.bannerUrl; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.profile, + icon: 'ti ti-user', +}); +</script> + +<style lang="scss" scoped> +.llvierxe { + position: relative; + background-size: cover; + background-position: center; + border: solid 1px var(--divider); + border-radius: 10px; + overflow: clip; + + > .avatar { + display: inline-block; + text-align: center; + padding: 16px; + + > .avatar { + display: inline-block; + width: 72px; + height: 72px; + margin: 0 auto 16px auto; + } + } + + > .bannerEdit { + position: absolute; + top: 16px; + right: 16px; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/reaction.vue b/packages/frontend/src/pages/settings/reaction.vue new file mode 100644 index 0000000000..2748cd7d4e --- /dev/null +++ b/packages/frontend/src/pages/settings/reaction.vue @@ -0,0 +1,154 @@ +<template> +<div class="_formRoot"> + <FromSlot class="_formBlock"> + <template #label>{{ i18n.ts.reactionSettingDescription }}</template> + <div v-panel style="border-radius: 6px;"> + <Sortable v-model="reactions" class="zoaiodol" :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="ti ti-plus"></i></button> + </template> + </Sortable> + </div> + <template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template> + </FromSlot> + + <FormRadios v-model="reactionPickerSize" class="_formBlock"> + <template #label>{{ i18n.ts.size }}</template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + </FormRadios> + <FormRadios v-model="reactionPickerWidth" class="_formBlock"> + <template #label>{{ i18n.ts.numberOfColumn }}</template> + <option :value="1">5</option> + <option :value="2">6</option> + <option :value="3">7</option> + <option :value="4">8</option> + <option :value="5">9</option> + </FormRadios> + <FormRadios v-model="reactionPickerHeight" class="_formBlock"> + <template #label>{{ i18n.ts.height }}</template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + <option :value="4">{{ i18n.ts.large }}+</option> + </FormRadios> + + <FormSwitch v-model="reactionPickerUseDrawerForMobile" class="_formBlock"> + {{ i18n.ts.useDrawerReactionPickerForMobile }} + <template #caption>{{ i18n.ts.needReloadToApply }}</template> + </FormSwitch> + + <FormSection> + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton> + <FormButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton> + </div> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, watch } from 'vue'; +import Sortable from 'vuedraggable'; +import FormInput from '@/components/form/input.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FromSlot from '@/components/form/slot.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { deepClone } from '@/scripts/clone'; + +let reactions = $ref(deepClone(defaultStore.state.reactions)); + +const reactionPickerSize = $computed(defaultStore.makeGetterSetter('reactionPickerSize')); +const reactionPickerWidth = $computed(defaultStore.makeGetterSetter('reactionPickerWidth')); +const reactionPickerHeight = $computed(defaultStore.makeGetterSetter('reactionPickerHeight')); +const reactionPickerUseDrawerForMobile = $computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile')); + +function save() { + defaultStore.set('reactions', reactions); +} + +function remove(reaction, ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.remove, + action: () => { + reactions = reactions.filter(x => x !== reaction); + }, + }], ev.currentTarget ?? ev.target); +} + +function preview(ev: MouseEvent) { + os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + asReactionPicker: true, + src: ev.currentTarget ?? ev.target, + }, {}, 'closed'); +} + +async function setDefault() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.resetAreYouSure, + }); + if (canceled) return; + + reactions = deepClone(defaultStore.def.reactions.default); +} + +function chooseEmoji(ev: MouseEvent) { + os.pickEmoji(ev.currentTarget ?? ev.target, { + showPinned: false, + }).then(emoji => { + if (!reactions.includes(emoji)) { + reactions.push(emoji); + } + }); +} + +watch($$(reactions), () => { + save(); +}, { + deep: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.reaction, + icon: 'ti ti-mood-happy', + action: { + icon: 'ti ti-eye', + handler: preview, + }, +}); +</script> + +<style lang="scss" scoped> +.zoaiodol { + padding: 12px; + font-size: 1.1em; + + > .item { + display: inline-block; + padding: 8px; + cursor: move; + } + + > .add { + display: inline-block; + padding: 8px; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue new file mode 100644 index 0000000000..33f49eb3ef --- /dev/null +++ b/packages/frontend/src/pages/settings/security.vue @@ -0,0 +1,160 @@ +<template> +<div class="_formRoot"> + <FormSection> + <template #label>{{ i18n.ts.password }}</template> + <FormButton primary @click="change()">{{ i18n.ts.changePassword }}</FormButton> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.twoStepAuthentication }}</template> + <X2fa/> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.signinHistory }}</template> + <MkPagination :pagination="pagination" disable-auto-load> + <template #default="{items}"> + <div> + <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> + <header> + <i v-if="item.success" class="ti ti-check icon succ"></i> + <i v-else class="ti ti-circle-x icon fail"></i> + <code class="ip _monospace">{{ item.ip }}</code> + <MkTime :time="item.createdAt" class="time"/> + </header> + </div> + </div> + </template> + </MkPagination> + </FormSection> + + <FormSection> + <FormSlot> + <FormButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> {{ i18n.ts.regenerateLoginToken }}</FormButton> + <template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template> + </FormSlot> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import X2fa from './2fa.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSlot from '@/components/form/slot.vue'; +import FormButton from '@/components/MkButton.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'i/signin-history' as const, + limit: 5, +}; + +async function change() { + const { canceled: canceled1, result: currentPassword } = await os.inputText({ + title: i18n.ts.currentPassword, + type: 'password', + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await os.inputText({ + title: i18n.ts.newPassword, + type: 'password', + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await os.inputText({ + title: i18n.ts.newPasswordRetype, + type: 'password', + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + os.alert({ + type: 'error', + text: i18n.ts.retypedNotMatch, + }); + return; + } + + os.apiWithDialog('i/change-password', { + currentPassword, + newPassword, + }); +} + +function regenerateToken() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/regenerate_token', { + password: password, + }); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.security, + icon: 'ti ti-lock', +}); +</script> + +<style lang="scss" scoped> +.timnmucd { + padding: 16px; + + &:first-child { + border-top-left-radius: 6px; + border-top-right-radius: 6px; + } + + &:last-child { + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + } + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + + > 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/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue new file mode 100644 index 0000000000..62627c6333 --- /dev/null +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -0,0 +1,45 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="type"> + <template #label>{{ i18n.ts.sound }}</template> + <option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option> + </FormSelect> + <FormRange v-model="volume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock"> + <template #label>{{ i18n.ts.volume }}</template> + </FormRange> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton inline @click="listen"><i class="ti ti-player-play"></i> {{ i18n.ts.listen }}</FormButton> + <FormButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSelect from '@/components/form/select.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormRange from '@/components/form/range.vue'; +import { i18n } from '@/i18n'; +import { playFile, soundsTypes } from '@/scripts/sound'; + +const props = defineProps<{ + type: string; + volume: number; +}>(); + +const emit = defineEmits<{ + (ev: 'update', result: { type: string; volume: number; }): void; +}>(); + +let type = $ref(props.type); +let volume = $ref(props.volume); + +function listen() { + playFile(type, volume); +} + +function save() { + emit('update', { type, volume }); +} +</script> diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue new file mode 100644 index 0000000000..ef60b2c3c9 --- /dev/null +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -0,0 +1,82 @@ +<template> +<div class="_formRoot"> + <FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock"> + <template #label>{{ i18n.ts.masterVolume }}</template> + </FormRange> + + <FormSection> + <template #label>{{ i18n.ts.sounds }}</template> + <FormFolder v-for="type in Object.keys(sounds)" :key="type" style="margin-bottom: 8px;"> + <template #label>{{ $t('_sfx.' + type) }}</template> + <template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template> + + <XSound :type="sounds[type].type" :volume="sounds[type].volume" @update="(res) => updated(type, res)"/> + </FormFolder> + </FormSection> + + <FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import XSound from './sounds.sound.vue'; +import FormRange from '@/components/form/range.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormFolder from '@/components/form/folder.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { playFile } from '@/scripts/sound'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const masterVolume = computed({ + get: () => { + return ColdDeviceStorage.get('sound_masterVolume'); + }, + set: (value) => { + ColdDeviceStorage.set('sound_masterVolume', value); + }, +}); + +const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up'); + +const sounds = ref({ + note: ColdDeviceStorage.get('sound_note'), + noteMy: ColdDeviceStorage.get('sound_noteMy'), + notification: ColdDeviceStorage.get('sound_notification'), + chat: ColdDeviceStorage.get('sound_chat'), + chatBg: ColdDeviceStorage.get('sound_chatBg'), + antenna: ColdDeviceStorage.get('sound_antenna'), + channel: ColdDeviceStorage.get('sound_channel'), +}); + +async function updated(type, sound) { + const v = { + type: sound.type, + volume: sound.volume, + }; + + ColdDeviceStorage.set('sound_' + type, v); + sounds.value[type] = v; +} + +function reset() { + for (const sound of Object.keys(sounds.value)) { + const v = ColdDeviceStorage.default['sound_' + sound]; + ColdDeviceStorage.set('sound_' + sound, v); + sounds.value[sound] = v; + } +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.sounds, + icon: 'ti ti-music', +}); +</script> diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue new file mode 100644 index 0000000000..608222386e --- /dev/null +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -0,0 +1,140 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock"> + <template #label>{{ i18n.ts.type }}</template> + <option value="rss">RSS</option> + <option value="federation">Federation</option> + <option value="userList">User list timeline</option> + </FormSelect> + + <MkInput v-model="statusbar.name" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.label }}</template> + </MkInput> + + <MkSwitch v-model="statusbar.black" class="_formBlock"> + <template #label>Black</template> + </MkSwitch> + + <FormRadios v-model="statusbar.size" class="_formBlock"> + <template #label>{{ i18n.ts.size }}</template> + <option value="verySmall">{{ i18n.ts.small }}+</option> + <option value="small">{{ i18n.ts.small }}</option> + <option value="medium">{{ i18n.ts.medium }}</option> + <option value="large">{{ i18n.ts.large }}</option> + <option value="veryLarge">{{ i18n.ts.large }}+</option> + </FormRadios> + + <template v-if="statusbar.type === 'rss'"> + <MkInput v-model="statusbar.props.url" manual-save class="_formBlock" type="url"> + <template #label>URL</template> + </MkInput> + <MkSwitch v-model="statusbar.props.shuffle" class="_formBlock"> + <template #label>{{ i18n.ts.shuffle }}</template> + </MkSwitch> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'federation'"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + <MkSwitch v-model="statusbar.props.colored" class="_formBlock"> + <template #label>{{ i18n.ts.colored }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'userList' && userLists != null"> + <FormSelect v-model="statusbar.props.userListId" class="_formBlock"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in userLists" :value="list.id">{{ list.name }}</option> + </FormSelect> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, reactive, ref, watch } from 'vue'; +import FormSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormRange from '@/components/form/range.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { deepClone } from '@/scripts/clone'; + +const props = defineProps<{ + _id: string; + userLists: any[] | null; +}>(); + +const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id))); + +watch(() => statusbar.type, () => { + if (statusbar.type === 'rss') { + statusbar.name = 'NEWS'; + statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'; + statusbar.props.shuffle = true; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } else if (statusbar.type === 'federation') { + statusbar.name = 'FEDERATION'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + statusbar.props.colored = false; + } else if (statusbar.type === 'userList') { + statusbar.name = 'LIST TL'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } +}); + +watch(statusbar, save); + +async function save() { + const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id); + const statusbars = deepClone(defaultStore.state.statusbars); + statusbars[i] = deepClone(statusbar); + defaultStore.set('statusbars', statusbars); +} + +function del() { + defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id)); +} +</script> diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue new file mode 100644 index 0000000000..86c69fa2c3 --- /dev/null +++ b/packages/frontend/src/pages/settings/statusbar.vue @@ -0,0 +1,54 @@ +<template> +<div class="_formRoot"> + <FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock"> + <template #label>{{ x.type ?? i18n.ts.notSet }}</template> + <template #suffix>{{ x.name }}</template> + <XStatusbar :_id="x.id" :user-lists="userLists"/> + </FormFolder> + <FormButton primary @click="add">{{ i18n.ts.add }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XStatusbar from './statusbar.statusbar.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const statusbars = defaultStore.reactiveState.statusbars; + +let userLists = $ref(); + +onMounted(() => { + os.api('users/lists/list').then(res => { + userLists = res; + }); +}); + +async function add() { + defaultStore.push('statusbars', { + id: uuid(), + type: null, + black: false, + size: 'medium', + props: {}, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.statusbar, + icon: 'ti ti-list', + bg: 'var(--bg)', +}); +</script> diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue new file mode 100644 index 0000000000..52a436e18d --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -0,0 +1,80 @@ +<template> +<div class="_formRoot"> + <FormTextarea v-model="installThemeCode" class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + </FormTextarea> + + <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton> + <FormButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormButton from '@/components/MkButton.vue'; +import { applyTheme, validateTheme } from '@/scripts/theme'; +import * as os from '@/os'; +import { addTheme, getThemes } from '@/theme-store'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let installThemeCode = $ref(null); + +function parseThemeCode(code: string) { + let theme; + + try { + theme = JSON5.parse(code); + } catch (err) { + os.alert({ + type: 'error', + text: i18n.ts._theme.invalid, + }); + return false; + } + if (!validateTheme(theme)) { + os.alert({ + type: 'error', + text: i18n.ts._theme.invalid, + }); + return false; + } + if (getThemes().some(t => t.id === theme.id)) { + os.alert({ + type: 'info', + text: i18n.ts._theme.alreadyInstalled, + }); + return false; + } + + return theme; +} + +function preview(code: string): void { + const theme = parseThemeCode(code); + if (theme) applyTheme(theme, false); +} + +async function install(code: string): Promise<void> { + const theme = parseThemeCode(code); + if (!theme) return; + await addTheme(theme); + os.alert({ + type: 'success', + text: i18n.t('_theme.installed', { name: theme.name }), + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._theme.install, + icon: 'ti ti-download', +}); +</script> diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue new file mode 100644 index 0000000000..409f0af650 --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -0,0 +1,78 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="selectedThemeId" class="_formBlock"> + <template #label>{{ i18n.ts.theme }}</template> + <optgroup :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <template v-if="selectedTheme"> + <FormInput readonly :model-value="selectedTheme.author" class="_formBlock"> + <template #label>{{ i18n.ts.author }}</template> + </FormInput> + <FormTextarea v-if="selectedTheme.desc" readonly :model-value="selectedTheme.desc" class="_formBlock"> + <template #label>{{ i18n.ts._theme.description }}</template> + </FormTextarea> + <FormTextarea readonly tall :model-value="selectedThemeCode" class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + <template #caption><button class="_textButton" @click="copyThemeCode()">{{ i18n.ts.copy }}</button></template> + </FormTextarea> + <FormButton v-if="!builtinThemes.some(t => t.id == selectedTheme.id)" class="_formBlock" danger @click="uninstall()"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</FormButton> + </template> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import { Theme, getBuiltinThemesRef } from '@/scripts/theme'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; +import { getThemes, removeTheme } from '@/theme-store'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const installedThemes = ref(getThemes()); +const builtinThemes = getBuiltinThemesRef(); +const selectedThemeId = ref(null); + +const themes = computed(() => [...installedThemes.value, ...builtinThemes.value]); + +const selectedTheme = computed(() => { + if (selectedThemeId.value == null) return null; + return themes.value.find(x => x.id === selectedThemeId.value); +}); + +const selectedThemeCode = computed(() => { + if (selectedTheme.value == null) return null; + return JSON5.stringify(selectedTheme.value, null, '\t'); +}); + +function copyThemeCode() { + copyToClipboard(selectedThemeCode.value); + os.success(); +} + +function uninstall() { + removeTheme(selectedTheme.value as Theme); + installedThemes.value = installedThemes.value.filter(t => t.id !== selectedThemeId.value); + selectedThemeId.value = null; + os.success(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._theme.manage, + icon: 'ti ti-tool', +}); +</script> diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue new file mode 100644 index 0000000000..f37c213b06 --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.vue @@ -0,0 +1,409 @@ +<template> +<div class="_formRoot rsljpzjq"> + <div v-adaptive-border class="rfqxtzch _panel _formBlock"> + <div class="toggle"> + <div class="toggleWrapper"> + <input id="dn" v-model="darkMode" type="checkbox" class="dn"/> + <label for="dn" class="toggle"> + <span class="before">{{ i18n.ts.light }}</span> + <span class="after">{{ i18n.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 class="sync"> + <FormSwitch v-model="syncDeviceDarkMode">{{ i18n.ts.syncDeviceDarkMode }}</FormSwitch> + </div> + </div> + + <div class="selects _formBlock"> + <FormSelect v-model="lightThemeId" large class="select"> + <template #label>{{ i18n.ts.themeForLightMode }}</template> + <template #prefix><i class="ti ti-sun"></i></template> + <option v-if="instanceLightTheme" :key="'instance:' + instanceLightTheme.id" :value="instanceLightTheme.id">{{ instanceLightTheme.name }}</option> + <optgroup v-if="installedLightThemes.length > 0" :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedLightThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinLightThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model="darkThemeId" large class="select"> + <template #label>{{ i18n.ts.themeForDarkMode }}</template> + <template #prefix><i class="ti ti-moon"></i></template> + <option v-if="instanceDarkTheme" :key="'instance:' + instanceDarkTheme.id" :value="instanceDarkTheme.id">{{ instanceDarkTheme.name }}</option> + <optgroup v-if="installedDarkThemes.length > 0" :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedDarkThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinDarkThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + </div> + + <FormSection> + <div class="_formLinksGrid"> + <FormLink to="/settings/theme/manage"><template #icon><i class="ti ti-tool"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> + <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="ti ti-world"></i></template>{{ i18n.ts._theme.explore }}</FormLink> + <FormLink to="/settings/theme/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._theme.install }}</FormLink> + <FormLink to="/theme-editor"><template #icon><i class="ti ti-paint"></i></template>{{ i18n.ts._theme.make }}</FormLink> + </div> + </FormSection> + + <FormButton v-if="wallpaper == null" class="_formBlock" @click="setWallpaper">{{ i18n.ts.setWallpaper }}</FormButton> + <FormButton v-else class="_formBlock" @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, onActivated, ref, watch } from 'vue'; +import JSON5 from 'json5'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import FormButton from '@/components/MkButton.vue'; +import { getBuiltinThemesRef } from '@/scripts/theme'; +import { selectFile } from '@/scripts/select-file'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { uniqueBy } from '@/scripts/array'; +import { fetchThemes, getThemes } from '@/theme-store'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const installedThemes = ref(getThemes()); +const builtinThemes = getBuiltinThemesRef(); + +const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); +const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); +const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); +const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); +const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); +const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); +const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id)); + +const darkTheme = ColdDeviceStorage.ref('darkTheme'); +const darkThemeId = computed({ + get() { + return darkTheme.value.id; + }, + set(id) { + const t = themes.value.find(x => x.id === id); + if (t) { // ใใผใใจใใฃใฟใงใใผใใไฝๆใใใจใใชใฉใฏใthemesใซๅๆ ใใใชใใใ undefined ใซใชใ + ColdDeviceStorage.set('darkTheme', t); + } + }, +}); +const lightTheme = ColdDeviceStorage.ref('lightTheme'); +const lightThemeId = computed({ + get() { + return lightTheme.value.id; + }, + set(id) { + const t = themes.value.find(x => x.id === id); + if (t) { // ใใผใใจใใฃใฟใงใใผใใไฝๆใใใจใใชใฉใฏใthemesใซๅๆ ใใใชใใใ undefined ใซใชใ + ColdDeviceStorage.set('lightTheme', t); + } + }, +}); +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.value) { + defaultStore.set('darkMode', isDeviceDarkmode()); + } +}); + +watch(wallpaper, () => { + if (wallpaper.value == null) { + localStorage.removeItem('wallpaper'); + } else { + localStorage.setItem('wallpaper', wallpaper.value); + } + location.reload(); +}); + +onActivated(() => { + fetchThemes().then(() => { + installedThemes.value = getThemes(); + }); +}); + +fetchThemes().then(() => { + installedThemes.value = getThemes(); +}); + +function setWallpaper(event) { + selectFile(event.currentTarget ?? event.target, null).then(file => { + wallpaper.value = file.url; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.theme, + icon: 'ti ti-palette', +}); +</script> + +<style lang="scss" scoped> +.rfqxtzch { + border-radius: 6px; + + > .toggle { + position: relative; + padding: 26px 0; + text-align: center; + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + + > .toggleWrapper { + display: inline-block; + text-align: left; + overflow: clip; + padding: 0 100px; + vertical-align: bottom; + + 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; + 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; + } + } + } + } + + > .sync { + padding: 14px 16px; + border-top: solid 0.5px var(--divider); + } +} + +.rsljpzjq { + > .selects { + display: flex; + gap: 1.5em var(--margin); + flex-wrap: wrap; + + > .select { + flex: 1; + min-width: 280px; + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue new file mode 100644 index 0000000000..c8ec1ea586 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -0,0 +1,95 @@ +<template> +<div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>Name</template> + </FormInput> + + <FormInput v-model="url" type="url" class="_formBlock"> + <template #label>URL</template> + </FormInput> + + <FormInput v-model="secret" class="_formBlock"> + <template #prefix><i class="ti ti-lock"></i></template> + <template #label>Secret</template> + </FormInput> + + <FormSection> + <template #label>Events</template> + + <FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch> + <FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch> + <FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch> + <FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch> + <FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch> + <FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch> + <FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch> + </FormSection> + + <FormSwitch v-model="active" class="_formBlock">Active</FormSwitch> + + <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton primary inline @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormInput from '@/components/form/input.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + webhookId: string; +}>(); + +const webhook = await os.api('i/webhooks/show', { + webhookId: props.webhookId, +}); + +let name = $ref(webhook.name); +let url = $ref(webhook.url); +let secret = $ref(webhook.secret); +let active = $ref(webhook.active); + +let event_follow = $ref(webhook.on.includes('follow')); +let event_followed = $ref(webhook.on.includes('followed')); +let event_note = $ref(webhook.on.includes('note')); +let event_reply = $ref(webhook.on.includes('reply')); +let event_renote = $ref(webhook.on.includes('renote')); +let event_reaction = $ref(webhook.on.includes('reaction')); +let event_mention = $ref(webhook.on.includes('mention')); + +async function save(): Promise<void> { + const events = []; + if (event_follow) events.push('follow'); + if (event_followed) events.push('followed'); + if (event_note) events.push('note'); + if (event_reply) events.push('reply'); + if (event_renote) events.push('renote'); + if (event_reaction) events.push('reaction'); + if (event_mention) events.push('mention'); + + os.apiWithDialog('i/webhooks/update', { + name, + url, + secret, + webhookId: props.webhookId, + on: events, + active, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Edit webhook', + icon: 'ti ti-webhook', +}); +</script> diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue new file mode 100644 index 0000000000..00a547da69 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -0,0 +1,82 @@ +<template> +<div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>Name</template> + </FormInput> + + <FormInput v-model="url" type="url" class="_formBlock"> + <template #label>URL</template> + </FormInput> + + <FormInput v-model="secret" class="_formBlock"> + <template #prefix><i class="ti ti-lock"></i></template> + <template #label>Secret</template> + </FormInput> + + <FormSection> + <template #label>Events</template> + + <FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch> + <FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch> + <FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch> + <FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch> + <FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch> + <FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch> + <FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch> + </FormSection> + + <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton primary inline @click="create"><i class="ti ti-check"></i> {{ i18n.ts.create }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormInput from '@/components/form/input.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let name = $ref(''); +let url = $ref(''); +let secret = $ref(''); + +let event_follow = $ref(true); +let event_followed = $ref(true); +let event_note = $ref(true); +let event_reply = $ref(true); +let event_renote = $ref(true); +let event_reaction = $ref(true); +let event_mention = $ref(true); + +async function create(): Promise<void> { + const events = []; + if (event_follow) events.push('follow'); + if (event_followed) events.push('followed'); + if (event_note) events.push('note'); + if (event_reply) events.push('reply'); + if (event_renote) events.push('renote'); + if (event_reaction) events.push('reaction'); + if (event_mention) events.push('mention'); + + os.apiWithDialog('i/webhooks/create', { + name, + url, + secret, + on: events, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Create new webhook', + icon: 'ti ti-webhook', +}); +</script> diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue new file mode 100644 index 0000000000..9be23ee4f0 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.vue @@ -0,0 +1,53 @@ +<template> +<div class="_formRoot"> + <FormSection> + <FormLink :to="`/settings/webhook/new`"> + Create webhook + </FormLink> + </FormSection> + + <FormSection> + <MkPagination :pagination="pagination"> + <template #default="{items}"> + <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`" class="_formBlock"> + <template #icon> + <i v-if="webhook.active === false" class="ti ti-player-pause"></i> + <i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i> + <i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i> + <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i> + </template> + {{ webhook.name || webhook.url }} + <template #suffix> + <MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime> + </template> + </FormLink> + </template> + </MkPagination> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'i/webhooks/list' as const, + limit: 10, +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Webhook', + icon: 'ti ti-webhook', +}); +</script> diff --git a/packages/frontend/src/pages/settings/word-mute.vue b/packages/frontend/src/pages/settings/word-mute.vue new file mode 100644 index 0000000000..6961d8151d --- /dev/null +++ b/packages/frontend/src/pages/settings/word-mute.vue @@ -0,0 +1,128 @@ +<template> +<div class="_formRoot"> + <MkTab v-model="tab" class="_formBlock"> + <option value="soft">{{ i18n.ts._wordMute.soft }}</option> + <option value="hard">{{ i18n.ts._wordMute.hard }}</option> + </MkTab> + <div class="_formBlock"> + <div v-show="tab === 'soft'"> + <MkInfo class="_formBlock">{{ i18n.ts._wordMute.softDescription }}</MkInfo> + <FormTextarea v-model="softMutedWords" class="_formBlock"> + <span>{{ i18n.ts._wordMute.muteWords }}</span> + <template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + </div> + <div v-show="tab === 'hard'"> + <MkInfo class="_formBlock">{{ i18n.ts._wordMute.hardDescription }} {{ i18n.ts.reflectMayTakeTime }}</MkInfo> + <FormTextarea v-model="hardMutedWords" class="_formBlock"> + <span>{{ i18n.ts._wordMute.muteWords }}</span> + <template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + <MkKeyValue v-if="hardWordMutedNotesCount != null" class="_formBlock"> + <template #key>{{ i18n.ts._wordMute.mutedNotes }}</template> + <template #value>{{ number(hardWordMutedNotesCount) }}</template> + </MkKeyValue> + </div> + </div> + <MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkTab from '@/components/MkTab.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const render = (mutedWords) => mutedWords.map(x => { + if (Array.isArray(x)) { + return x.join(' '); + } else { + return x; + } +}).join('\n'); + +const tab = ref('soft'); +const softMutedWords = ref(render(defaultStore.state.mutedWords)); +const hardMutedWords = ref(render($i!.mutedWords)); +const hardWordMutedNotesCount = ref(null); +const changed = ref(false); + +os.api('i/get-word-muted-notes-count', {}).then(response => { + hardWordMutedNotesCount.value = response?.count; +}); + +watch(softMutedWords, () => { + changed.value = true; +}); + +watch(hardMutedWords, () => { + changed.value = true; +}); + +async function save() { + const parseMutes = (mutes, tab) => { + // split into lines, remove empty lines and unnecessary whitespace + let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== ''); + + // check each line if it is a RegExp or not + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const regexp = line.match(/^\/(.+)\/(.*)$/); + if (regexp) { + // check that the RegExp is valid + try { + new RegExp(regexp[1], regexp[2]); + // note that regex lines will not be split by spaces! + } catch (err: any) { + // invalid syntax: do not save, do not reset changed flag + os.alert({ + type: 'error', + title: i18n.ts.regexpError, + text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(), + }); + // re-throw error so these invalid settings are not saved + throw err; + } + } else { + lines[i] = line.split(' '); + } + } + + return lines; + }; + + let softMutes, hardMutes; + try { + softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft); + hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard); + } catch (err) { + // already displayed error message in parseMutes + return; + } + + defaultStore.set('mutedWords', softMutes); + await os.api('i/update', { + mutedWords: hardMutes, + }); + + changed.value = false; +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.wordMute, + icon: 'ti ti-message-off', +}); +</script> diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue new file mode 100644 index 0000000000..a7e797eeab --- /dev/null +++ b/packages/frontend/src/pages/share.vue @@ -0,0 +1,169 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XPostForm + v-if="state === 'writing'" + fixed + :instant="true" + :initial-text="initialText" + :initial-visibility="visibility" + :initial-files="files" + :initial-local-only="localOnly" + :reply="reply" + :renote="renote" + :initial-visible-users="visibleUsers" + class="_panel" + @posted="state = 'posted'" + /> + <MkButton v-else-if="state === 'posted'" primary class="close" @click="close()">{{ i18n.ts.close }}</MkButton> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html + +import { } from 'vue'; +import { noteVisibilities } from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import XPostForm from '@/components/MkPostForm.vue'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const urlParams = new URLSearchParams(window.location.search); +const localOnlyQuery = urlParams.get('localOnly'); +const visibilityQuery = urlParams.get('visibility'); + +let state = $ref('fetching' as 'fetching' | 'writing' | 'posted'); +let title = $ref(urlParams.get('title')); +const text = urlParams.get('text'); +const url = urlParams.get('url'); +let initialText = $ref(null as string | null); +let reply = $ref(null as Misskey.entities.Note | null); +let renote = $ref(null as Misskey.entities.Note | null); +let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : null); +let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : null); +let files = $ref([] as Misskey.entities.DriveFile[]); +let visibleUsers = $ref([] as Misskey.entities.User[]); + +async function init() { + let noteText = ''; + if (title) noteText += `[ ${title} ]\n`; + // Googleใใฅใผในๅฏพ็ญ + if (text?.startsWith(`${title}.\n`)) noteText += text.replace(`${title}.\n`, ''); + else if (text && title !== text) noteText += `${text}\n`; + if (url) noteText += `${url}`; + initialText = noteText.trim(); + + if (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 => { + visibleUsers.push(user); + }, () => { + console.error(`Invalid user query: ${JSON.stringify(q)}`); + }), + ), + ); + } + + try { + //#region Reply + const replyId = urlParams.get('replyId'); + const replyUri = urlParams.get('replyUri'); + if (replyId) { + reply = await os.api('notes/show', { + noteId: replyId, + }); + } else if (replyUri) { + const obj = await os.api('ap/show', { + uri: replyUri, + }); + if (obj.type === 'Note') { + reply = obj.object; + } + } + //#endregion + + //#region Renote + const renoteId = urlParams.get('renoteId'); + const renoteUri = urlParams.get('renoteUri'); + if (renoteId) { + renote = await os.api('notes/show', { + noteId: renoteId, + }); + } else if (renoteUri) { + const obj = await os.api('ap/show', { + uri: renoteUri, + }); + if (obj.type === 'Note') { + 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 => { + files.push(file); + }, () => { + console.error(`Failed to fetch a file ${fileId}`); + }), + ), + ); + } + //#endregion + } catch (err) { + os.alert({ + type: 'error', + title: err.message, + text: err.name, + }); + } + + state = 'writing'; +} + +init(); + +function close(): void { + window.close(); + + // ้ใใชใใใฐ100msๅพใฟใคใ ใฉใคใณใซ + window.setTimeout(() => { + mainRouter.push('/'); + }, 100); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.share, + icon: 'ti ti-share', +}); +</script> + +<style lang="scss" scoped> +.close { + margin: 16px auto; +} +</style> diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue new file mode 100644 index 0000000000..5459532310 --- /dev/null +++ b/packages/frontend/src/pages/signup-complete.vue @@ -0,0 +1,41 @@ +<template> +<div> + {{ i18n.ts.processing }} +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as os from '@/os'; +import { login } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + code: string; +}>(); + +onMounted(async () => { + await os.alert({ + type: 'info', + text: i18n.t('clickToFinishEmailVerification', { ok: i18n.ts.gotIt }), + }); + const res = await os.apiWithDialog('signup-pending', { + code: props.code, + }); + login(res.i, '/'); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.signup, + icon: 'ti ti-user', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue new file mode 100644 index 0000000000..72775ed5c9 --- /dev/null +++ b/packages/frontend/src/pages/tag.vue @@ -0,0 +1,35 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XNotes class="_content" :pagination="pagination"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XNotes from '@/components/MkNotes.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + tag: string; +}>(); + +const pagination = { + endpoint: 'notes/search-by-tag' as const, + limit: 10, + params: computed(() => ({ + tag: props.tag, + })), +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: props.tag, + icon: 'ti ti-hash', +}))); +</script> diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue new file mode 100644 index 0000000000..d8ff170ca2 --- /dev/null +++ b/packages/frontend/src/pages/theme-editor.vue @@ -0,0 +1,283 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <div class="cwepdizn _formRoot"> + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.backgroundColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(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" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> + </div> + </FormFolder> + + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.accentColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)"> + <div class="preview" :style="{ background: color }"></div> + </button> + </div> + </div> + </FormFolder> + + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.textColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)"> + <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div> + </button> + </div> + </div> + </FormFolder> + + <FormFolder :default-open="false" class="_formBlock"> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts.editCode }}</template> + + <div class="_formRoot"> + <FormTextarea v-model="themeCode" tall class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + </FormTextarea> + <FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton> + </div> + </FormFolder> + + <FormFolder :default-open="false" class="_formBlock"> + <template #label>{{ i18n.ts.addDescription }}</template> + + <div class="_formRoot"> + <FormTextarea v-model="description"> + <template #label>{{ i18n.ts._theme.description }}</template> + </FormTextarea> + </div> + </FormFolder> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { watch } from 'vue'; +import { toUnicode } from 'punycode/'; +import tinycolor from 'tinycolor2'; +import { v4 as uuid } from 'uuid'; +import JSON5 from 'json5'; + +import FormButton from '@/components/MkButton.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormFolder from '@/components/form/folder.vue'; + +import { $i } from '@/account'; +import { Theme, applyTheme } from '@/scripts/theme'; +import lightTheme from '@/themes/_light.json5'; +import darkTheme from '@/themes/_dark.json5'; +import { host } from '@/config'; +import * as os from '@/os'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { addTheme } from '@/theme-store'; +import { i18n } from '@/i18n'; +import { useLeaveGuard } from '@/scripts/use-leave-guard'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const 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' }, +] as const; +const accentColors = ['#e36749', '#f29924', '#98c934', '#34c9a9', '#34a1c9', '#606df7', '#8d34c9', '#e84d83']; +const 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' }, +]; + +let theme = $ref<Partial<Theme>>({ + base: 'light', + props: lightTheme.props, +}); +let description = $ref<string | null>(null); +let themeCode = $ref<string | null>(null); +let changed = $ref(false); + +useLeaveGuard($$(changed)); + +function showPreview() { + os.pageWindow('/preview'); +} + +function setBgColor(color: typeof bgColors[number]) { + if (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; + theme.props[prop] = base.props[prop]; + } + } + theme.base = color.kind; + theme.props.bg = color.color; + + if (theme.props.fg) { + const matchedFgColor = fgColors.find(x => [tinycolor(x.forLight).toRgbString(), tinycolor(x.forDark).toRgbString()].includes(tinycolor(theme.props.fg).toRgbString())); + if (matchedFgColor) setFgColor(matchedFgColor); + } +} + +function setAccentColor(color) { + theme.props.accent = color; +} + +function setFgColor(color) { + theme.props.fg = theme.base === 'light' ? color.forLight : color.forDark; +} + +function apply() { + themeCode = JSON5.stringify(theme, null, '\t'); + applyTheme(theme, false); + changed = true; +} + +function applyThemeCode() { + let parsed; + + try { + parsed = JSON5.parse(themeCode); + } catch (err) { + os.alert({ + type: 'error', + text: i18n.ts._theme.invalid, + }); + return; + } + + theme = parsed; +} + +async function saveAs() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.name, + allowEmpty: false, + }); + if (canceled) return; + + theme.id = uuid(); + theme.name = name; + theme.author = `@${$i.username}@${toUnicode(host)}`; + if (description) theme.desc = description; + await addTheme(theme); + applyTheme(theme); + if (defaultStore.state.darkMode) { + ColdDeviceStorage.set('darkTheme', theme); + } else { + ColdDeviceStorage.set('lightTheme', theme); + } + changed = false; + os.alert({ + type: 'success', + text: i18n.t('_theme.installed', { name: theme.name }), + }); +} + +watch($$(theme), apply, { deep: true }); + +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'ti ti-eye', + text: i18n.ts.preview, + handler: showPreview, +}, { + asFullButton: true, + icon: 'ti ti-check', + text: i18n.ts.saveAs, + handler: saveAs, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.themeEditor, + icon: 'ti ti-palette', +}); +</script> + +<style lang="scss" scoped> +.cwepdizn { + ::v-deep(.cwepdizn-colors) { + 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/frontend/src/pages/timeline.tutorial.vue b/packages/frontend/src/pages/timeline.tutorial.vue new file mode 100644 index 0000000000..ae7b098b90 --- /dev/null +++ b/packages/frontend/src/pages/timeline.tutorial.vue @@ -0,0 +1,142 @@ +<template> +<div class="_card"> + <div :class="$style.title" class="_title"> + <div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._tutorial.title }}</div> + <div :class="$style.step"> + <button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--"> + <i class="ti ti-chevron-left"></i> + </button> + <span :class="$style.stepNumber">{{ tutorial + 1 }} / {{ tutorialsNumber }}</span> + <button class="_button" :class="$style.stepArrow" :disabled="tutorial === tutorialsNumber - 1" @click="tutorial++"> + <i class="ti ti-chevron-right"></i> + </button> + </div> + </div> + <div v-if="tutorial === 0" class="_content"> + <div>{{ i18n.ts._tutorial.step1_1 }}</div> + <div>{{ i18n.ts._tutorial.step1_2 }}</div> + <div>{{ i18n.ts._tutorial.step1_3 }}</div> + </div> + <div v-else-if="tutorial === 1" class="_content"> + <div>{{ i18n.ts._tutorial.step2_1 }}</div> + <div>{{ i18n.ts._tutorial.step2_2 }}</div> + <MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA> + </div> + <div v-else-if="tutorial === 2" class="_content"> + <div>{{ i18n.ts._tutorial.step3_1 }}</div> + <div>{{ i18n.ts._tutorial.step3_2 }}</div> + <div>{{ i18n.ts._tutorial.step3_3 }}</div> + <small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small> + </div> + <div v-else-if="tutorial === 3" class="_content"> + <div>{{ i18n.ts._tutorial.step4_1 }}</div> + <div>{{ i18n.ts._tutorial.step4_2 }}</div> + </div> + <div v-else-if="tutorial === 4" class="_content"> + <div>{{ i18n.ts._tutorial.step5_1 }}</div> + <I18n :src="i18n.ts._tutorial.step5_2" tag="div"> + <template #featured> + <MkA class="_link" to="/featured">{{ i18n.ts.featured }}</MkA> + </template> + <template #explore> + <MkA class="_link" to="/explore">{{ i18n.ts.explore }}</MkA> + </template> + </I18n> + <div>{{ i18n.ts._tutorial.step5_3 }}</div> + <small :class="$style.small">{{ i18n.ts._tutorial.step5_4 }}</small> + </div> + <div v-else-if="tutorial === 5" class="_content"> + <div>{{ i18n.ts._tutorial.step6_1 }}</div> + <div>{{ i18n.ts._tutorial.step6_2 }}</div> + <div>{{ i18n.ts._tutorial.step6_3 }}</div> + </div> + <div v-else-if="tutorial === 6" class="_content"> + <div>{{ i18n.ts._tutorial.step7_1 }}</div> + <I18n :src="i18n.ts._tutorial.step7_2" tag="div"> + <template #help> + <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a> + </template> + </I18n> + <div>{{ i18n.ts._tutorial.step7_3 }}</div> + </div> + <div v-else-if="tutorial === 7" class="_content"> + <div>{{ i18n.ts._tutorial.step8_1 }}</div> + <div>{{ i18n.ts._tutorial.step8_2 }}</div> + <small :class="$style.small">{{ i18n.ts._tutorial.step8_3 }}</small> + </div> + + <div class="_footer" :class="$style.footer"> + <template v-if="tutorial === tutorialsNumber - 1"> + <MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1" /> + <MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton> + </template> + <template v-else> + <MkButton :class="$style.footerItem" primary @click="tutorial++"><i class="ti ti-check"></i> {{ i18n.ts.next }}</MkButton> + </template> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; + +const tutorialsNumber = 8; + +const tutorial = computed({ + get() { return defaultStore.reactiveState.tutorial.value || 0; }, + set(value) { defaultStore.set('tutorial', value); }, +}); +</script> + +<style lang="scss" module> +.small { + opacity: 0.7; +} + +.title { + display: flex; + flex-wrap: wrap; + + &Text { + margin: 4px 0; + padding-right: 4px; + } +} + +.step { + margin-left: auto; + + &Arrow { + padding: 4px; + &:disabled { + opacity: 0.5; + } + &:first-child { + padding-right: 8px; + } + &:last-child { + padding-left: 8px; + } + } + + &Number { + font-weight: normal; + margin: 4px; + } +} + +.footer { + display: flex; + flex-wrap: wrap; + flex-direction: row; + justify-content: right; + + &Item { + margin: 4px; + } +} +</style> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue new file mode 100644 index 0000000000..1c9e389367 --- /dev/null +++ b/packages/frontend/src/pages/timeline.vue @@ -0,0 +1,183 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="headerTabs" :display-my-avatar="true"/></template> + <MkSpacer :content-max="800"> + <div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf"> + <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/> + <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/> + + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tl" :key="src" + class="tl" + :src="src" + :sound="true" + @queue="queueUpdated" + /> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, watch } from 'vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import XPostForm from '@/components/MkPostForm.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { $i } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue')); + +const isLocalTimelineAvailable = !instance.disableLocalTimeline || ($i != null && ($i.isModerator || $i.isAdmin)); +const isGlobalTimelineAvailable = !instance.disableGlobalTimeline || ($i != null && ($i.isModerator || $i.isAdmin)); +const keymap = { + 't': focus, +}; + +const tlComponent = $ref<InstanceType<typeof XTimeline>>(); +const rootEl = $ref<HTMLElement>(); + +let queue = $ref(0); +const src = $computed({ get: () => defaultStore.reactiveState.tl.value.src, set: (x) => saveSrc(x) }); + +watch ($$(src), () => queue = 0); + +function queueUpdated(q: number): void { + queue = q; +} + +function top(): void { + scroll(rootEl, { top: 0 }); +} + +async function chooseList(ev: MouseEvent): Promise<void> { + const lists = await os.api('users/lists/list'); + const items = lists.map(list => ({ + type: 'link' as const, + text: list.name, + to: `/timeline/list/${list.id}`, + })); + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +async function chooseAntenna(ev: MouseEvent): Promise<void> { + const antennas = await os.api('antennas/list'); + const items = antennas.map(antenna => ({ + type: 'link' as const, + text: antenna.name, + indicate: antenna.hasUnreadNote, + to: `/timeline/antenna/${antenna.id}`, + })); + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +async function chooseChannel(ev: MouseEvent): Promise<void> { + const channels = await os.api('channels/followed'); + const items = channels.map(channel => ({ + type: 'link' as const, + text: channel.name, + indicate: channel.hasUnreadNote, + to: `/channels/${channel.id}`, + })); + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global'): void { + defaultStore.set('tl', { + ...defaultStore.state.tl, + src: newSrc, + }); +} + +async function timetravel(): Promise<void> { + const { canceled, result: date } = await os.inputDate({ + title: i18n.ts.date, + }); + if (canceled) return; + + tlComponent.timetravel(date); +} + +function focus(): void { + tlComponent.focus(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'home', + title: i18n.ts._timelines.home, + icon: 'ti ti-home', + iconOnly: true, +}, ...(isLocalTimelineAvailable ? [{ + key: 'local', + title: i18n.ts._timelines.local, + icon: 'ti ti-messages', + iconOnly: true, +}, { + key: 'social', + title: i18n.ts._timelines.social, + icon: 'ti ti-share', + iconOnly: true, +}] : []), ...(isGlobalTimelineAvailable ? [{ + key: 'global', + title: i18n.ts._timelines.global, + icon: 'ti ti-world', + iconOnly: true, +}] : []), { + icon: 'ti ti-list', + title: i18n.ts.lists, + iconOnly: true, + onClick: chooseList, +}, { + icon: 'ti ti-antenna', + title: i18n.ts.antennas, + iconOnly: true, + onClick: chooseAntenna, +}, { + icon: 'ti ti-device-tv', + title: i18n.ts.channel, + iconOnly: true, + onClick: chooseChannel, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.timeline, + icon: src === 'local' ? 'ti ti-messages' : src === 'social' ? 'ti ti-share' : src === 'global' ? 'ti ti-world' : 'ti ti-home', +}))); +</script> + +<style lang="scss" scoped> +.cmuxhskf { + > .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; + } +} +</style> diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue new file mode 100644 index 0000000000..addc8db9e6 --- /dev/null +++ b/packages/frontend/src/pages/user-info.vue @@ -0,0 +1,485 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div v-if="tab === 'overview'" class="_formRoot"> + <div class="_formBlock aeakzknw"> + <MkAvatar class="avatar" :user="user" :show-indicator="true"/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + <span class="state"> + <span v-if="suspended" class="suspended">Suspended</span> + <span v-if="silenced" class="silenced">Silenced</span> + <span v-if="moderator" class="moderator">Moderator</span> + </span> + </div> + </div> + + <MkInfo v-if="user.username.includes('.')" class="_formBlock">{{ i18n.ts.isSystemAccount }}</MkInfo> + + <div v-if="user.url" class="_formLinksGrid _formBlock"> + <FormLink :to="userPage(user)">Profile</FormLink> + <FormLink :to="user.url" :external="true">Profile (remote)</FormLink> + </div> + <FormLink v-else class="_formBlock" :to="userPage(user)">Profile</FormLink> + + <FormLink v-if="user.host" class="_formBlock" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> + + <div class="_formBlock"> + <MkKeyValue :copy="user.id" oneline style="margin: 1em 0;"> + <template #key>ID</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> + </MkKeyValue> + <!-- ่ฆใ๏ผ + <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline style="margin: 1em 0;"> + <template #key>IP (recent)</template> + <template #value><span class="_monospace">{{ ips[0].ip }}</span></template> + </MkKeyValue> + --> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.lastActiveDate }}</template> + <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.email }}</template> + <template #value><span class="_monospace">{{ info.email }}</span></template> + </MkKeyValue> + </div> + + <FormSection> + <template #label>ActivityPub</template> + + <div class="_formBlock"> + <MkKeyValue v-if="user.host" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.instanceInfo }}</template> + <template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="ti ti-chevron-right"></i></MkA></template> + </MkKeyValue> + <MkKeyValue v-else oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.updatedAt }}</template> + <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + </MkKeyValue> + <MkKeyValue v-if="ap" oneline style="margin: 1em 0;"> + <template #key>Type</template> + <template #value><span class="_monospace">{{ ap.type }}</span></template> + </MkKeyValue> + </div> + + <FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</FormButton> + + <FormFolder class="_formBlock"> + <template #label>Raw</template> + + <MkObjectView v-if="ap" tall :value="ap"> + </MkObjectView> + </FormFolder> + </FormSection> + </div> + <div v-else-if="tab === 'moderation'" class="_formRoot"> + <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:model-value="toggleModerator">{{ i18n.ts.moderator }}</FormSwitch> + <FormSwitch v-model="silenced" class="_formBlock" @update:model-value="toggleSilence">{{ i18n.ts.silence }}</FormSwitch> + <FormSwitch v-model="suspended" class="_formBlock" @update:model-value="toggleSuspend">{{ i18n.ts.suspend }}</FormSwitch> + {{ i18n.ts.reflectMayTakeTime }} + <div class="_formBlock"> + <FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</FormButton> + <FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</FormButton> + </div> + <FormTextarea v-model="moderationNote" manual-save class="_formBlock"> + <template #label>Moderation note</template> + </FormTextarea> + <FormFolder class="_formBlock"> + <template #label>IP</template> + <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> + <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo> + <template v-if="iAmAdmin && ips"> + <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> + <span class="date">{{ record.createdAt }}</span> + <span class="ip">{{ record.ip }}</span> + </div> + </template> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.files }}</template> + + <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> + </FormFolder> + <FormSection> + <template #label>Drive Capacity Override</template> + + <FormInput v-if="user.host == null" v-model="driveCapacityOverrideMb" inline :manual-save="true" type="number" :placeholder="i18n.t('defaultValueIs', { value: instance.driveCapacityPerLocalUserMb })" @update:model-value="applyDriveCapacityOverride"> + <template #label>{{ i18n.ts.driveCapOverrideLabel }}</template> + <template #suffix>MB</template> + <template #caption> + {{ i18n.ts.driveCapOverrideCaption }} + </template> + </FormInput> + </FormSection> + </div> + <div v-else-if="tab === 'chart'" class="_formRoot"> + <div class="cmhjzshm"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> + <option value="per-user-notes">{{ i18n.ts.notes }}</option> + </MkSelect> + </div> + <div class="charts"> + <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> + <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> + </div> + </div> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView v-if="info && $i.isAdmin" tall :value="info"> + </MkObjectView> + + <MkObjectView tall :value="user"> + </MkObjectView> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MkChart from '@/components/MkChart.vue'; +import MkObjectView from '@/components/MkObjectView.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormFolder from '@/components/form/folder.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkSelect from '@/components/form/select.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import { url } from '@/config'; +import { userPage, acct } from '@/filters/user'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { iAmAdmin, iAmModerator } from '@/account'; +import { instance } from '@/instance'; + +const props = defineProps<{ + userId: string; +}>(); + +let tab = $ref('overview'); +let chartSrc = $ref('per-user-notes'); +let user = $ref<null | misskey.entities.UserDetailed>(); +let init = $ref<ReturnType<typeof createFetcher>>(); +let info = $ref(); +let ips = $ref(null); +let ap = $ref(null); +let moderator = $ref(false); +let silenced = $ref(false); +let suspended = $ref(false); +let driveCapacityOverrideMb: number | null = $ref(0); +let moderationNote = $ref(''); +const filesPagination = { + endpoint: 'admin/drive/files' as const, + limit: 10, + params: computed(() => ({ + userId: props.userId, + })), +}; + +function createFetcher() { + if (iAmModerator) { + return () => Promise.all([os.api('users/show', { + userId: props.userId, + }), os.api('admin/show-user', { + userId: props.userId, + }), iAmAdmin ? os.api('admin/get-user-ips', { + userId: props.userId, + }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => { + user = _user; + info = _info; + ips = _ips; + moderator = info.isModerator; + silenced = info.isSilenced; + suspended = info.isSuspended; + driveCapacityOverrideMb = user.driveCapacityOverrideMb; + moderationNote = info.moderationNote; + + watch($$(moderationNote), async () => { + await os.api('admin/update-user-note', { userId: user.id, text: moderationNote }); + await refreshUser(); + }); + }); + } else { + return () => os.api('users/show', { + userId: props.userId, + }).then((res) => { + user = res; + }); + } +} + +function refreshUser() { + init = createFetcher(); +} + +async function updateRemoteUser() { + await os.apiWithDialog('federation/update-remote-user', { userId: user.id }); + refreshUser(); +} + +async function resetPassword() { + const { password } = await os.api('admin/reset-password', { + userId: user.id, + }); + + os.alert({ + type: 'success', + text: i18n.t('newPasswordIs', { password }), + }); +} + +async function toggleSilence(v) { + const confirm = await os.confirm({ + type: 'warning', + text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm, + }); + if (confirm.canceled) { + silenced = !v; + } else { + await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id }); + await refreshUser(); + } +} + +async function toggleSuspend(v) { + const confirm = await os.confirm({ + type: 'warning', + text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm, + }); + if (confirm.canceled) { + suspended = !v; + } else { + await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.id }); + await refreshUser(); + } +} + +async function toggleModerator(v) { + await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id }); + await refreshUser(); +} + +async function deleteAllFiles() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAllFilesConfirm, + }); + if (confirm.canceled) return; + const process = async () => { + await os.api('admin/delete-all-files-of-a-user', { userId: user.id }); + os.success(); + }; + await process().catch(err => { + os.alert({ + type: 'error', + text: err.toString(), + }); + }); + await refreshUser(); +} + +async function applyDriveCapacityOverride() { + let driveCapOrMb = driveCapacityOverrideMb; + if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) { + driveCapOrMb = null; + } + try { + await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb }); + await refreshUser(); + } catch (err) { + os.alert({ + type: 'error', + text: err.toString(), + }); + } +} + +async function deleteAccount() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAccountConfirm, + }); + if (confirm.canceled) return; + + const typed = await os.inputText({ + text: i18n.t('typeToConfirm', { x: user?.username }), + }); + if (typed.canceled) return; + + if (typed.result === user?.username) { + await os.apiWithDialog('admin/delete-account', { + userId: user.id, + }); + } else { + os.alert({ + type: 'error', + text: 'input not match', + }); + } +} + +watch(() => props.userId, () => { + init = createFetcher(); +}, { + immediate: true, +}); + +watch($$(user), () => { + os.api('ap/get', { + uri: user.uri ?? `${url}/users/${user.id}`, + }).then(res => { + ap = res; + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'ti ti-info-circle', +}, iAmModerator ? { + key: 'moderation', + title: i18n.ts.moderation, + icon: 'ti ti-user-exclamation', +} : null, { + key: 'chart', + title: i18n.ts.charts, + icon: 'ti ti-chart-line', +}, { + key: 'raw', + title: 'Raw', + icon: 'ti ti-code', +}].filter(x => x != null)); + +definePageMetadata(computed(() => ({ + title: user ? acct(user) : i18n.ts.userInfo, + icon: 'ti ti-info-circle', +}))); +</script> + +<style lang="scss" scoped> +.aeakzknw { + display: flex; + align-items: center; + + > .avatar { + display: block; + width: 64px; + height: 64px; + margin-right: 16px; + } + + > .body { + flex: 1; + overflow: hidden; + + > .name { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .sub { + display: block; + width: 100%; + font-size: 85%; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .state { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; + + &:empty { + display: none; + } + + > .suspended, > .silenced, > .moderator { + display: inline-block; + border: solid 1px; + border-radius: 6px; + padding: 2px 6px; + font-size: 85%; + } + + > .suspended { + color: var(--error); + border-color: var(--error); + } + + > .silenced { + color: var(--warn); + border-color: var(--warn); + } + + > .moderator { + color: var(--success); + border-color: var(--success); + } + } + } +} + +.cmhjzshm { + > .selects { + display: flex; + margin: 0 0 16px 0; + } + + > .charts { + > .label { + margin-bottom: 12px; + font-weight: bold; + } + } +} +</style> + +<style lang="scss" module> +.ip { + display: flex; + + > :global(.date) { + opacity: 0.7; + } + + > :global(.ip) { + margin-left: auto; + } +} +</style> diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue new file mode 100644 index 0000000000..fdb3167375 --- /dev/null +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -0,0 +1,121 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div ref="rootEl" v-size="{ min: [800] }" class="eqqrhokj"> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tlEl" :key="listId" + class="tl" + src="list" + :list="listId" + :sound="true" + @queue="queueUpdated" + /> + </div> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, watch, inject } from 'vue'; +import XTimeline from '@/components/MkTimeline.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const router = useRouter(); + +const props = defineProps<{ + listId: string; +}>(); + +let list = $ref(null); +let queue = $ref(0); +let tlEl = $ref<InstanceType<typeof XTimeline>>(); +let rootEl = $ref<HTMLElement>(); + +watch(() => props.listId, async () => { + list = await os.api('users/lists/show', { + listId: props.listId, + }); +}, { immediate: true }); + +function queueUpdated(q) { + queue = q; +} + +function top() { + scroll(rootEl, { top: 0 }); +} + +function settings() { + router.push(`/my/lists/${props.listId}`); +} + +async function timetravel() { + const { canceled, result: date } = await os.inputDate({ + title: i18n.ts.date, + }); + if (canceled) return; + + tlEl.timetravel(date); +} + +const headerActions = $computed(() => list ? [{ + icon: 'fas fa-calendar-alt', + text: i18n.ts.jumpToSpecifiedDate, + handler: timetravel, +}, { + icon: 'ti ti-settings', + text: i18n.ts.settings, + handler: settings, +}] : []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => list ? { + title: list.name, + icon: 'ti ti-list', +} : null)); +</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; + } +} + +@container (min-width: 800px) { + .eqqrhokj { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/frontend/src/pages/user/clips.vue b/packages/frontend/src/pages/user/clips.vue new file mode 100644 index 0000000000..8c71aacb0c --- /dev/null +++ b/packages/frontend/src/pages/user/clips.vue @@ -0,0 +1,47 @@ +<template> +<MkSpacer :content-max="700"> + <div class="pages-user-clips"> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination" 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> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const pagination = { + endpoint: 'users/clips' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> +.pages-user-clips { + > .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/frontend/src/pages/user/follow-list.vue b/packages/frontend/src/pages/user/follow-list.vue new file mode 100644 index 0000000000..d42acd838f --- /dev/null +++ b/packages/frontend/src/pages/user/follow-list.vue @@ -0,0 +1,47 @@ +<template> +<div> + <MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers"> + <div class="users"> + <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkUserInfo from '@/components/MkUserInfo.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: misskey.entities.User; + type: 'following' | 'followers'; +}>(); + +const followingPagination = { + endpoint: 'users/following' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; + +const followersPagination = { + endpoint: 'users/followers' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</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/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue new file mode 100644 index 0000000000..17c2843381 --- /dev/null +++ b/packages/frontend/src/pages/user/followers.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="followers"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'ti ti-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.followers, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue new file mode 100644 index 0000000000..03892ec03d --- /dev/null +++ b/packages/frontend/src/pages/user/following.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="following"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'ti ti-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.following, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/user/gallery.vue b/packages/frontend/src/pages/user/gallery.vue new file mode 100644 index 0000000000..b80e83fb11 --- /dev/null +++ b/packages/frontend/src/pages/user/gallery.vue @@ -0,0 +1,38 @@ +<template> +<MkSpacer :content-max="700"> + <MkPagination v-slot="{items}" :pagination="pagination"> + <div class="jrnovfpt"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; +}>(), { +}); + +const pagination = { + endpoint: 'users/gallery/posts' as const, + limit: 6, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</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/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue new file mode 100644 index 0000000000..43c1b37e1d --- /dev/null +++ b/packages/frontend/src/pages/user/home.vue @@ -0,0 +1,530 @@ +<template> +<MkSpacer :content-max="narrow ? 800 : 1100"> + <div ref="rootEl" v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }"> + <div class="main"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> + + <div class="profile"> + <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> + + <div :key="user.id" class="_block main"> + <div class="banner-container" :style="style"> + <div ref="bannerEl" class="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="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + </div> + </div> + <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> + <div v-if="$i" class="actions"> + <button class="menu _button" @click="menu"><i class="ti ti-dots"></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="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-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">{{ i18n.ts.noAccountDescription }}</p> + </div> + <div class="fields system"> + <dl v-if="user.location" class="field"> + <dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl v-if="user.birthday" class="field"> + <dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div v-if="user.fields.length > 0" class="fields"> + <dl v-for="(field, i) in user.fields" :key="i" class="field"> + <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 v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ i18n.ts.notes }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ i18n.ts.following }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ i18n.ts.followers }}</span> + </MkA> + </div> + </div> + </div> + + <div class="contents"> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> + <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/> + </div> + <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> + <template v-if="narrow"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </template> + </div> + <div> + <XUserTimeline :user="user"/> + </div> + </div> + <div v-if="!narrow" class="sub"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </div> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import calcAge from 's-age'; +import * as misskey from 'misskey-js'; +import XUserTimeline from './index.timeline.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkInfo from '@/components/MkInfo.vue'; +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 { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; + +const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); +const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); + +const props = withDefaults(defineProps<{ + user: misskey.entities.UserDetailed; +}>(), { +}); + +const router = useRouter(); + +let parallaxAnimationId = $ref<null | number>(null); +let narrow = $ref<null | boolean>(null); +let rootEl = $ref<null | HTMLElement>(null); +let bannerEl = $ref<null | HTMLElement>(null); + +const style = $computed(() => { + if (props.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ props.user.bannerUrl })`, + }; +}); + +const age = $computed(() => { + return calcAge(props.user.birthday); +}); + +function menu(ev) { + os.popupMenu(getUserMenu(props.user, router), ev.currentTarget ?? ev.target); +} + +function parallaxLoop() { + parallaxAnimationId = window.requestAnimationFrame(parallaxLoop); + parallax(); +} + +function parallax() { + const banner = bannerEl as any; + if (banner == null) return; + + const top = getScrollPosition(rootEl); + + if (top < 0) return; + + const z = 1.75; // ๅฅฅ่กใ(ๅฐใใใปใฉๅฅฅ) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; +} + +onMounted(() => { + window.requestAnimationFrame(parallaxLoop); + narrow = rootEl!.clientWidth < 1000; +}); + +onUnmounted(() => { + if (parallaxAnimationId) { + window.cancelAnimationFrame(parallaxAnimationId); + } +}); +</script> + +<style lang="scss" scoped> +.ftskorzw { + + > .main { + + > .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 { + > .main { + > .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%; + } + } + } + } + + &.wide { + display: flex; + width: 100%; + + > .main { + width: 100%; + min-width: 0; + } + + > .sub { + max-width: 350px; + min-width: 350px; + margin-left: var(--margin); + } + } +} + +@container (max-width: 500px) { + .ftskorzw { + > .main { + > .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/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue new file mode 100644 index 0000000000..523072d2e6 --- /dev/null +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -0,0 +1,52 @@ +<template> +<MkContainer> + <template #header><i class="ti ti-chart-line" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template> + <template #func> + <button class="_button" @click="showMenu"> + <i class="ti ti-dots"></i> + </button> + </template> + + <div style="padding: 8px;"> + <MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import MkContainer from '@/components/MkContainer.vue'; +import MkChart from '@/components/MkChart.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; + limit?: number; +}>(), { + limit: 50, +}); + +let chartSrc = $ref('per-user-notes'); + +function showMenu(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.notes, + active: true, + action: () => { + chartSrc = 'per-user-notes'; + }, + }, /*, { + text: i18n.ts.following, + action: () => { + chartSrc = 'per-user-following'; + } + }, { + text: i18n.ts.followers, + action: () => { + chartSrc = 'per-user-followers'; + } + }*/], ev.currentTarget ?? ev.target); +} +</script> diff --git a/packages/frontend/src/pages/user/index.photos.vue b/packages/frontend/src/pages/user/index.photos.vue new file mode 100644 index 0000000000..b33979a79d --- /dev/null +++ b/packages/frontend/src/pages/user/index.photos.vue @@ -0,0 +1,102 @@ +<template> +<MkContainer :max-height="300" :foldable="true"> + <template #header><i class="ti ti-photo" style="margin-right: 0.5em;"></i>{{ $ts.images }}</template> + <div class="ujigsodd"> + <MkLoading v-if="fetching"/> + <div v-if="!fetching && images.length > 0" class="stream"> + <MkA + v-for="image in images" + :key="image.note.id + image.file.id" + class="img" + :to="notePage(image.note)" + > + <ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/> + </MkA> + </div> + <p v-if="!fetching && images.length == 0" class="empty">{{ $ts.nothing }}</p> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as misskey from 'misskey-js'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { notePage } from '@/filters/note'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import { defaultStore } from '@/store'; + +const props = defineProps<{ + user: misskey.entities.UserDetailed; +}>(); + +let fetching = $ref(true); +let images = $ref<{ + note: misskey.entities.Note; + file: misskey.entities.DriveFile; +}[]>([]); + +function thumbnail(image: misskey.entities.DriveFile): string { + return defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(image.thumbnailUrl) + : image.thumbnailUrl; +} + +onMounted(() => { + const image = [ + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/png', + 'image/gif', + 'image/apng', + 'image/vnd.mozilla.apng', + ]; + os.api('users/notes', { + userId: props.user.id, + fileType: image, + excludeNsfw: defaultStore.state.nsfw !== 'ignore', + limit: 10, + }).then(notes => { + for (const note of notes) { + for (const file of note.files) { + images.push({ + note, + file, + }); + } + } + fetching = false; + }); +}); +</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/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue new file mode 100644 index 0000000000..41983a5ae8 --- /dev/null +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -0,0 +1,45 @@ +<template> +<MkStickyContainer> + <template #header> + <MkTab v-model="include" :class="$style.tab"> + <option :value="null">{{ i18n.ts.notes }}</option> + <option value="replies">{{ i18n.ts.notesAndReplies }}</option> + <option value="files">{{ i18n.ts.withFiles }}</option> + </MkTab> + </template> + <XNotes :no-gap="true" :pagination="pagination"/> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import * as misskey from 'misskey-js'; +import XNotes from '@/components/MkNotes.vue'; +import MkTab from '@/components/MkTab.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + user: misskey.entities.UserDetailed; +}>(); + +const include = ref<string | null>(null); + +const pagination = { + endpoint: 'users/notes' as const, + limit: 10, + params: computed(() => ({ + userId: props.user.id, + includeReplies: include.value === 'replies', + withFiles: include.value === 'files', + })), +}; +</script> + +<style lang="scss" module> +.tab { + margin: calc(var(--margin) / 2) 0; + padding: calc(var(--margin) / 2) 0; + background: var(--bg); +} +</style> diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue new file mode 100644 index 0000000000..6e895cd8d7 --- /dev/null +++ b/packages/frontend/src/pages/user/index.vue @@ -0,0 +1,113 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <div> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XHome v-if="tab === 'home'" :user="user"/> + <XReactions v-else-if="tab === 'reactions'" :user="user"/> + <XClips v-else-if="tab === 'clips'" :user="user"/> + <XPages v-else-if="tab === 'pages'" :user="user"/> + <XGallery v-else-if="tab === 'gallery'" :user="user"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import calcAge from 's-age'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import { getScrollPosition } from '@/scripts/scroll'; +import number from '@/filters/number'; +import { userPage, acct as getAcct } from '@/filters/user'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; + +const XHome = defineAsyncComponent(() => import('./home.vue')); +const XReactions = defineAsyncComponent(() => import('./reactions.vue')); +const XClips = defineAsyncComponent(() => import('./clips.vue')); +const XPages = defineAsyncComponent(() => import('./pages.vue')); +const XGallery = defineAsyncComponent(() => import('./gallery.vue')); + +const props = withDefaults(defineProps<{ + acct: string; + page?: string; +}>(), { + page: 'home', +}); + +const router = useRouter(); + +let tab = $ref(props.page); +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => user ? [{ + key: 'home', + title: i18n.ts.overview, + icon: 'ti ti-home', +}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{ + key: 'reactions', + title: i18n.ts.reaction, + icon: 'ti ti-mood-happy', +}] : [], { + key: 'clips', + title: i18n.ts.clips, + icon: 'ti ti-paperclip', +}, { + key: 'pages', + title: i18n.ts.pages, + icon: 'ti ti-news', +}, { + key: 'gallery', + title: i18n.ts.gallery, + icon: 'ti ti-icons', +}] : null); + +definePageMetadata(computed(() => user ? { + icon: 'ti ti-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: `@${getAcct(user)}`, + userName: user, + avatar: user, + path: `/@${user.username}`, + share: { + title: user.name, + }, +} : 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; +} +</style> diff --git a/packages/frontend/src/pages/user/pages.vue b/packages/frontend/src/pages/user/pages.vue new file mode 100644 index 0000000000..7833d6c42c --- /dev/null +++ b/packages/frontend/src/pages/user/pages.vue @@ -0,0 +1,30 @@ +<template> +<MkSpacer :content-max="700"> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagePreview from '@/components/MkPagePreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const pagination = { + endpoint: 'users/pages' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/user/reactions.vue b/packages/frontend/src/pages/user/reactions.vue new file mode 100644 index 0000000000..ab3df34301 --- /dev/null +++ b/packages/frontend/src/pages/user/reactions.vue @@ -0,0 +1,61 @@ +<template> +<MkSpacer :content-max="700"> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <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 :key="item.id" :note="item.note"/> + </div> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagination from '@/components/MkPagination.vue'; +import MkNote from '@/components/MkNote.vue'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const pagination = { + endpoint: 'users/reactions' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</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/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue new file mode 100644 index 0000000000..bfa54d39f2 --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -0,0 +1,309 @@ +<template> +<div v-if="meta" class="rsqzvsbo"> + <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"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button> + <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"> + <!-- eslint-disable-next-line vue/no-v-html --> + <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton inline rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.signup }}</MkButton> + <MkButton inline rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> + </div> + </div> + </div> + <div v-if="instances" class="federation"> + <MarqueeText :duration="40"> + <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window"> + <!--<MkInstanceCardMini :instance="instance"/>--> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <span class="name _monospace">{{ instance.host }}</span> + </MkA> + </MarqueeText> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { toUnicode } from 'punycode/'; +import XTimeline from './welcome.timeline.vue'; +import MarqueeText from '@/components/MkMarquee.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { i18n } from '@/i18n'; + +let meta = $ref(); +let stats = $ref(); +let tags = $ref(); +let onlineUsersCount = $ref(); +let instances = $ref(); + +os.api('meta', { detail: true }).then(_meta => { + meta = _meta; +}); + +os.api('stats').then(_stats => { + stats = _stats; +}); + +os.api('get-online-users-count').then(res => { + onlineUsersCount = res.count; +}); + +os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8, +}).then(_tags => { + tags = _tags; +}); + +os.api('federation/instances', { + sort: '+pubSub', + limit: 20, +}).then(_instances => { + instances = _instances; +}); + +function signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); +} + +function signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); +} + +function showMenu(ev) { + os.popupMenu([{ + text: i18n.ts.instanceInfo, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about'); + }, + }, { + text: i18n.ts.aboutMisskey, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + }, + }, null, { + text: i18n.ts.help, + icon: 'ti ti-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.md', '_blank'); + }, + }], ev.currentTarget ?? ev.target); +} +</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: 140px; + + @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; + background: var(--panel); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgb(0 0 0 / 25%); + + @media (max-width: 1200px) { + margin: auto; + } + + > .icon { + width: 85px; + margin-top: -47px; + border-radius: 100%; + vertical-align: bottom; + } + + > .menu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 18px; + } + + > .fg { + position: relative; + z-index: 1; + + > h1 { + display: block; + margin: 0; + padding: 16px 32px 24px 32px; + font-size: 1.4em; + + > .logo { + vertical-align: bottom; + max-height: 120px; + max-width: min(100%, 300px); + } + } + + > .about { + padding: 0 32px; + } + + > .action { + padding: 32px; + + > * { + line-height: 28px; + } + } + } + } + + > .federation { + position: absolute; + bottom: 16px; + left: 0; + right: 0; + margin: auto; + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-radius: 999px; + overflow: clip; + width: 800px; + padding: 8px 0; + + @media (max-width: 900px) { + display: none; + } + } + } +} +</style> + +<style lang="scss" module> +.federationInstance { + display: inline-flex; + align-items: center; + vertical-align: bottom; + padding: 6px 12px 6px 6px; + margin: 0 10px 0 0; + background: var(--panel); + border-radius: 999px; + + > :global(.icon) { + display: inline-block; + width: 20px; + height: 20px; + margin-right: 5px; + border-radius: 999px; + } +} +</style> diff --git a/packages/frontend/src/pages/welcome.entrance.b.vue b/packages/frontend/src/pages/welcome.entrance.b.vue new file mode 100644 index 0000000000..8230adaf1f --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.b.vue @@ -0,0 +1,237 @@ +<template> +<div v-if="meta" class="rsqzvsbo"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <XTimeline class="tl"/> + <div class="shape"></div> + <div class="main"> + <h1> + <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton class="signup" inline gradate @click="signup()">{{ $ts.signup }}</MkButton> + <MkButton class="signin" inline @click="signin()">{{ $ts.login }}</MkButton> + </div> + <div v-if="onlineUsersCount && stats" class="status"> + <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/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.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: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about'); + }, + }, { + text: this.$ts.aboutMisskey, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + }, + }, null, { + text: this.$ts.help, + icon: 'ti ti-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/frontend/src/pages/welcome.entrance.c.vue b/packages/frontend/src/pages/welcome.entrance.c.vue new file mode 100644 index 0000000000..d2d07bb1f0 --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.c.vue @@ -0,0 +1,306 @@ +<template> +<div v-if="meta" class="rsqzvsbo"> + <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 v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton inline gradate @click="signup()">{{ $ts.signup }}</MkButton> + <MkButton inline @click="signin()">{{ $ts.login }}</MkButton> + </div> + <div v-if="onlineUsersCount && stats" class="status"> + <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="ti ti-dots"></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/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.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: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about'); + }, + }, { + text: this.$ts.aboutMisskey, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + }, + }, null, { + text: this.$ts.help, + icon: 'ti ti-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/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue new file mode 100644 index 0000000000..2729d30d4b --- /dev/null +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -0,0 +1,89 @@ +<template> +<form class="mk-setup" @submit.prevent="submit()"> + <h1>Welcome to Misskey!</h1> + <div class="_formRoot"> + <p>{{ $ts.intro }}</p> + <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock"> + <template #label>{{ $ts.username }}</template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkInput v-model="password" type="password" data-cy-admin-password class="_formBlock"> + <template #label>{{ $ts.password }}</template> + <template #prefix><i class="ti ti-lock"></i></template> + </MkInput> + <div class="bottom _formBlock"> + <MkButton gradate type="submit" :disabled="submitting" data-cy-admin-ok> + {{ submitting ? $ts.processing : $ts.done }}<MkEllipsis v-if="submitting"/> + </MkButton> + </div> + </div> +</form> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/form/input.vue'; +import { host } from '@/config'; +import * as os from '@/os'; +import { login } from '@/account'; +import { i18n } from '@/i18n'; + +let username = $ref(''); +let password = $ref(''); +let submitting = $ref(false); + +function submit() { + if (submitting) return; + submitting = true; + + os.api('admin/accounts/create', { + username: username, + password: password, + }).then(res => { + return login(res.token); + }).catch(() => { + submitting = false; + + os.alert({ + type: 'error', + text: i18n.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; + } + + > .bottom { + > * { + margin: 0 auto; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue new file mode 100644 index 0000000000..d6a88540d1 --- /dev/null +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -0,0 +1,99 @@ +<template> +<div class="civpbkhh"> + <div ref="scroll" class="scrollbox" v-bind:class="{ scroll: isScrolling }"> + <div v-for="note in notes" class="note"> + <div class="content _panel"> + <div class="body"> + <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> + <MkA v-if="note.renoteId" class="rp" :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 ref="reactionsViewer" :note="note"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XReactionsViewer from '@/components/MkReactionsViewer.vue'; +import XMediaList from '@/components/MkMediaList.vue'; +import XPoll from '@/components/MkPoll.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/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue new file mode 100644 index 0000000000..a1c3fc2abb --- /dev/null +++ b/packages/frontend/src/pages/welcome.vue @@ -0,0 +1,30 @@ +<template> +<div v-if="meta"> + <XSetup v-if="meta.requireSetup"/> + <XEntrance v-else/> +</div> +</template> + +<script lang="ts" setup> +import { computed } 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 { definePageMetadata } from '@/scripts/page-metadata'; + +let meta = $ref(null); + +os.api('meta', { detail: true }).then(res => { + meta = res; +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: instanceName, + icon: null, +}))); +</script> |