diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-04-24 22:38:24 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2021-04-24 22:38:24 +0900 |
| commit | fec3c70886c13a267814e7eba5d2dd9aa807687b (patch) | |
| tree | d88fcd2904b964a30a925be6e1b26e1e51ee329d /src | |
| parent | Tweak UI (diff) | |
| download | sharkey-fec3c70886c13a267814e7eba5d2dd9aa807687b.tar.gz sharkey-fec3c70886c13a267814e7eba5d2dd9aa807687b.tar.bz2 sharkey-fec3c70886c13a267814e7eba5d2dd9aa807687b.zip | |
Gallery (#7194)
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
Diffstat (limited to 'src')
30 files changed, 1555 insertions, 9 deletions
diff --git a/src/client/components/gallery-post-preview.vue b/src/client/components/gallery-post-preview.vue new file mode 100644 index 0000000000..5c3bdb1349 --- /dev/null +++ b/src/client/components/gallery-post-preview.vue @@ -0,0 +1,126 @@ +<template> +<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1"> + <div class="thumbnail"> + <ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/> + </div> + <article> + <header> + <MkAvatar :user="post.user" class="avatar"/> + </header> + <footer> + <span class="title">{{ post.title }}</span> + </footer> + </article> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { userName } from '@client/filters/user'; +import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; +import * as os from '@client/os'; + +export default defineComponent({ + components: { + ImgWithBlurhash + }, + props: { + post: { + type: Object, + required: true + }, + }, + methods: { + userName + } +}); +</script> + +<style lang="scss" scoped> +.ttasepnz { + display: block; + position: relative; + height: 200px; + + &:hover { + text-decoration: none; + color: var(--accent); + + > .thumbnail { + transform: scale(1.1); + } + + > article { + > footer { + &:before { + opacity: 1; + } + } + } + } + + > .thumbnail { + width: 100%; + height: 100%; + position: absolute; + transition: all 0.5s ease; + + > .img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + > article { + position: absolute; + z-index: 1; + width: 100%; + height: 100%; + + > header { + position: absolute; + top: 0; + width: 100%; + padding: 12px; + box-sizing: border-box; + display: flex; + + > .avatar { + margin-left: auto; + width: 32px; + height: 32px; + } + } + + > footer { + position: absolute; + bottom: 0; + width: 100%; + padding: 16px; + box-sizing: border-box; + color: #fff; + text-shadow: 0 0 8px #000; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + + &:before { + content: ""; + display: block; + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(rgba(0, 0, 0, 0.4), transparent); + opacity: 0; + transition: opacity 0.5s ease; + } + + > .title { + font-weight: bold; + } + } + } +} +</style> diff --git a/src/client/components/ui/button.vue b/src/client/components/ui/button.vue index 3901e8ae44..c92f30db97 100644 --- a/src/client/components/ui/button.vue +++ b/src/client/components/ui/button.vue @@ -139,7 +139,8 @@ export default defineComponent({ } &.primary { - color: #fff; + font-weight: bold; + color: #fff !important; background: var(--accent); &:not(:disabled):hover { @@ -200,10 +201,6 @@ export default defineComponent({ min-width: 100px; } - &.primary { - font-weight: bold; - } - > .ripples { position: absolute; z-index: 0; diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue index cfd928518e..2e8eea7132 100644 --- a/src/client/components/ui/container.vue +++ b/src/client/components/ui/container.vue @@ -199,6 +199,7 @@ export default defineComponent({ > .fade { display: block; position: absolute; + z-index: 10; bottom: 0; left: 0; width: 100%; diff --git a/src/client/components/ui/pagination.vue b/src/client/components/ui/pagination.vue index ac8ed01e12..1bd77447b7 100644 --- a/src/client/components/ui/pagination.vue +++ b/src/client/components/ui/pagination.vue @@ -10,8 +10,8 @@ <div v-else class="cxiknjgy"> <slot :items="items"></slot> - <div class="more" v-show="more" key="_more_"> - <MkButton class="button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> + <div class="more _gap" v-show="more" key="_more_"> + <MkButton class="button" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> <template v-if="!moreFetching">{{ $ts.loadMore }}</template> <template v-if="moreFetching"><MkLoading inline/></template> </MkButton> @@ -38,6 +38,12 @@ export default defineComponent({ pagination: { required: true }, + + disableAutoLoad: { + type: Boolean, + required: false, + default: false, + } }, }); </script> diff --git a/src/client/pages/gallery/index.vue b/src/client/pages/gallery/index.vue new file mode 100644 index 0000000000..9e726e70f2 --- /dev/null +++ b/src/client/pages/gallery/index.vue @@ -0,0 +1,152 @@ +<template> +<div class="xprsixdl _root"> + <MkTab v-model:value="tab" v-if="$i"> + <option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option> + <option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option> + <option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option> + </MkTab> + + <div v-if="tab === 'explore'"> + <MkFolder class="_gap"> + <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template> + <MkPagination :pagination="recentPostsPagination" #default="{items}" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template> + <MkPagination :pagination="popularPostsPagination" #default="{items}" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </MkFolder> + </div> + <div v-else-if="tab === 'liked'"> + <MkPagination :pagination="likedPostsPagination" #default="{items}"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="like in items" :post="like.post" :key="like.id" class="post"/> + </div> + </MkPagination> + </div> + <div v-else-if="tab === 'my'"> + <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA> + <MkPagination :pagination="myPostsPagination" #default="{items}"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XUserList from '@client/components/user-list.vue'; +import MkFolder from '@client/components/ui/folder.vue'; +import MkInput from '@client/components/ui/input.vue'; +import MkButton from '@client/components/ui/button.vue'; +import MkTab from '@client/components/tab.vue'; +import MkPagination from '@client/components/ui/pagination.vue'; +import MkGalleryPostPreview from '@client/components/gallery-post-preview.vue'; +import number from '@client/filters/number'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; + +export default defineComponent({ + components: { + XUserList, + MkFolder, + MkInput, + MkButton, + MkTab, + MkPagination, + MkGalleryPostPreview, + }, + + props: { + tag: { + type: String, + required: false + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.gallery, + icon: 'fas fa-icons' + }, + tab: 'explore', + recentPostsPagination: { + endpoint: 'gallery/posts', + limit: 6, + }, + popularPostsPagination: { + endpoint: 'gallery/featured', + limit: 5, + }, + myPostsPagination: { + endpoint: 'i/gallery/posts', + limit: 5, + }, + likedPostsPagination: { + endpoint: 'i/gallery/likes', + limit: 5, + }, + tags: [], + }; + }, + + computed: { + meta() { + return this.$instance; + }, + tagUsers(): any { + return { + endpoint: 'hashtags/users', + limit: 30, + params: { + tag: this.tag, + origin: 'combined', + sort: '+follower', + } + }; + }, + }, + + watch: { + tag() { + if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); + }, + }, + + created() { + + }, + + methods: { + + } +}); +</script> + +<style lang="scss" scoped> +.xprsixdl { + max-width: 1400px; + margin: 0 auto; +} + +.vfpdbgtk { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: 0 var(--margin); + + > .post { + + } +} +</style> diff --git a/src/client/pages/gallery/new.vue b/src/client/pages/gallery/new.vue new file mode 100644 index 0000000000..3f9756df8e --- /dev/null +++ b/src/client/pages/gallery/new.vue @@ -0,0 +1,110 @@ +<template> +<FormBase> + <FormInput v-model:value="title"> + <span>{{ $ts.title }}</span> + </FormInput> + + <FormTextarea v-model:value="description" :max="500"> + <span>{{ $ts.description }}</span> + </FormTextarea> + + <FormGroup> + <div v-for="file in files" :key="file.id" class="_formItem _formPanel wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> + <div class="name">{{ file.name }}</div> + <button class="remove _button" @click="remove(file)" v-tooltip="$ts.remove"><i class="fas fa-times"></i></button> + </div> + <FormButton @click="selectFile" primary><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton> + </FormGroup> + + <FormSwitch v-model:value="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch> + + <FormButton @click="publish" primary><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@client/components/form/button.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormTuple from '@client/components/form/tuple.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import { selectFile } from '@client/scripts/select-file'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; + +export default defineComponent({ + components: { + FormButton, + FormInput, + FormTextarea, + FormSwitch, + FormBase, + FormGroup, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.postToGallery, + icon: 'fas fa-pencil-alt' + }, + files: [], + description: null, + title: null, + isSensitive: false, + } + }, + + methods: { + selectFile(e) { + selectFile(e.currentTarget || e.target, null, true).then(files => { + this.files = this.files.concat(files); + }); + }, + + remove(file) { + this.files = this.files.filter(f => f.id !== file.id); + }, + + async publish() { + const post = await os.apiWithDialog('gallery/posts/create', { + title: this.title, + description: this.description, + fileIds: this.files.map(file => file.id), + isSensitive: this.isSensitive, + }); + + this.$router.push(`/gallery/${post.id}`); + } + } +}); +</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/src/client/pages/gallery/post.vue b/src/client/pages/gallery/post.vue new file mode 100644 index 0000000000..86fae99888 --- /dev/null +++ b/src/client/pages/gallery/post.vue @@ -0,0 +1,271 @@ +<template> +<div class="_root"> + <transition name="fade" mode="out-in"> + <div v-if="post" class="rkxwuolj"> + <div class="files"> + <div class="file" v-for="file in post.files" :key="file.id"> + <img :src="file.url"/> + </div> + </div> + <div class="body _block"> + <div class="title">{{ post.title }}</div> + <div class="description"><Mfm :text="post.description"/></div> + <div class="info"> + <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton class="button" @click="unlike()" v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton> + <MkButton class="button" @click="like()" v-else v-tooltip="$ts._gallery.like"><i class="far fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button class="_button" @click="createNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button> + <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="post.user" class="avatar"/> + <div class="name"> + <MkUserName :user="post.user" style="display: block;"/> + <MkAcct :user="post.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + </div> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> + <MkPagination :pagination="otherPostsPagination" #default="{items}"> + <div class="sdrarzaf"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </MkContainer> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkButton from '@client/components/ui/button.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import MkContainer from '@client/components/ui/container.vue'; +import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; +import MkPagination from '@client/components/ui/pagination.vue'; +import MkGalleryPostPreview from '@client/components/gallery-post-preview.vue'; +import MkFollowButton from '@client/components/follow-button.vue'; +import { url } from '@client/config'; + +export default defineComponent({ + components: { + MkContainer, + ImgWithBlurhash, + MkPagination, + MkGalleryPostPreview, + MkButton, + MkFollowButton, + }, + props: { + postId: { + type: String, + required: true + } + }, + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.post ? { + title: this.post.title, + avatar: this.post.user, + path: `/gallery/${this.post.id}`, + share: { + title: this.post.title, + text: this.post.description, + }, + } : null), + otherPostsPagination: { + endpoint: 'users/gallery/posts', + limit: 6, + params: () => ({ + userId: this.post.user.id + }) + }, + post: null, + error: null, + }; + }, + + watch: { + postId: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.post = null; + os.api('gallery/posts/show', { + postId: this.postId + }).then(post => { + this.post = post; + }).catch(e => { + this.error = e; + }); + }, + + share() { + navigator.share({ + title: this.post.title, + text: this.post.description, + url: `${url}/gallery/${this.post.id}` + }); + }, + + like() { + os.apiWithDialog('gallery/posts/like', { + postId: this.postId, + }).then(() => { + this.post.isLiked = true; + this.post.likedCount++; + }); + }, + + async unlike() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('gallery/posts/unlike', { + postId: this.postId, + }).then(() => { + this.post.isLiked = false; + this.post.likedCount--; + }); + }, + + createNote() { + os.post({ + initialText: `${this.post.title} ${url}/gallery/${this.post.id}` + }); + } + } +}); +</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/src/client/pages/page.vue b/src/client/pages/page.vue index f25ed51184..e43add7b0b 100644 --- a/src/client/pages/page.vue +++ b/src/client/pages/page.vue @@ -166,10 +166,11 @@ export default defineComponent({ border-top: solid 0.5px var(--divider); > .button { - --accent: rgb(216 71 106); + --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; diff --git a/src/client/pages/user/gallery.vue b/src/client/pages/user/gallery.vue new file mode 100644 index 0000000000..2a4c4e03f4 --- /dev/null +++ b/src/client/pages/user/gallery.vue @@ -0,0 +1,63 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}"> + <div class="jrnovfpt"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkGalleryPostPreview from '@client/components/gallery-post-preview.vue'; +import MkPagination from '@client/components/ui/pagination.vue'; +import { userPage, acct } from '../../filters/user'; + +export default defineComponent({ + components: { + MkPagination, + MkGalleryPostPreview, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/gallery/posts', + limit: 6, + params: () => ({ + userId: this.user.id + }) + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + }, + + methods: { + userPage, + + acct + } +}); +</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/src/client/pages/user/index.vue b/src/client/pages/user/index.vue index 207b44f631..474860e6db 100644 --- a/src/client/pages/user/index.vue +++ b/src/client/pages/user/index.vue @@ -191,6 +191,10 @@ <i class="fas fa-file-alt icon"></i> <span>{{ $ts.pages }}</span> </MkA> + <MkA :to="userPage(user, 'gallery')" :class="{ active: page === 'gallery' }" class="link"> + <i class="fas fa-icons icon"></i> + <span>{{ $ts.gallery }}</span> + </MkA> </div> <template v-if="page === 'index'"> @@ -210,6 +214,7 @@ <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/> <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/> <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> + <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/> </div> </div> <MkError v-else-if="error" @retry="fetch()"/> @@ -250,6 +255,7 @@ export default defineComponent({ XFollowList: defineAsyncComponent(() => import('./follow-list.vue')), XClips: defineAsyncComponent(() => import('./clips.vue')), XPages: defineAsyncComponent(() => import('./pages.vue')), + XGallery: defineAsyncComponent(() => import('./gallery.vue')), XPhotos: defineAsyncComponent(() => import('./index.photos.vue')), XActivity: defineAsyncComponent(() => import('./index.activity.vue')), }, diff --git a/src/client/router.ts b/src/client/router.ts index 26a4dac499..5371bf17d9 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -37,6 +37,9 @@ export const router = createRouter({ { 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: '/gallery', component: page('gallery/index') }, + { path: '/gallery/new', component: page('gallery/new') }, + { path: '/gallery/:postId', component: page('gallery/post'), props: route => ({ postId: route.params.postId }) }, { path: '/channels', component: page('channels') }, { path: '/channels/new', component: page('channel-editor') }, { path: '/channels/:channelId/edit', component: page('channel-editor'), props: true }, diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts index e5105f13b4..7686da10b2 100644 --- a/src/client/sidebar.ts +++ b/src/client/sidebar.ts @@ -97,6 +97,11 @@ export const sidebarDef = { icon: 'fas fa-file-alt', to: '/pages', }, + gallery: { + title: 'gallery', + icon: 'fas fa-icons', + to: '/gallery', + }, clips: { title: 'clip', icon: 'fas fa-paperclip', diff --git a/src/db/postgre.ts b/src/db/postgre.ts index d53d315f7b..c8b0121719 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -51,6 +51,8 @@ import { UserSecurityKey } from '../models/entities/user-security-key'; import { AttestationChallenge } from '../models/entities/attestation-challenge'; import { Page } from '../models/entities/page'; import { PageLike } from '../models/entities/page-like'; +import { GalleryPost } from '../models/entities/gallery-post'; +import { GalleryLike } from '../models/entities/gallery-like'; import { ModerationLog } from '../models/entities/moderation-log'; import { UsedUsername } from '../models/entities/used-username'; import { Announcement } from '../models/entities/announcement'; @@ -137,6 +139,8 @@ export const entities = [ NoteUnread, Page, PageLike, + GalleryPost, + GalleryLike, Log, DriveFile, DriveFolder, diff --git a/src/models/entities/gallery-like.ts b/src/models/entities/gallery-like.ts new file mode 100644 index 0000000000..7d084a2275 --- /dev/null +++ b/src/models/entities/gallery-like.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { GalleryPost } from './gallery-post'; + +@Entity() +@Index(['userId', 'postId'], { unique: true }) +export class GalleryLike { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public postId: GalleryPost['id']; + + @ManyToOne(type => GalleryPost, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public post: GalleryPost | null; +} diff --git a/src/models/entities/gallery-post.ts b/src/models/entities/gallery-post.ts new file mode 100644 index 0000000000..f59cd671f3 --- /dev/null +++ b/src/models/entities/gallery-post.ts @@ -0,0 +1,79 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { DriveFile } from './drive-file'; + +@Entity() +export class GalleryPost { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the GalleryPost.' + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The updated date of the GalleryPost.' + }) + public updatedAt: Date; + + @Column('varchar', { + length: 256, + }) + public title: string; + + @Column('varchar', { + length: 2048, nullable: true + }) + public description: string | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + array: true, default: '{}' + }) + public fileIds: DriveFile['id'][]; + + @Index() + @Column('boolean', { + default: false, + comment: 'Whether the post is sensitive.' + }) + public isSensitive: boolean; + + @Index() + @Column('integer', { + default: 0 + }) + public likedCount: number; + + @Index() + @Column('varchar', { + length: 128, array: true, default: '{}' + }) + public tags: string[]; + + constructor(data: Partial<GalleryPost>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/src/models/index.ts b/src/models/index.ts index 213570a9c4..9d08e49858 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -43,6 +43,8 @@ import { UserSecurityKey } from './entities/user-security-key'; import { HashtagRepository } from './repositories/hashtag'; import { PageRepository } from './repositories/page'; import { PageLikeRepository } from './repositories/page-like'; +import { GalleryPostRepository } from './repositories/gallery-post'; +import { GalleryLikeRepository } from './repositories/gallery-like'; import { ModerationLogRepository } from './repositories/moderation-logs'; import { UsedUsername } from './entities/used-username'; import { ClipRepository } from './repositories/clip'; @@ -105,6 +107,8 @@ export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository); export const Logs = getRepository(Log); export const Pages = getCustomRepository(PageRepository); export const PageLikes = getCustomRepository(PageLikeRepository); +export const GalleryPosts = getCustomRepository(GalleryPostRepository); +export const GalleryLikes = getCustomRepository(GalleryLikeRepository); export const ModerationLogs = getCustomRepository(ModerationLogRepository); export const Clips = getCustomRepository(ClipRepository); export const ClipNotes = getRepository(ClipNote); diff --git a/src/models/repositories/gallery-like.ts b/src/models/repositories/gallery-like.ts new file mode 100644 index 0000000000..e01c17cff5 --- /dev/null +++ b/src/models/repositories/gallery-like.ts @@ -0,0 +1,25 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { GalleryLike } from '../entities/gallery-like'; +import { GalleryPosts } from '..'; + +@EntityRepository(GalleryLike) +export class GalleryLikeRepository extends Repository<GalleryLike> { + public async pack( + src: GalleryLike['id'] | GalleryLike, + me?: any + ) { + const like = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: like.id, + post: await GalleryPosts.pack(like.post || like.postId, me), + }; + } + + public packMany( + likes: any[], + me: any + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} diff --git a/src/models/repositories/gallery-post.ts b/src/models/repositories/gallery-post.ts new file mode 100644 index 0000000000..f1d6fe6326 --- /dev/null +++ b/src/models/repositories/gallery-post.ts @@ -0,0 +1,113 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { GalleryPost } from '../entities/gallery-post'; +import { SchemaType } from '../../misc/schema'; +import { Users, DriveFiles, GalleryLikes } from '..'; +import { awaitAll } from '../../prelude/await-all'; +import { User } from '../entities/user'; + +export type PackedGalleryPost = SchemaType<typeof packedGalleryPostSchema>; + +@EntityRepository(GalleryPost) +export class GalleryPostRepository extends Repository<GalleryPost> { + public async pack( + src: GalleryPost['id'] | GalleryPost, + me?: { id: User['id'] } | null | undefined, + ): Promise<PackedGalleryPost> { + const meId = me ? me.id : null; + const post = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: post.id, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + userId: post.userId, + user: Users.pack(post.user || post.userId, me), + title: post.title, + description: post.description, + fileIds: post.fileIds, + files: DriveFiles.packMany(post.fileIds), + tags: post.tags.length > 0 ? post.tags : undefined, + isSensitive: post.isSensitive, + likedCount: post.likedCount, + isLiked: meId ? await GalleryLikes.findOne({ postId: post.id, userId: meId }).then(x => x != null) : undefined, + }); + } + + public packMany( + posts: GalleryPost[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(posts.map(x => this.pack(x, me))); + } +} + +export const packedGalleryPostSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + description: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + user: { + type: 'object' as const, + ref: 'User', + optional: false as const, nullable: false as const, + }, + fileIds: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + } + }, + files: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile' + } + }, + tags: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + }, + isSensitive: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + } +}; diff --git a/src/server/api/endpoints/gallery/featured.ts b/src/server/api/endpoints/gallery/featured.ts new file mode 100644 index 0000000000..d09000cc71 --- /dev/null +++ b/src/server/api/endpoints/gallery/featured.ts @@ -0,0 +1,29 @@ +import define from '../../define'; +import { GalleryPosts } from '../../../../models'; + +export const meta = { + tags: ['gallery'], + + 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: 'GalleryPost', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder('post') + .andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) }) + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); + + const posts = await query.take(10).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/src/server/api/endpoints/gallery/popular.ts b/src/server/api/endpoints/gallery/popular.ts new file mode 100644 index 0000000000..e240b14d27 --- /dev/null +++ b/src/server/api/endpoints/gallery/popular.ts @@ -0,0 +1,28 @@ +import define from '../../define'; +import { GalleryPosts } from '../../../../models'; + +export const meta = { + tags: ['gallery'], + + 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: 'GalleryPost', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder('post') + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); + + const posts = await query.take(10).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/src/server/api/endpoints/gallery/posts.ts b/src/server/api/endpoints/gallery/posts.ts new file mode 100644 index 0000000000..656765d80a --- /dev/null +++ b/src/server/api/endpoints/gallery/posts.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { GalleryPosts } from '../../../../models'; + +export const meta = { + tags: ['gallery'], + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.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: 'GalleryPost', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('post.user', 'user'); + + const posts = await query.take(ps.limit!).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/src/server/api/endpoints/gallery/posts/create.ts b/src/server/api/endpoints/gallery/posts/create.ts new file mode 100644 index 0000000000..d1ae68b126 --- /dev/null +++ b/src/server/api/endpoints/gallery/posts/create.ts @@ -0,0 +1,76 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../../define'; +import { ID } from '../../../../../misc/cafy-id'; +import { DriveFiles, GalleryPosts } from '../../../../../models'; +import { genId } from '../../../../../misc/gen-id'; +import { GalleryPost } from '../../../../../models/entities/gallery-post'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + title: { + validator: $.str.min(1), + }, + + description: { + validator: $.optional.nullable.str, + }, + + fileIds: { + validator: $.arr($.type(ID)).unique().range(1, 32), + }, + + isSensitive: { + validator: $.optional.bool, + default: false, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + DriveFiles.findOne({ + id: fileId, + userId: user.id + }) + ))).filter(file => file != null); + + if (files.length === 0) { + throw new Error(); + } + + const post = await GalleryPosts.insert(new GalleryPost({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + description: ps.description, + userId: user.id, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id) + })).then(x => GalleryPosts.findOneOrFail(x.identifiers[0])); + + return await GalleryPosts.pack(post, user); +}); diff --git a/src/server/api/endpoints/gallery/posts/like.ts b/src/server/api/endpoints/gallery/posts/like.ts new file mode 100644 index 0000000000..3bf37c13e3 --- /dev/null +++ b/src/server/api/endpoints/gallery/posts/like.ts @@ -0,0 +1,71 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts, GalleryLikes } from '../../../../../models'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery-likes', + + params: { + postId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: '56c06af3-1287-442f-9701-c93f7c4a62ff' + }, + + yourPost: { + message: 'You cannot like your post.', + code: 'YOUR_POST', + id: 'f78f1511-5ebc-4478-a888-1198d752da68' + }, + + alreadyLiked: { + message: 'The post has already been liked.', + code: 'ALREADY_LIKED', + id: '40e9ed56-a59c-473a-bf3f-f289c54fb5a7' + }, + } +}; + +export default define(meta, async (ps, user) => { + const post = await GalleryPosts.findOne(ps.postId); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + if (post.userId === user.id) { + throw new ApiError(meta.errors.yourPost); + } + + // if already liked + const exist = await GalleryLikes.findOne({ + postId: post.id, + userId: user.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await GalleryLikes.insert({ + id: genId(), + createdAt: new Date(), + postId: post.id, + userId: user.id + }); + + GalleryPosts.increment({ id: post.id }, 'likedCount', 1); +}); diff --git a/src/server/api/endpoints/gallery/posts/show.ts b/src/server/api/endpoints/gallery/posts/show.ts new file mode 100644 index 0000000000..17628544b7 --- /dev/null +++ b/src/server/api/endpoints/gallery/posts/show.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts } from '@/models'; + +export const meta = { + tags: ['gallery'], + + requireCredential: false as const, + + params: { + postId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: '1137bf14-c5b0-4604-85bb-5b5371b1cd45' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } +}; + +export default define(meta, async (ps, me) => { + const post = await GalleryPosts.findOne({ + id: ps.postId, + }); + + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + return await GalleryPosts.pack(post, me); +}); diff --git a/src/server/api/endpoints/gallery/posts/unlike.ts b/src/server/api/endpoints/gallery/posts/unlike.ts new file mode 100644 index 0000000000..155949ae3d --- /dev/null +++ b/src/server/api/endpoints/gallery/posts/unlike.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts, GalleryLikes } from '../../../../../models'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery-likes', + + params: { + postId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: 'c32e6dd0-b555-4413-925e-b3757d19ed84' + }, + + notLiked: { + message: 'You have not liked that post.', + code: 'NOT_LIKED', + id: 'e3e8e06e-be37-41f7-a5b4-87a8250288f0' + }, + } +}; + +export default define(meta, async (ps, user) => { + const post = await GalleryPosts.findOne(ps.postId); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + const exist = await GalleryLikes.findOne({ + postId: post.id, + userId: user.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await GalleryLikes.delete(exist.id); + + GalleryPosts.decrement({ id: post.id }, 'likedCount', 1); +}); diff --git a/src/server/api/endpoints/i/gallery/likes.ts b/src/server/api/endpoints/i/gallery/likes.ts new file mode 100644 index 0000000000..e569261fa6 --- /dev/null +++ b/src/server/api/endpoints/i/gallery/likes.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryLikes } from '../../../../../models'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'gallery'], + + requireCredential: true as const, + + kind: 'read:gallery-likes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + page: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.post', 'post'); + + const likes = await query + .take(ps.limit!) + .getMany(); + + return await GalleryLikes.packMany(likes, user); +}); diff --git a/src/server/api/endpoints/i/gallery/posts.ts b/src/server/api/endpoints/i/gallery/posts.ts new file mode 100644 index 0000000000..d7c2e96c16 --- /dev/null +++ b/src/server/api/endpoints/i/gallery/posts.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryPosts } from '../../../../../models'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'gallery'], + + requireCredential: true as const, + + kind: 'read:gallery', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.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: 'GalleryPost' + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere(`post.userId = :meId`, { meId: user.id }); + + const posts = await query + .take(ps.limit!) + .getMany(); + + return await GalleryPosts.packMany(posts, user); +}); diff --git a/src/server/api/endpoints/users/gallery/posts.ts b/src/server/api/endpoints/users/gallery/posts.ts new file mode 100644 index 0000000000..1da6bced5c --- /dev/null +++ b/src/server/api/endpoints/users/gallery/posts.ts @@ -0,0 +1,39 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryPosts } from '../../../../../models'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['users', 'gallery'], + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere(`post.userId = :userId`, { userId: ps.userId }); + + const posts = await query + .take(ps.limit!) + .getMany(); + + return await GalleryPosts.packMany(posts, user); +}); diff --git a/src/server/web/index.ts b/src/server/web/index.ts index 1caab14cc2..c3b184088b 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -17,7 +17,7 @@ import packFeed from './feed'; import { fetchMeta } from '@/misc/fetch-meta'; import { genOpenapiSpec } from '../api/openapi/gen-spec'; import config from '@/config'; -import { Users, Notes, Emojis, UserProfiles, Pages, Channels, Clips } from '../../models'; +import { Users, Notes, Emojis, UserProfiles, Pages, Channels, Clips, GalleryPosts } from '../../models'; import parseAcct from '@/misc/acct/parse'; import { getNoteSummary } from '@/misc/get-note-summary'; import { getConnection } from 'typeorm'; @@ -342,6 +342,29 @@ router.get('/clips/:clip', async ctx => { ctx.status = 404; }); +// Gallery post +router.get('/gallery/:post', async ctx => { + const post = await GalleryPosts.findOne(ctx.params.post); + + if (post) { + const _post = await GalleryPosts.pack(post); + const profile = await UserProfiles.findOneOrFail(post.userId); + const meta = await fetchMeta(); + await ctx.render('gallery-post', { + post: _post, + profile, + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl + }); + + ctx.set('Cache-Control', 'public, max-age=180'); + + return; + } + + ctx.status = 404; +}); + // Channel router.get('/channels/:channel', async ctx => { const channel = await Channels.findOne({ diff --git a/src/server/web/views/gallery-post.pug b/src/server/web/views/gallery-post.pug new file mode 100644 index 0000000000..95bbb2437c --- /dev/null +++ b/src/server/web/views/gallery-post.pug @@ -0,0 +1,35 @@ +extends ./base + +block vars + - const user = post.user; + - const title = post.title; + - const url = `${config.url}/gallery/${post.id}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= post.description) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= post.description) + meta(property='og:url' content= url) + meta(property='og:image' content= post.files[0].thumbnailUrl) + +block meta + if user.host || profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) + + if !user.host + link(rel='alternate' href=url type='application/activity+json') |