diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 3824d437fe90a09a1fc5583d8610f2fc275f09bc..041bdfb11ddfcb7ede5441ab126411887ab15ce7 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -704,6 +704,7 @@ editCode: "コードを編集" apply: "é©ç”¨" receiveAnnouncementFromInstance: "インスタンスã‹ã‚‰ã®ãŠçŸ¥ã‚‰ã›ã‚’å—ã‘å–ã‚‹" emailNotification: "メール通知" +publish: "公開" inChannelSearch: "ãƒãƒ£ãƒ³ãƒãƒ«å†…検索" useReactionPickerForContextMenu: "å³ã‚¯ãƒªãƒƒã‚¯ã§ãƒªã‚¢ã‚¯ã‚·ãƒ§ãƒ³ãƒ”ッカーを開ã" typingUsers: "{users}ãŒå…¥åŠ›ä¸" @@ -741,6 +742,17 @@ switch: "切り替ãˆ" noMaintainerInformationWarning: "管ç†è€…æƒ…å ±ãŒè¨å®šã•ã‚Œã¦ã„ã¾ã›ã‚“。" noBotProtectionWarning: "Bot防御ãŒè¨å®šã•ã‚Œã¦ã„ã¾ã›ã‚“。" configure: "è¨å®šã™ã‚‹" +postToGallery: "ギャラリーã¸æŠ•ç¨¿" +gallery: "ギャラリー" +recentPosts: "最近ã®æŠ•ç¨¿" +popularPosts: "人気ã®æŠ•ç¨¿" +shareWithNote: "ノートã§å…±æœ‰" + +_gallery: + my: "自分ã®æŠ•ç¨¿" + liked: "ã„ã„ãã—ãŸæŠ•ç¨¿" + like: "ã„ã„ãï¼" + unlike: "ã„ã„ã解除" _email: _follow: diff --git a/migration/1611397665007-gallery.ts b/migration/1611397665007-gallery.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b64490feb97dcd29e77a30b4c53ab8048a83c0c --- /dev/null +++ b/migration/1611397665007-gallery.ts @@ -0,0 +1,40 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class gallery1611397665007 implements MigrationInterface { + name = 'gallery1611397665007' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`CREATE TABLE "gallery_post" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "description" character varying(2048), "userId" character varying(32) NOT NULL, "fileIds" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], "isSensitive" boolean NOT NULL DEFAULT false, "likedCount" integer NOT NULL DEFAULT '0', "tags" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], CONSTRAINT "PK_8e90d7b6015f2c4518881b14753" PRIMARY KEY ("id")); COMMENT ON COLUMN "gallery_post"."createdAt" IS 'The created date of the GalleryPost.'; COMMENT ON COLUMN "gallery_post"."updatedAt" IS 'The updated date of the GalleryPost.'; COMMENT ON COLUMN "gallery_post"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "gallery_post"."isSensitive" IS 'Whether the post is sensitive.'`); + await queryRunner.query(`CREATE INDEX "IDX_8f1a239bd077c8864a20c62c2c" ON "gallery_post" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_f631d37835adb04792e361807c" ON "gallery_post" ("updatedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_985b836dddd8615e432d7043dd" ON "gallery_post" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_3ca50563facd913c425e7a89ee" ON "gallery_post" ("fileIds") `); + await queryRunner.query(`CREATE INDEX "IDX_f2d744d9a14d0dfb8b96cb7fc5" ON "gallery_post" ("isSensitive") `); + await queryRunner.query(`CREATE INDEX "IDX_1a165c68a49d08f11caffbd206" ON "gallery_post" ("likedCount") `); + await queryRunner.query(`CREATE INDEX "IDX_05cca34b985d1b8edc1d1e28df" ON "gallery_post" ("tags") `); + await queryRunner.query(`CREATE TABLE "gallery_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "postId" character varying(32) NOT NULL, CONSTRAINT "PK_853ab02be39b8de45cd720cc15f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_8fd5215095473061855ceb948c" ON "gallery_like" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_df1b5f4099e99fb0bc5eae53b6" ON "gallery_like" ("userId", "postId") `); + await queryRunner.query(`ALTER TABLE "gallery_post" ADD CONSTRAINT "FK_985b836dddd8615e432d7043ddb" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "gallery_like" ADD CONSTRAINT "FK_8fd5215095473061855ceb948cf" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "gallery_like" ADD CONSTRAINT "FK_b1cb568bfe569e47b7051699fc8" FOREIGN KEY ("postId") REFERENCES "gallery_post"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "gallery_like" DROP CONSTRAINT "FK_b1cb568bfe569e47b7051699fc8"`); + await queryRunner.query(`ALTER TABLE "gallery_like" DROP CONSTRAINT "FK_8fd5215095473061855ceb948cf"`); + await queryRunner.query(`ALTER TABLE "gallery_post" DROP CONSTRAINT "FK_985b836dddd8615e432d7043ddb"`); + await queryRunner.query(`DROP INDEX "IDX_df1b5f4099e99fb0bc5eae53b6"`); + await queryRunner.query(`DROP INDEX "IDX_8fd5215095473061855ceb948c"`); + await queryRunner.query(`DROP TABLE "gallery_like"`); + await queryRunner.query(`DROP INDEX "IDX_05cca34b985d1b8edc1d1e28df"`); + await queryRunner.query(`DROP INDEX "IDX_1a165c68a49d08f11caffbd206"`); + await queryRunner.query(`DROP INDEX "IDX_f2d744d9a14d0dfb8b96cb7fc5"`); + await queryRunner.query(`DROP INDEX "IDX_3ca50563facd913c425e7a89ee"`); + await queryRunner.query(`DROP INDEX "IDX_985b836dddd8615e432d7043dd"`); + await queryRunner.query(`DROP INDEX "IDX_f631d37835adb04792e361807c"`); + await queryRunner.query(`DROP INDEX "IDX_8f1a239bd077c8864a20c62c2c"`); + await queryRunner.query(`DROP TABLE "gallery_post"`); + } + +} diff --git a/src/client/components/gallery-post-preview.vue b/src/client/components/gallery-post-preview.vue new file mode 100644 index 0000000000000000000000000000000000000000..5c3bdb1349075b6092f4b11e44c1ab5cea9252c6 --- /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 3901e8ae44f0fcb3ae5399f18f8c3b7cd6a7dd45..c92f30db97fc28acce6a71585c9214765399592d 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 cfd928518e45e59e47aeddae825d4a46138acbc5..2e8eea713209cbb8ec2623216eb1c295553f8dcb 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 ac8ed01e1272f51ec6ee5d27da0fb17c527b55ce..1bd77447b7c04886596216f9942f07d9cc3d4ff4 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 0000000000000000000000000000000000000000..9e726e70f21d60a1e92900f1ed4666e8eb88e347 --- /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 0000000000000000000000000000000000000000..3f9756df8e4f7b2f54e0cbb7a329008881f2e106 --- /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 0000000000000000000000000000000000000000..86fae9988860dcdd192d3312e2130ce395260a35 --- /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 f25ed511841f6aa406505a7de46b9901cc194556..e43add7b0b74660cd03ad8d035b895e602615119 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 0000000000000000000000000000000000000000..2a4c4e03f40a5dab4584df27b28fb2677727eef2 --- /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 207b44f631bb49e7003159abefb6e45a5c518f0d..474860e6dbf6b0093f50b4bd000189a8c72922ef 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 26a4dac4994807b1ddf83dce8ffa436a052e927a..5371bf17d9ce8558a34c3ce6bcc02fb459304dd4 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 e5105f13b4578dbd13380ea2fc2c4f2bcfb0b400..7686da10b222ff17d7c938d461b50effd3b95316 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 d53d315f7ba281b1873da206ae5725314d672380..c8b0121719ea74abc0403466666e7d9e552f524a 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 0000000000000000000000000000000000000000..7d084a227558b8bc7bcf08f50d273feefc0e0ed2 --- /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 0000000000000000000000000000000000000000..f59cd671f36f5540df72053752f0d007c7125dfa --- /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 213570a9c4bbccb4f2f1b8964fb99c42ffb5ce0e..9d08e4985867a823d469eee43bdf479fccf74ef1 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 0000000000000000000000000000000000000000..e01c17cff566cb15a93d9785f20b425e708aef8c --- /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 0000000000000000000000000000000000000000..f1d6fe63265151ac56b9f77381746bade3ff3823 --- /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 0000000000000000000000000000000000000000..d09000cc714a6cbdb56027406580d761b18277f0 --- /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 0000000000000000000000000000000000000000..e240b14d27321cf6cab6ba78f6b101a39b7ead00 --- /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 0000000000000000000000000000000000000000..656765d80ae521b4576daac8a511371350353229 --- /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 0000000000000000000000000000000000000000..d1ae68b12664d3ddadee831aa89707b8b5d7d6cc --- /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 0000000000000000000000000000000000000000..3bf37c13e3c3e75a2f5469f103ec1d9ab8b97b1f --- /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 0000000000000000000000000000000000000000..17628544b7437cf1639f9d7c92f502a5374ad04d --- /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 0000000000000000000000000000000000000000..155949ae3db1252448523f7eea151616a7ed07d3 --- /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 0000000000000000000000000000000000000000..e569261fa6d9b854e4647324999969c2a73344f0 --- /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 0000000000000000000000000000000000000000..d7c2e96c1637cf1c9f9464d801afb1e86c40fcd2 --- /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 0000000000000000000000000000000000000000..1da6bced5c3a057cf6ce2710326d8e719c61488a --- /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 1caab14cc24fd16957410ee7585608174f0b46b1..c3b184088b1d1e657c9e12032ec85fe7b37a38ff 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 0000000000000000000000000000000000000000..95bbb2437c2d1fb9e448420f3bda3c2c55b069ce --- /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')