diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index ab540c6a4d..a3f2a82ed3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -316,6 +316,8 @@ bannerUrl: "バナー画像のURL" basicInfo: "基本情報" pinnedUsers: "ピン留めユーザー" pinnedUsersDescription: "「みつける」ページなどにピン留めしたいユーザーを改行で区切って記述します。" +pinnedPages: "ピン留めページ" +pinnedPagesDescription: "インスタンスのトップページにピン留めしたいページのパスを改行で区切って記述します。" hcaptcha: "hCaptcha" enableHcaptcha: "hCaptchaを有効にする" hcaptchaSiteKey: "サイトキー" @@ -1117,6 +1119,7 @@ _pages: unlike: "いいね解除" my: "自分のページ" liked: "いいねしたページ" + featured: "人気" inspector: "インスペクター" contents: "コンテンツ" content: "ページブロック" diff --git a/migration/1605585339718-instance-pinned-pages.ts b/migration/1605585339718-instance-pinned-pages.ts new file mode 100644 index 0000000000..2f0ebab235 --- /dev/null +++ b/migration/1605585339718-instance-pinned-pages.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class instancePinnedPages1605585339718 implements MigrationInterface { + name = 'instancePinnedPages1605585339718' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{"/announcements", "/featured", "/channels", "/pages", "/explore", "/games/reversi", "/about-misskey"}'::varchar[]`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedPages"`); + } + +} diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue index f2ea7e929b..649e7c4cf6 100644 --- a/src/client/components/notes.vue +++ b/src/client/components/notes.vue @@ -8,7 +8,7 @@ <MkError v-if="error" @retry="init()"/> <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <button class="_loadMore" v-appear="$store.state.device.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <button class="_loadMore" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <template v-if="!moreFetching">{{ $t('loadMore') }}</template> <template v-if="moreFetching"><MkLoading inline/></template> </button> diff --git a/src/client/components/tab.vue b/src/client/components/tab.vue index 7278c74d9a..aca4d32a22 100644 --- a/src/client/components/tab.vue +++ b/src/client/components/tab.vue @@ -1,26 +1,32 @@ -<template> -<div class="pxhvhrfw" v-size="{ max: [500] }"> - <button v-for="item in items" class="_button" @click="$emit('update:value', item.value)" :class="{ active: value === item.value }" :disabled="value === item.value" :key="item.value"><Fa v-if="item.icon" :icon="item.icon" class="icon"/>{{ item.label }}</button> -</div> -</template> - <script lang="ts"> -import { defineComponent } from 'vue'; +import { defineComponent, h, resolveDirective, withDirectives } from 'vue'; export default defineComponent({ props: { - items: { - type: Array, - required: true, - }, value: { required: true, }, }, + render() { + const options = this.$slots.default(); + + return withDirectives(h('div', { + class: 'pxhvhrfw', + }, options.map(option => h('button', { + class: ['_button', { active: this.value === option.props.value }], + key: option.props.value, + disabled: this.value === option.props.value, + onClick: () => { + this.$emit('update:value', option.props.value); + } + }, option.children))), [ + [resolveDirective('size'), { max: [500] }] + ]); + } }); </script> -<style lang="scss" scoped> +<style lang="scss"> .pxhvhrfw { display: flex; diff --git a/src/client/components/taskmanager.api-window.vue b/src/client/components/taskmanager.api-window.vue index 8a6f200a29..0df3f75fa2 100644 --- a/src/client/components/taskmanager.api-window.vue +++ b/src/client/components/taskmanager.api-window.vue @@ -9,7 +9,10 @@ <template #header>Req Viewer</template> <div class="rlkneywz"> - <MkTab v-model:value="tab" :items="[{ label: 'Request', value: 'req', }, { label: 'Response', value: 'res', }]" style="border-bottom: solid 1px var(--divider);"/> + <MkTab v-model:value="tab" style="border-bottom: solid 1px var(--divider);"> + <option value="req">Request</option> + <option value="res">Response</option> + </MkTab> <code v-if="tab === 'req'">{{ reqStr }}</code> <code v-if="tab === 'res'">{{ resStr }}</code> diff --git a/src/client/components/taskmanager.vue b/src/client/components/taskmanager.vue index ab8d4a80dd..92c56442c3 100644 --- a/src/client/components/taskmanager.vue +++ b/src/client/components/taskmanager.vue @@ -4,7 +4,12 @@ <Fa :icon="faTerminal" style="margin-right: 0.5em;"/>Task Manager </template> <div class="qljqmnzj"> - <MkTab v-model:value="tab" :items="[{ label: 'Windows', value: 'windows', }, { label: 'Stream', value: 'stream', }, { label: 'Stream (Pool)', value: 'streamPool', }, { label: 'API', value: 'api', }]" style="border-bottom: solid 1px var(--divider);"/> + <MkTab v-model:value="tab" style="border-bottom: solid 1px var(--divider);"> + <option value="windows">Windows</option> + <option value="stream">Stream</option> + <option value="streamPool">Stream (Pool)</option> + <option value="api">API</option> + </MkTab> <div class="content"> <div v-if="tab === 'windows'" class="windows" v-follow> diff --git a/src/client/pages/channel.vue b/src/client/pages/channel.vue index 33339bbc95..ef41308541 100644 --- a/src/client/pages/channel.vue +++ b/src/client/pages/channel.vue @@ -20,7 +20,7 @@ </div> </div> - <XPostForm :channel="channel" class="post-form _content _panel _vMargin" fixed/> + <XPostForm :channel="channel" class="post-form _content _panel _vMargin" fixed v-if="this.$store.getters.isSignedIn"/> <XTimeline class="_content _vMargin" src="channel" :channel="channelId" @before="before" @after="after"/> </div> diff --git a/src/client/pages/channels.vue b/src/client/pages/channels.vue index a57f974c4d..e428051284 100644 --- a/src/client/pages/channels.vue +++ b/src/client/pages/channels.vue @@ -1,26 +1,30 @@ <template> <div> - <div class="_section" style="padding: 0;"> - <MkTab class="_content" v-model:value="tab" :items="[{ label: $t('_channel.featured'), value: 'featured', icon: faFireAlt }, { label: $t('_channel.following'), value: 'following', icon: faHeart }, { label: $t('_channel.owned'), value: 'owned', icon: faEdit }]"/> + <div class="_section" style="padding: 0;" v-if="this.$store.getters.isSignedIn"> + <MkTab class="_content" v-model:value="tab"> + <option value="featured"><Fa :icon="faFireAlt"/> {{ $t('_channel.featured') }}</option> + <option value="following"><Fa :icon="faHeart"/> {{ $t('_channel.following') }}</option> + <option value="owned"><Fa :icon="faEdit"/> {{ $t('_channel.owned') }}</option> + </MkTab> </div> <div class="_section"> <div class="_content grwlizim featured" v-if="tab === 'featured'"> <MkPagination :pagination="featuredPagination" #default="{items}"> - <MkChannelPreview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> + <MkChannelPreview v-for="channel in items" class="_vMargin" :channel="channel" :key="channel.id"/> </MkPagination> </div> <div class="_content grwlizim following" v-if="tab === 'following'"> <MkPagination :pagination="followingPagination" #default="{items}"> - <MkChannelPreview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> + <MkChannelPreview v-for="channel in items" class="_vMargin" :channel="channel" :key="channel.id"/> </MkPagination> </div> <div class="_content grwlizim owned" v-if="tab === 'owned'"> <MkButton class="new" @click="create()"><Fa :icon="faPlus"/></MkButton> <MkPagination :pagination="ownedPagination" #default="{items}"> - <MkChannelPreview v-for="channel in items" class="uveselbe" :channel="channel" :key="channel.id"/> + <MkChannelPreview v-for="channel in items" class="_vMargin" :channel="channel" :key="channel.id"/> </MkPagination> </div> </div> @@ -44,7 +48,11 @@ export default defineComponent({ return { INFO: { title: this.$t('channel'), - icon: faSatelliteDish + icon: faSatelliteDish, + action: { + icon: faPlus, + handler: this.create + } }, tab: 'featured', featuredPagination: { @@ -69,23 +77,3 @@ export default defineComponent({ } }); </script> - -<style lang="scss" scoped> -.grwlizim { - padding: 16px 0; - - &.my .uveselbe:first-child { - margin-top: 16px; - } - - .uveselbe:not(:last-child) { - margin-bottom: 8px; - } - - @media (min-width: 500px) { - .uveselbe:not(:last-child) { - margin-bottom: 16px; - } - } -} -</style> diff --git a/src/client/pages/instance/emojis.vue b/src/client/pages/instance/emojis.vue index dcd12edc91..01ea0d7f82 100644 --- a/src/client/pages/instance/emojis.vue +++ b/src/client/pages/instance/emojis.vue @@ -1,7 +1,10 @@ <template> <div class="mk-instance-emojis"> <div class="_section" style="padding: 0;"> - <MkTab v-model:value="tab" :items="[{ label: $t('local'), value: 'local' }, { label: $t('remote'), value: 'remote' }]"/> + <MkTab v-model:value="tab"> + <option value="local">{{ $t('local') }}</option> + <option value="remote">{{ $t('remote') }}</option> + </MkTab> </div> <div class="_section"> diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue index 1015b5f98d..32a6a9595f 100644 --- a/src/client/pages/instance/settings.vue +++ b/src/client/pages/instance/settings.vue @@ -1,6 +1,6 @@ <template> -<div v-if="meta"> - <section class="_section info"> +<div v-if="meta" class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div> <div class="_content"> <MkInput v-model:value="name">{{ $t('instanceName') }}</MkInput> @@ -16,7 +16,7 @@ </div> </section> - <section class="_section info"> + <section class="_card _vMargin"> <div class="_content"> <MkInput v-model:value="maxNoteTextLength" type="number" :save="() => save()"><template #icon><Fa :icon="faPencilAlt"/></template>{{ $t('maxNoteTextLength') }}</MkInput> </div> @@ -30,7 +30,7 @@ </div> </section> - <section class="_section info"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faUser"/> {{ $t('registration') }}</div> <div class="_content"> <MkSwitch v-model:value="enableRegistration" @update:value="save()">{{ $t('enableRegistration') }}</MkSwitch> @@ -38,7 +38,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faShieldAlt"/> {{ $t('hcaptcha') }}</div> <div class="_content"> <MkSwitch v-model:value="enableHcaptcha">{{ $t('enableHcaptcha') }}</MkSwitch> @@ -56,7 +56,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div> <div class="_content"> <MkSwitch v-model:value="enableRecaptcha" ref="enableRecaptcha">{{ $t('enableRecaptcha') }}</MkSwitch> @@ -74,7 +74,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faEnvelope" /> {{ $t('emailConfig') }}</div> <div class="_content"> <MkSwitch v-model:value="enableEmail" @update:value="save()">{{ $t('enableEmail') }}<template #desc>{{ $t('emailConfigInfo') }}</template></MkSwitch> @@ -97,7 +97,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faBolt"/> {{ $t('serviceworker') }}</div> <div class="_content"> <MkSwitch v-model:value="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworkerInfo') }}</template></MkSwitch> @@ -113,7 +113,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div> <div class="_content"> <MkTextarea v-model:value="pinnedUsers"> @@ -125,7 +125,19 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> + <div class="_title"><Fa :icon="faThumbtack"/> {{ $t('pinnedPages') }}</div> + <div class="_content"> + <MkTextarea v-model:value="pinnedPages"> + <template #desc>{{ $t('pinnedPagesDescription') }}</template> + </MkTextarea> + </div> + <div class="_footer"> + <MkButton primary @click="save(true)"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + </div> + </section> + + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faCloud"/> {{ $t('files') }}</div> <div class="_content"> <MkSwitch v-model:value="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></MkSwitch> @@ -138,7 +150,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faCloud"/> {{ $t('objectStorage') }}</div> <div class="_content"> <MkSwitch v-model:value="useObjectStorage">{{ $t('useObjectStorage') }}</MkSwitch> @@ -166,7 +178,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div> <div class="_content"> <MkInput :value="proxyAccount ? proxyAccount.username : null" disabled><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></MkInput> @@ -174,7 +186,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faBan"/> {{ $t('blockedInstances') }}</div> <div class="_content"> <MkTextarea v-model:value="blockedHosts"> @@ -186,7 +198,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faShareAlt"/> {{ $t('integration') }}</div> <div class="_content"> <header><Fa :icon="faTwitter"/> Twitter</header> @@ -220,7 +232,7 @@ </div> </section> - <section class="_section"> + <section class="_card _vMargin"> <div class="_title"><Fa :icon="faArchway" /> Summaly Proxy</div> <div class="_content"> <MkInput v-model:value="summalyProxy">URL</MkInput> @@ -260,6 +272,7 @@ export default defineComponent({ title: this.$t('instance'), icon: faCog, }, + meta: null, url, proxyAccount: null, proxyAccountId: null, @@ -269,6 +282,7 @@ export default defineComponent({ remoteDriveCapacityMb: 0, blockedHosts: '', pinnedUsers: '', + pinnedPages: '', maintainerName: null, maintainerEmail: null, name: null, @@ -323,13 +337,9 @@ export default defineComponent({ } }, - computed: { - meta() { - return this.$store.state.instance.meta; - }, - }, + async created() { + this.meta = await os.api('meta', { detail: true }); - created() { this.name = this.meta.name; this.description = this.meta.description; this.tosUrl = this.meta.tosUrl; @@ -356,6 +366,7 @@ export default defineComponent({ this.remoteDriveCapacityMb = this.meta.driveCapacityPerRemoteUserMb; this.blockedHosts = this.meta.blockedHosts.join('\n'); this.pinnedUsers = this.meta.pinnedUsers.join('\n'); + this.pinnedPages = this.meta.pinnedPages.join('\n'); this.enableServiceWorker = this.meta.enableServiceWorker; this.swPublicKey = this.meta.swPublickey; this.swPrivateKey = this.meta.swPrivateKey; @@ -506,6 +517,7 @@ export default defineComponent({ remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10), blockedHosts: this.blockedHosts.split('\n') || [], pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [], + pinnedPages: this.pinnedPages ? this.pinnedPages.split('\n') : [], enableServiceWorker: this.enableServiceWorker, swPublicKey: this.swPublicKey, swPrivateKey: this.swPrivateKey, diff --git a/src/client/pages/my-groups/index.vue b/src/client/pages/my-groups/index.vue index d81165b2dc..fb3d9ccb34 100644 --- a/src/client/pages/my-groups/index.vue +++ b/src/client/pages/my-groups/index.vue @@ -1,7 +1,11 @@ <template> <div class=""> <div class="_section" style="padding: 0;"> - <MkTab v-model:value="tab" :items="[{ label: $t('ownedGroups'), value: 'owned' }, { label: $t('joinedGroups'), value: 'joined' }, { label: $t('invites'), icon: faEnvelopeOpenText, value: 'invites' }]"/> + <MkTab v-model:value="tab"> + <option value="owned">{{ $t('ownedGroups') }}</option> + <option value="joined">{{ $t('joinedGroups') }}</option> + <option value="invites"><Fa :icon="faEnvelopeOpenText"/> {{ $t('invites') }}</option> + </MkTab> </div> <div class="_section"> diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue index 7f416c7558..6ad6f2ba1b 100644 --- a/src/client/pages/note.vue +++ b/src/client/pages/note.vue @@ -1,21 +1,31 @@ <template> <div class="fcuexfpr"> <div v-if="note" class="note"> - <div class="_section"> - <XNotes v-if="showNext" class="_content" :pagination="next"/> - <MkButton v-else-if="hasNext" class="load _content" @click="showNext = true"><Fa :icon="faChevronUp"/></MkButton> + <div class="_section" v-if="showNext"> + <XNotes class="_content" :pagination="next"/> </div> - <div class="_section"> - <div class="_content"> + <div class="_section main"> + <MkButton v-if="!showNext && hasNext" class="load next _content" @click="showNext = true"><Fa :icon="faChevronUp"/></MkButton> + <div class="_content _vMargin"> <MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" class="_vMargin"/> <XNote v-model:note="note" :key="note.id" :detail="true" class="_vMargin"/> </div> + <div class="_content clips _vMargin" v-if="clips && clips.length > 0"> + <div class="title">{{ $t('clip') }}</div> + <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _vMargin"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + <div class="user"> + <MkAvatar :user="item.user" class="avatar"/> <MkUserName :user="item.user" :nowrap="false"/> + </div> + </MkA> + </div> + <MkButton v-if="!showPrev && hasPrev" class="load prev _content" @click="showPrev = true"><Fa :icon="faChevronDown"/></MkButton> </div> - <div class="_section"> - <XNotes v-if="showPrev" class="_content" :pagination="prev"/> - <MkButton v-else-if="hasPrev" class="load _content" @click="showPrev = true"><Fa :icon="faChevronDown"/></MkButton> + <div class="_section" v-if="showPrev"> + <XNotes class="_content" :pagination="prev"/> </div> </div> @@ -28,7 +38,6 @@ <script lang="ts"> import { computed, defineComponent } from 'vue'; import { faChevronUp, faChevronDown } from '@fortawesome/free-solid-svg-icons'; -import Progress from '@/scripts/loading'; import XNote from '@/components/note.vue'; import XNotes from '@/components/notes.vue'; import MkRemoteCaution from '@/components/remote-caution.vue'; @@ -55,6 +64,7 @@ export default defineComponent({ avatar: this.note.user, } : null), note: null, + clips: null, hasPrev: false, hasNext: false, showPrev: false, @@ -88,11 +98,13 @@ export default defineComponent({ }, methods: { fetch() { - Progress.start(); os.api('notes/show', { noteId: this.noteId }).then(note => { Promise.all([ + os.api('notes/clips', { + noteId: note.id, + }), os.api('users/notes', { userId: note.userId, untilId: note.id, @@ -103,15 +115,14 @@ export default defineComponent({ sinceId: note.id, limit: 1, }), - ]).then(([prev, next]) => { + ]).then(([clips, prev, next]) => { + this.clips = clips; this.hasPrev = prev.length !== 0; this.hasNext = next.length !== 0; this.note = note; }); }).catch(e => { this.error = e; - }).finally(() => { - Progress.done(); }); } } @@ -121,10 +132,46 @@ export default defineComponent({ <style lang="scss" scoped> .fcuexfpr { > .note { - > ._section { + > .main { > .load { min-width: 0; border-radius: 999px; + + &.next { + margin-bottom: var(--margin); + } + + &.prev { + margin-top: var(--margin); + } + } + + > .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 1px var(--divider); + line-height: $height; + + > .avatar { + width: $height; + height: $height; + } + } + } } } } diff --git a/src/client/pages/page-editor/page-editor.vue b/src/client/pages/page-editor/page-editor.vue index 30146b56ed..eab48c766e 100644 --- a/src/client/pages/page-editor/page-editor.vue +++ b/src/client/pages/page-editor/page-editor.vue @@ -277,7 +277,7 @@ export default defineComponent({ type: 'success', text: this.$t('_pages.created') }); - this.$router.push(`/my/pages/edit/${this.pageId}`); + this.$router.push(`/pages/edit/${this.pageId}`); }).catch(onError); } }, @@ -296,7 +296,7 @@ export default defineComponent({ type: 'success', text: this.$t('_pages.deleted') }); - this.$router.push(`/my/pages`); + this.$router.push(`/pages`); }); }); }, diff --git a/src/client/pages/page.vue b/src/client/pages/page.vue index d1df8f796f..43c1688824 100644 --- a/src/client/pages/page.vue +++ b/src/client/pages/page.vue @@ -22,7 +22,7 @@ <div class="_content"> <MkA :to="`./${page.name}/view-source`" class="link">{{ $t('_pages.viewSource') }}</MkA> <template v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId"> - <MkA :to="`/my/pages/edit/${page.id}`" class="link">{{ $t('_pages.editThisPage') }}</MkA> + <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $t('_pages.editThisPage') }}</MkA> <button v-if="$store.state.i.pinnedPageId === page.id" @click="pin(false)" class="link _textButton">{{ $t('unpin') }}</button> <button v-else @click="pin(true)" class="link _textButton">{{ $t('pin') }}</button> </template> diff --git a/src/client/pages/pages.vue b/src/client/pages/pages.vue index 4e0ff5dd3c..140bbcb873 100644 --- a/src/client/pages/pages.vue +++ b/src/client/pages/pages.vue @@ -1,8 +1,18 @@ <template> <div> - <MkTab v-model:value="tab" :items="[{ label: $t('_pages.my'), value: 'my', icon: faEdit }, { label: $t('_pages.liked'), value: 'liked', icon: faHeart }]"/> + <MkTab v-model:value="tab" v-if="this.$store.getters.isSignedIn"> + <option value="featured"><Fa :icon="faFireAlt"/> {{ $t('_pages.featured') }}</option> + <option value="my"><Fa :icon="faEdit"/> {{ $t('_pages.my') }}</option> + <option value="liked"><Fa :icon="faHeart"/> {{ $t('_pages.liked') }}</option> + </MkTab> <div class="_section"> + <div class="rknalgpo _content" v-if="tab === 'featured'"> + <MkPagination :pagination="featuredPagesPagination" #default="{items}"> + <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> + </MkPagination> + </div> + <div class="rknalgpo _content my" v-if="tab === 'my'"> <MkButton class="new" @click="create()"><Fa :icon="faPlus"/></MkButton> <MkPagination :pagination="myPagesPagination" #default="{items}"> @@ -21,7 +31,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import { faPlus, faEdit } from '@fortawesome/free-solid-svg-icons'; +import { faPlus, faEdit, faFireAlt } from '@fortawesome/free-solid-svg-icons'; import { faStickyNote, faHeart } from '@fortawesome/free-regular-svg-icons'; import MkPagePreview from '@/components/page-preview.vue'; import MkPagination from '@/components/ui/pagination.vue'; @@ -42,7 +52,11 @@ export default defineComponent({ handler: this.create } }, - tab: 'my', + tab: 'featured', + featuredPagesPagination: { + endpoint: 'pages/featured', + noPaging: true, + }, myPagesPagination: { endpoint: 'i/pages', limit: 5, @@ -51,12 +65,12 @@ export default defineComponent({ endpoint: 'i/page-likes', limit: 5, }, - faStickyNote, faPlus, faEdit, faHeart + faStickyNote, faPlus, faEdit, faHeart, faFireAlt }; }, methods: { create() { - this.$router.push(`/my/pages/new`); + this.$router.push(`/pages/new`); } } }); diff --git a/src/client/pages/settings/mute-block.vue b/src/client/pages/settings/mute-block.vue index 2143d108b5..43e2c396b9 100644 --- a/src/client/pages/settings/mute-block.vue +++ b/src/client/pages/settings/mute-block.vue @@ -1,6 +1,9 @@ <template> <section class="rrfwjxfl _section"> - <MkTab v-model:value="tab" :items="[{ label: $t('mutedUsers'), value: 'mute' }, { label: $t('blockedUsers'), value: 'block' }]" style="margin-bottom: var(--margin);"/> + <MkTab v-model:value="tab" style="margin-bottom: var(--margin);"> + <option value="mute">{{ $t('mutedUsers') }}</option> + <option value="block">{{ $t('blockedUsers') }}</option> + </MkTab> <div class="_content" v-if="tab === 'mute'"> <MkPagination :pagination="mutingPagination" class="muting"> <template #empty><MkInfo>{{ $t('noUsers') }}</MkInfo></template> diff --git a/src/client/pages/settings/word-mute.vue b/src/client/pages/settings/word-mute.vue index aeae031830..444b2e598c 100644 --- a/src/client/pages/settings/word-mute.vue +++ b/src/client/pages/settings/word-mute.vue @@ -1,7 +1,10 @@ <template> <div class="_section"> <div class="_card"> - <MkTab v-model:value="tab" :items="[{ label: $t('_wordMute.soft'), value: 'soft' }, { label: $t('_wordMute.hard'), value: 'hard' }]"/> + <MkTab v-model:value="tab"> + <option value="soft">{{ $t('_wordMute.soft') }}</option> + <option value="hard">{{ $t('_wordMute.hard') }}</option> + </MkTab> <div class="_content"> <div v-show="tab === 'soft'"> <MkInfo>{{ $t('_wordMute.softDescription') }}</MkInfo> diff --git a/src/client/pages/welcome.entrance.block.vue b/src/client/pages/welcome.entrance.block.vue new file mode 100644 index 0000000000..0e4aefa4b0 --- /dev/null +++ b/src/client/pages/welcome.entrance.block.vue @@ -0,0 +1,141 @@ +<template> +<div class="xyeqzsjl _panel"> + <header> + <button class="_button" @click="back()" v-if="history.length > 0"><Fa :icon="faChevronLeft"/></button> + <XHeader class="title" :info="pageInfo" :with-back="false"/> + </header> + <div> + <component :is="component" v-bind="props" :ref="changePage"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faChevronLeft } from '@fortawesome/free-solid-svg-icons'; +import XWindow from '@/components/ui/window.vue'; +import XHeader from '@/ui/_common_/header.vue'; +import { popout } from '@/scripts/popout'; +import { resolve } from '@/router'; +import { url } from '@/config'; + +export default defineComponent({ + components: { + XWindow, + XHeader, + }, + + provide() { + return { + navHook: (path) => { + this.navigate(path); + } + }; + }, + + props: { + initialPath: { + type: String, + required: true, + }, + }, + + data() { + return { + pageInfo: null, + path: this.initialPath, + component: null, + props: null, + history: [], + faChevronLeft, + }; + }, + + computed: { + url(): string { + return url + this.path; + }, + }, + + created() { + const { component, props } = resolve(this.initialPath); + this.component = component; + this.props = props; + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + + navigate(path, record = true) { + if (record) this.history.push(this.path); + this.path = path; + const { component, props } = resolve(path); + this.component = component; + this.props = props; + }, + + back() { + this.navigate(this.history.pop(), false); + }, + + expand() { + this.$router.push(this.path); + this.$refs.window.close(); + }, + + popout() { + popout(this.path, this.$el); + this.$refs.window.close(); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.xyeqzsjl { + --section-padding: 16px; + + display: flex; + flex-direction: column; + contain: content; + + > header { + $height: 50px; + display: flex; + position: relative; + z-index: 1; + height: $height; + line-height: $height; + box-shadow: 0px 1px var(--divider); + + > button { + height: $height; + width: $height; + + &:hover { + color: var(--fgHighlighted); + } + } + + > .title { + flex: 1; + position: relative; + line-height: $height; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + } + } + + > div { + flex: 1; + overflow: auto; + } +} +</style> diff --git a/src/client/pages/welcome.entrance.vue b/src/client/pages/welcome.entrance.vue index ff946f7452..b1cd6d50c6 100644 --- a/src/client/pages/welcome.entrance.vue +++ b/src/client/pages/welcome.entrance.vue @@ -1,18 +1,13 @@ <template> -<div class="rsqzvsbo"> - <div class="_section"> - <div class="_content _panel about" v-if="meta"> - <div class="body"> - <div class="desc" v-html="meta.description || $t('introMisskey')"></div> - <MkButton @click="signup()" style="display: inline-block; margin-right: 16px;" primary>{{ $t('signup') }}</MkButton> - <MkButton @click="signin()" style="display: inline-block;">{{ $t('login') }}</MkButton> - </div> - </div> +<div class="rsqzvsbo _section" v-if="meta"> + <div class="about"> + <h1>{{ instanceName }}</h1> + <div class="desc" v-html="meta.description || $t('introMisskey')"></div> + <MkButton @click="signup()" style="display: inline-block; margin-right: 16px;" primary>{{ $t('signup') }}</MkButton> + <MkButton @click="signin()" style="display: inline-block;">{{ $t('login') }}</MkButton> </div> - <div class="_section"> - <div class="_content"> - <XNotes :pagination="featuredPagination"/> - </div> + <div class="blocks"> + <XBlock class="block" v-for="path in meta.pinnedPages" :initial-path="path" :key="path"/> </div> </div> </template> @@ -24,33 +19,30 @@ import XSigninDialog from '@/components/signin-dialog.vue'; import XSignupDialog from '@/components/signup-dialog.vue'; import MkButton from '@/components/ui/button.vue'; import XNotes from '@/components/notes.vue'; -import { host } from '@/config'; +import XBlock from './welcome.entrance.block.vue'; +import { host, instanceName } from '@/config'; import * as os from '@/os'; export default defineComponent({ components: { MkButton, XNotes, + XBlock, }, data() { return { - featuredPagination: { - endpoint: 'notes/featured', - limit: 10, - noPaging: true, - }, host: toUnicode(host), + instanceName, + meta: null, }; }, - computed: { - meta() { - return this.$store.state.instance.meta; - }, - }, - created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + os.api('stats').then(stats => { this.stats = stats; }); @@ -74,15 +66,42 @@ export default defineComponent({ <style lang="scss" scoped> .rsqzvsbo { - > ._section { - > .about { - > .body { - padding: 32px; + text-align: center; - @media (max-width: 500px) { - padding: 16px; - } - } + > .about { + display: inline-block; + padding: 24px; + margin-bottom: var(--margin); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + background: rgba(0, 0, 0, 0.5); + border-radius: var(--radius); + text-align: center; + box-sizing: border-box; + min-width: 300px; + max-width: 800px; + + &, * { + color: #fff !important; + } + + > h1 { + margin: 0 0 16px 0; + } + } + + > .blocks { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); + grid-gap: var(--margin); + text-align: left; + + > .block { + height: 600px; + } + + @media (max-width: 800px) { + grid-template-columns: 1fr; } } } diff --git a/src/client/pages/welcome.vue b/src/client/pages/welcome.vue index 32ac43eb9d..cc57629c8a 100644 --- a/src/client/pages/welcome.vue +++ b/src/client/pages/welcome.vue @@ -10,6 +10,7 @@ import { defineComponent } from 'vue'; import XSetup from './welcome.setup.vue'; import XEntrance from './welcome.entrance.vue'; import { instanceName } from '@/config'; +import * as os from '@/os'; export default defineComponent({ components: { @@ -20,16 +21,17 @@ export default defineComponent({ data() { return { INFO: { - title: instanceName || 'Misskey', + title: instanceName, icon: null }, + meta: null } }, - computed: { - meta() { - return this.$store.state.instance.meta; - }, - }, + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + } }); </script> diff --git a/src/client/router.ts b/src/client/router.ts index 413e72c320..5ad3345d55 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -33,6 +33,9 @@ export const router = createRouter({ { path: '/explore', component: page('explore') }, { path: '/explore/tags/:tag', props: true, component: page('explore') }, { path: '/search', component: page('search') }, + { path: '/pages', name: 'pages', component: page('pages') }, + { path: '/pages/new', component: page('page-editor/page-editor') }, + { path: '/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) }, { path: '/channels', component: page('channels') }, { path: '/channels/new', component: page('channel-editor') }, { path: '/channels/:channelId/edit', component: page('channel-editor'), props: true }, @@ -47,9 +50,6 @@ export const router = createRouter({ { path: '/my/messaging/group/:group', component: page('messaging/messaging-room'), props: route => ({ groupId: route.params.group }) }, { path: '/my/drive', name: 'drive', component: page('drive') }, { path: '/my/drive/folder/:folder', component: page('drive') }, - { path: '/my/pages', name: 'pages', component: page('pages') }, - { path: '/my/pages/new', component: page('page-editor/page-editor') }, - { path: '/my/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) }, { path: '/my/follow-requests', component: page('follow-requests') }, { path: '/my/lists', component: page('my-lists/index') }, { path: '/my/lists/:list', component: page('my-lists/list') }, diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts index a541670df1..a3a32d7875 100644 --- a/src/client/sidebar.ts +++ b/src/client/sidebar.ts @@ -96,8 +96,7 @@ export const sidebarDef = { pages: { title: 'pages', icon: faFileAlt, - show: computed(() => store.getters.isSignedIn), - to: '/my/pages', + to: '/pages', }, clips: { title: 'clip', diff --git a/src/client/ui/visitor.vue b/src/client/ui/visitor.vue index 8a3c19b631..56cc270be7 100644 --- a/src/client/ui/visitor.vue +++ b/src/client/ui/visitor.vue @@ -7,12 +7,12 @@ <MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA> </header> - <div class="banner" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }"> - <h1>{{ instanceName }}</h1> + <div class="banner" :class="{ asBg: $route.path === '/' }" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }"> + <h1 v-if="$route.path !== '/'">{{ instanceName }}</h1> </div> <div class="contents" ref="contents" :class="{ wallpaper }"> - <header class="header" ref="header"> + <header class="header" ref="header" v-show="$route.path !== '/'"> <XHeader :info="pageInfo"/> </header> <main ref="main"> @@ -116,11 +116,10 @@ export default defineComponent({ <style lang="scss" scoped> .mk-app { min-height: 100vh; - max-width: 1300px; - margin: 0 auto; - box-shadow: 1px 0 var(--divider), -1px 0 var(--divider); > header { + position: relative; + z-index: 1; background: var(--panel); padding: 0 16px; text-align: center; @@ -145,6 +144,12 @@ export default defineComponent({ background-size: cover; background-position: center; + &.asBg { + position: absolute; + left: 0; + height: 320px; + } + &:after { content: ""; display: block; @@ -166,6 +171,9 @@ export default defineComponent({ } > .contents { + position: relative; + z-index: 1; + > .header { position: sticky; top: 0; diff --git a/src/models/entities/meta.ts b/src/models/entities/meta.ts index d1eecf6277..b7fe8b18ad 100644 --- a/src/models/entities/meta.ts +++ b/src/models/entities/meta.ts @@ -76,6 +76,11 @@ export class Meta { }) public blockedHosts: string[]; + @Column('varchar', { + length: 512, array: true, default: '{"/announcements", "/featured", "/channels", "/explore", "/games/reversi", "/about-misskey"}' + }) + public pinnedPages: string[]; + @Column('varchar', { length: 512, nullable: true, diff --git a/src/models/repositories/page.ts b/src/models/repositories/page.ts index 662c41905f..3889bf59a7 100644 --- a/src/models/repositories/page.ts +++ b/src/models/repositories/page.ts @@ -85,8 +85,9 @@ export class PageRepository extends Repository<Page> { public packMany( pages: Page[], + me?: User['id'] | User | null | undefined, ) { - return Promise.all(pages.map(x => this.pack(x))); + return Promise.all(pages.map(x => this.pack(x, me))); } } diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts index fea6cb539f..ae6d2a4163 100644 --- a/src/server/api/endpoints/admin/update-meta.ts +++ b/src/server/api/endpoints/admin/update-meta.ts @@ -208,6 +208,10 @@ export const meta = { } }, + pinnedPages: { + validator: $.optional.arr($.str), + }, + langs: { validator: $.optional.arr($.str), desc: { @@ -537,6 +541,10 @@ export default define(meta, async (ps, me) => { set.langs = ps.langs.filter(Boolean); } + if (Array.isArray(ps.pinnedPages)) { + set.pinnedPages = ps.pinnedPages.filter(Boolean); + } + if (ps.summalyProxy !== undefined) { set.summalyProxy = ps.summalyProxy; } diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts index f46139aa23..97376a9d73 100644 --- a/src/server/api/endpoints/meta.ts +++ b/src/server/api/endpoints/meta.ts @@ -99,8 +99,6 @@ export default define(meta, async (ps, me) => { } }); - const proxyAccount = instance.proxyAccountId ? await Users.pack(instance.proxyAccountId).catch(() => null) : null; - const response: any = { maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, @@ -122,8 +120,6 @@ export default define(meta, async (ps, me) => { disableGlobalTimeline: instance.disableGlobalTimeline, driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, - cacheRemoteFiles: instance.cacheRemoteFiles, - proxyRemoteFiles: instance.proxyRemoteFiles, enableHcaptcha: instance.enableHcaptcha, hcaptchaSiteKey: instance.hcaptchaSiteKey, enableRecaptcha: instance.enableRecaptcha, @@ -135,9 +131,6 @@ export default define(meta, async (ps, me) => { iconUrl: instance.iconUrl, maxNoteTextLength: Math.min(instance.maxNoteTextLength, DB_MAX_NOTE_TEXT_LENGTH), emojis: await Emojis.packMany(emojis), - requireSetup: (await Users.count({ - host: null, - })) === 0, enableEmail: instance.enableEmail, enableTwitterIntegration: instance.enableTwitterIntegration, @@ -146,10 +139,20 @@ export default define(meta, async (ps, me) => { enableServiceWorker: instance.enableServiceWorker, - proxyAccountName: proxyAccount ? proxyAccount.username : null, + ...(ps.detail ? { + pinnedPages: instance.pinnedPages, + cacheRemoteFiles: instance.cacheRemoteFiles, + proxyRemoteFiles: instance.proxyRemoteFiles, + requireSetup: (await Users.count({ + host: null, + })) === 0, + } : {}) }; if (ps.detail) { + const proxyAccount = instance.proxyAccountId ? await Users.pack(instance.proxyAccountId).catch(() => null) : null; + + response.proxyAccountName = proxyAccount ? proxyAccount.username : null; response.features = { registration: !instance.disableRegistration, localTimeLine: !instance.disableLocalTimeline, @@ -164,42 +167,42 @@ export default define(meta, async (ps, me) => { serviceWorker: instance.enableServiceWorker, miauth: true, }; - } - if (me && me.isAdmin) { - response.useStarForReactionFallback = instance.useStarForReactionFallback; - response.pinnedUsers = instance.pinnedUsers; - response.hiddenTags = instance.hiddenTags; - response.blockedHosts = instance.blockedHosts; - response.hcaptchaSecretKey = instance.hcaptchaSecretKey; - response.recaptchaSecretKey = instance.recaptchaSecretKey; - response.proxyAccountId = instance.proxyAccountId; - response.twitterConsumerKey = instance.twitterConsumerKey; - response.twitterConsumerSecret = instance.twitterConsumerSecret; - response.githubClientId = instance.githubClientId; - response.githubClientSecret = instance.githubClientSecret; - response.discordClientId = instance.discordClientId; - response.discordClientSecret = instance.discordClientSecret; - response.summalyProxy = instance.summalyProxy; - response.email = instance.email; - response.smtpSecure = instance.smtpSecure; - response.smtpHost = instance.smtpHost; - response.smtpPort = instance.smtpPort; - response.smtpUser = instance.smtpUser; - response.smtpPass = instance.smtpPass; - response.swPrivateKey = instance.swPrivateKey; - response.useObjectStorage = instance.useObjectStorage; - response.objectStorageBaseUrl = instance.objectStorageBaseUrl; - response.objectStorageBucket = instance.objectStorageBucket; - response.objectStoragePrefix = instance.objectStoragePrefix; - response.objectStorageEndpoint = instance.objectStorageEndpoint; - response.objectStorageRegion = instance.objectStorageRegion; - response.objectStoragePort = instance.objectStoragePort; - response.objectStorageAccessKey = instance.objectStorageAccessKey; - response.objectStorageSecretKey = instance.objectStorageSecretKey; - response.objectStorageUseSSL = instance.objectStorageUseSSL; - response.objectStorageUseProxy = instance.objectStorageUseProxy; - response.objectStorageSetPublicRead = instance.objectStorageSetPublicRead; + if (me && me.isAdmin) { + response.useStarForReactionFallback = instance.useStarForReactionFallback; + response.pinnedUsers = instance.pinnedUsers; + response.hiddenTags = instance.hiddenTags; + response.blockedHosts = instance.blockedHosts; + response.hcaptchaSecretKey = instance.hcaptchaSecretKey; + response.recaptchaSecretKey = instance.recaptchaSecretKey; + response.proxyAccountId = instance.proxyAccountId; + response.twitterConsumerKey = instance.twitterConsumerKey; + response.twitterConsumerSecret = instance.twitterConsumerSecret; + response.githubClientId = instance.githubClientId; + response.githubClientSecret = instance.githubClientSecret; + response.discordClientId = instance.discordClientId; + response.discordClientSecret = instance.discordClientSecret; + response.summalyProxy = instance.summalyProxy; + response.email = instance.email; + response.smtpSecure = instance.smtpSecure; + response.smtpHost = instance.smtpHost; + response.smtpPort = instance.smtpPort; + response.smtpUser = instance.smtpUser; + response.smtpPass = instance.smtpPass; + response.swPrivateKey = instance.swPrivateKey; + response.useObjectStorage = instance.useObjectStorage; + response.objectStorageBaseUrl = instance.objectStorageBaseUrl; + response.objectStorageBucket = instance.objectStorageBucket; + response.objectStoragePrefix = instance.objectStoragePrefix; + response.objectStorageEndpoint = instance.objectStorageEndpoint; + response.objectStorageRegion = instance.objectStorageRegion; + response.objectStoragePort = instance.objectStoragePort; + response.objectStorageAccessKey = instance.objectStorageAccessKey; + response.objectStorageSecretKey = instance.objectStorageSecretKey; + response.objectStorageUseSSL = instance.objectStorageUseSSL; + response.objectStorageUseProxy = instance.objectStorageUseProxy; + response.objectStorageSetPublicRead = instance.objectStorageSetPublicRead; + } } return response; diff --git a/src/server/api/endpoints/notes/clips.ts b/src/server/api/endpoints/notes/clips.ts new file mode 100644 index 0000000000..6126f12c66 --- /dev/null +++ b/src/server/api/endpoints/notes/clips.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ClipNotes, Clips } from '../../../../models'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { In } from 'typeorm'; + +export const meta = { + tags: ['clips', 'notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '47db1a1c-b0af-458d-8fb4-986e4efafe1e' + } + } +}; + +export default define(meta, async (ps, me) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const clipNotes = await ClipNotes.find({ + noteId: note.id, + }); + + const clips = await Clips.find({ + id: In(clipNotes.map(x => x.clipId)), + }); + + return await Promise.all(clips.map(x => Clips.pack(x))); +}); diff --git a/src/server/api/endpoints/pages/featured.ts b/src/server/api/endpoints/pages/featured.ts new file mode 100644 index 0000000000..19802d0448 --- /dev/null +++ b/src/server/api/endpoints/pages/featured.ts @@ -0,0 +1,29 @@ +import define from '../../define'; +import { Pages } from '../../../../models'; + +export const meta = { + tags: ['pages'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Pages.createQueryBuilder('page') + .where('page.visibility = \'public\'') + .andWhere('page.likedCount > 0') + .orderBy('page.likedCount', 'DESC'); + + const pages = await query.take(10).getMany(); + + return await Pages.packMany(pages, me); +}); diff --git a/src/server/web/index.ts b/src/server/web/index.ts index f889374139..0bc9f242ad 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -299,6 +299,7 @@ router.get('/@:user/pages/:page', async ctx => { }); // Clip +// TODO: 非publicなclipのハンドリング router.get('/clips/:clip', async ctx => { const clip = await Clips.findOne({ id: ctx.params.clip,