From 380749051d1bdb63c667dd055f949f339c356e35 Mon Sep 17 00:00:00 2001 From: syuilo <syuilotan@yahoo.co.jp> Date: Fri, 17 May 2019 19:56:47 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=AB=E3=81=84?= =?UTF-8?q?=E3=81=84=E3=81=AD=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/ja-JP.yml | 4 + migration/1558072954435-PageLike.ts | 23 +++ .../app/common/views/pages/page/page.vue | 32 +++- src/client/app/common/views/pages/pages.vue | 138 ++++++++++++++++++ src/client/app/desktop/script.ts | 2 +- src/client/app/desktop/views/home/pages.vue | 92 ------------ src/client/app/mobile/views/pages/pages.vue | 79 +--------- src/db/postgre.ts | 2 + src/models/entities/page-like.ts | 33 +++++ src/models/entities/page.ts | 5 + src/models/index.ts | 2 + src/models/repositories/page-like.ts | 26 ++++ src/models/repositories/page.ts | 52 ++++--- src/server/api/endpoints/i/page-likes.ts | 45 ++++++ src/server/api/endpoints/pages/like.ts | 79 ++++++++++ src/server/api/endpoints/pages/show.ts | 2 +- src/server/api/endpoints/pages/unlike.ts | 62 ++++++++ src/server/api/kinds.ts | 2 + 18 files changed, 489 insertions(+), 191 deletions(-) create mode 100644 migration/1558072954435-PageLike.ts create mode 100644 src/client/app/common/views/pages/pages.vue delete mode 100644 src/client/app/desktop/views/home/pages.vue create mode 100644 src/models/entities/page-like.ts create mode 100644 src/models/repositories/page-like.ts create mode 100644 src/server/api/endpoints/i/page-likes.ts create mode 100644 src/server/api/endpoints/pages/like.ts create mode 100644 src/server/api/endpoints/pages/unlike.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f34b015639..dc0692e4b9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1874,6 +1874,10 @@ pages: edit-this-page: "このページを編集" view-source: "ソースを表示" view-page: "ページを見る" + like: "いいね" + unlike: "いいね解除" + liked-pages: "いいねしたページ" + my-pages: "自分のページ" inspector: "インスペクター" content: "ページブロック" variables: "変数" diff --git a/migration/1558072954435-PageLike.ts b/migration/1558072954435-PageLike.ts new file mode 100644 index 0000000000..93cdb8afeb --- /dev/null +++ b/migration/1558072954435-PageLike.ts @@ -0,0 +1,23 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class PageLike1558072954435 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`CREATE TABLE "page_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "pageId" character varying(32) NOT NULL, CONSTRAINT "PK_813f034843af992d3ae0f43c64c" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_0e61efab7f88dbb79c9166dbb4" ON "page_like" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4ce6fb9c70529b4c8ac46c9bfa" ON "page_like" ("userId", "pageId") `); + await queryRunner.query(`ALTER TABLE "page" ADD "likedCount" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "page_like" ADD CONSTRAINT "FK_0e61efab7f88dbb79c9166dbb48" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "page_like" ADD CONSTRAINT "FK_cf8782626dced3176038176a847" FOREIGN KEY ("pageId") REFERENCES "page"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise<any> { + await queryRunner.query(`ALTER TABLE "page_like" DROP CONSTRAINT "FK_cf8782626dced3176038176a847"`); + await queryRunner.query(`ALTER TABLE "page_like" DROP CONSTRAINT "FK_0e61efab7f88dbb79c9166dbb48"`); + await queryRunner.query(`ALTER TABLE "page" DROP COLUMN "likedCount"`); + await queryRunner.query(`DROP INDEX "IDX_4ce6fb9c70529b4c8ac46c9bfa"`); + await queryRunner.query(`DROP INDEX "IDX_0e61efab7f88dbb79c9166dbb4"`); + await queryRunner.query(`DROP TABLE "page_like"`); + } + +} diff --git a/src/client/app/common/views/pages/page/page.vue b/src/client/app/common/views/pages/page/page.vue index 29580fab64..d3fb948c85 100644 --- a/src/client/app/common/views/pages/page/page.vue +++ b/src/client/app/common/views/pages/page/page.vue @@ -12,6 +12,11 @@ <small>@{{ page.user.username }}</small> <router-link v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId" :to="`/i/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link> <router-link :to="`./${page.name}/view-source`">{{ $t('view-source') }}</router-link> + <div class="like"> + <button @click="unlike()" v-if="page.isLiked" :title="$t('unlike')"><fa :icon="faHeartS"/></button> + <button @click="like()" v-else :title="$t('like')"><fa :icon="faHeart"/></button> + <span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span> + </div> </footer> </div> </template> @@ -19,8 +24,8 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../../i18n'; -import { faICursor, faPlus } from '@fortawesome/free-solid-svg-icons'; -import { faSave, faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import { faHeart as faHeartS } from '@fortawesome/free-solid-svg-icons'; +import { faHeart } from '@fortawesome/free-regular-svg-icons'; import XBlock from './page.block.vue'; import { ASEvaluator } from '../../../../../../misc/aiscript/evaluator'; import { collectPageVars } from '../../../scripts/collect-page-vars'; @@ -76,7 +81,7 @@ export default Vue.extend({ return { page: null, script: null, - faPlus, faICursor, faSave, faStickyNote + faHeartS, faHeart }; }, @@ -103,6 +108,24 @@ export default Vue.extend({ getPageVars() { return collectPageVars(this.page.content); }, + + like() { + this.$root.api('pages/like', { + pageId: this.page.id, + }).then(() => { + this.page.isLiked = true; + this.page.likedCount++; + }); + }, + + unlike() { + this.$root.api('pages/unlike', { + pageId: this.page.id, + }).then(() => { + this.page.isLiked = false; + this.page.likedCount--; + }); + } } }); </script> @@ -161,4 +184,7 @@ export default Vue.extend({ > a + a margin-left 8px + > .like + margin-top 16px + </style> diff --git a/src/client/app/common/views/pages/pages.vue b/src/client/app/common/views/pages/pages.vue new file mode 100644 index 0000000000..751ea72374 --- /dev/null +++ b/src/client/app/common/views/pages/pages.vue @@ -0,0 +1,138 @@ +<template> +<div> + <ui-container :body-togglable="true"> + <template #header><fa :icon="faEdit" fixed-width/>{{ $t('my-pages') }}</template> + <div class="rknalgpo" v-if="!fetching"> + <ui-button class="new" @click="create()"><fa :icon="faPlus"/></ui-button> + <sequential-entrance animation="entranceFromTop" delay="25" tag="div" class="pages"> + <x-page-preview v-for="page in pages" class="page" :page="page" :key="page.id"/> + </sequential-entrance> + <ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> + </div> + </ui-container> + + <ui-container :body-togglable="true"> + <template #header><fa :icon="faHeart" fixed-width/>{{ $t('liked-pages') }}</template> + <div class="rknalgpo" v-if="!fetching"> + <sequential-entrance animation="entranceFromTop" delay="25" tag="div" class="pages"> + <x-page-preview v-for="like in likes" class="page" :page="like.page" :key="like.page.id"/> + </sequential-entrance> + <ui-button v-if="existMoreLikes" @click="fetchMoreLiked()">{{ $t('@.load-more') }}</ui-button> + </div> + </ui-container> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlus, faEdit } from '@fortawesome/free-solid-svg-icons'; +import { faStickyNote, faHeart } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../../../i18n'; +import Progress from '../../scripts/loading'; +import XPagePreview from '../../views/components/page-preview.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + components: { + XPagePreview + }, + data() { + return { + fetching: true, + pages: [], + existMore: false, + moreFetching: false, + likes: [], + existMoreLikes: false, + moreLikesFetching: false, + faStickyNote, faPlus, faEdit, faHeart + }; + }, + created() { + this.fetch(); + }, + methods: { + async fetch() { + Progress.start(); + this.fetching = true; + + const pages = await this.$root.api('i/pages', { + limit: 11 + }); + + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } + + const likes = await this.$root.api('i/page-likes', { + limit: 11 + }); + + if (likes.length == 11) { + this.existMoreLikes = true; + likes.pop(); + } + + this.pages = pages; + this.likes = likes; + this.fetching = false; + + Progress.done(); + }, + fetchMore() { + this.moreFetching = true; + this.$root.api('i/pages', { + limit: 11, + untilId: this.pages[this.pages.length - 1].id + }).then(pages => { + if (pages.length == 11) { + this.existMore = true; + pages.pop(); + } else { + this.existMore = false; + } + + this.pages = this.pages.concat(pages); + this.moreFetching = false; + }); + }, + fetchMoreLiked() { + this.moreLikesFetching = true; + this.$root.api('i/page-likes', { + limit: 11, + untilId: this.likes[this.likes.length - 1].id + }).then(pages => { + if (pages.length == 11) { + this.existMoreLikes = true; + pages.pop(); + } else { + this.existMoreLikes = false; + } + + this.likes = this.likes.concat(pages); + this.moreLikesFetching = false; + }); + }, + create() { + this.$router.push(`/i/pages/new`); + } + } +}); +</script> + +<style lang="stylus" scoped> +.rknalgpo + padding 16px + + > .new + margin-bottom 16px + + > * > .page + margin-bottom 8px + + @media (min-width 500px) + > * > .page + margin-bottom 16px + +</style> diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index e8da235263..464f7d3ce9 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -156,7 +156,7 @@ init(async (launch, os) => { { path: '/explore', name: 'explore', component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, { path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, { path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) }, - { path: '/i/pages', component: () => import('./views/home/pages.vue').then(m => m.default) }, + { path: '/i/pages', component: () => import('../common/views/pages/pages.vue').then(m => m.default) }, ]}, { path: '/@:user/pages/:page', props: true, component: () => import('./views/pages/page.vue').then(m => m.default) }, { path: '/@:user/pages/:pageName/view-source', props: true, component: () => import('./views/pages/page-editor.vue').then(m => m.default) }, diff --git a/src/client/app/desktop/views/home/pages.vue b/src/client/app/desktop/views/home/pages.vue deleted file mode 100644 index 9f7fb65159..0000000000 --- a/src/client/app/desktop/views/home/pages.vue +++ /dev/null @@ -1,92 +0,0 @@ -<template> -<div class="rknalgpo" v-if="!fetching"> - <ui-button @click="create()"><fa :icon="faPlus"/></ui-button> - <sequential-entrance animation="entranceFromTop" delay="25"> - <template v-for="page in pages"> - <x-page-preview class="page" :page="page" :key="page.id"/> - </template> - </sequential-entrance> - <ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; -import { faPlus } from '@fortawesome/free-solid-svg-icons'; -import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; -import XPagePreview from '../../../common/views/components/page-preview.vue'; - -export default Vue.extend({ - i18n: i18n(), - components: { - XPagePreview - }, - data() { - return { - fetching: true, - pages: [], - existMore: false, - moreFetching: false, - faStickyNote, faPlus - }; - }, - created() { - this.fetch(); - }, - methods: { - fetch() { - Progress.start(); - this.fetching = true; - - this.$root.api('i/pages', { - limit: 11 - }).then(pages => { - if (pages.length == 11) { - this.existMore = true; - pages.pop(); - } - - this.pages = pages; - this.fetching = false; - - Progress.done(); - }); - }, - fetchMore() { - this.moreFetching = true; - this.$root.api('i/pages', { - limit: 11, - untilId: this.pages[this.pages.length - 1].id - }).then(pages => { - if (pages.length == 11) { - this.existMore = true; - pages.pop(); - } else { - this.existMore = false; - } - - this.pages = this.pages.concat(pages); - this.moreFetching = false; - }); - }, - create() { - this.$router.push(`/i/pages/new`); - } - } -}); -</script> - -<style lang="stylus" scoped> -.rknalgpo - margin 0 auto - - > * > .page - margin-bottom 8px - - @media (min-width 500px) - > * > .page - margin-bottom 16px - -</style> diff --git a/src/client/app/mobile/views/pages/pages.vue b/src/client/app/mobile/views/pages/pages.vue index 100c814ad9..2fd134fcd2 100644 --- a/src/client/app/mobile/views/pages/pages.vue +++ b/src/client/app/mobile/views/pages/pages.vue @@ -3,92 +3,27 @@ <template #header><span style="margin-right:4px;"><fa :icon="faStickyNote"/></span>{{ $t('@.pages') }}</template> <main> - <ui-button @click="create()"><fa :icon="faPlus"/></ui-button> - <sequential-entrance animation="entranceFromTop" delay="25"> - <template v-for="page in pages"> - <x-page-preview class="page" :page="page" :key="page.id"/> - </template> - </sequential-entrance> - <ui-button v-if="existMore" @click="fetchMore()">{{ $t('@.load-more') }}</ui-button> + <x-pages v-bind="$attrs"/> </main> </mk-ui> </template> + <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; -import { faPlus } from '@fortawesome/free-solid-svg-icons'; -import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; -import XPagePreview from '../../../common/views/components/page-preview.vue'; +import { faHashtag } from '@fortawesome/free-solid-svg-icons'; +import XPages from '../../../common/views/pages/pages.vue'; export default Vue.extend({ - i18n: i18n(), + i18n: i18n(''), components: { - XPagePreview + XPages }, data() { return { - fetching: true, - pages: [], - existMore: false, - moreFetching: false, - faStickyNote, faPlus + faHashtag }; }, - created() { - this.fetch(); - }, - methods: { - fetch() { - Progress.start(); - this.fetching = true; - - this.$root.api('i/pages', { - limit: 11 - }).then(pages => { - if (pages.length == 11) { - this.existMore = true; - pages.pop(); - } - - this.pages = pages; - this.fetching = false; - - Progress.done(); - }); - }, - fetchMore() { - this.moreFetching = true; - this.$root.api('i/pages', { - limit: 11, - untilId: this.pages[this.pages.length - 1].id - }).then(pages => { - if (pages.length == 11) { - this.existMore = true; - pages.pop(); - } else { - this.existMore = false; - } - - this.pages = this.pages.concat(pages); - this.moreFetching = false; - }); - }, - create() { - this.$router.push(`/i/pages/new`); - } - } }); </script> - -<style lang="stylus" scoped> -main - > * > .page - margin-bottom 8px - - @media (min-width 500px) - > * > .page - margin-bottom 16px - -</style> diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 18283836aa..f488af03ca 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -41,6 +41,7 @@ import { UserKeypair } from '../models/entities/user-keypair'; import { UserPublickey } from '../models/entities/user-publickey'; import { UserProfile } from '../models/entities/user-profile'; import { Page } from '../models/entities/page'; +import { PageLike } from '../models/entities/page-like'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -116,6 +117,7 @@ export function initDb(justBorrow = false, sync = false, log = false) { NoteWatching, NoteUnread, Page, + PageLike, Log, DriveFile, DriveFolder, diff --git a/src/models/entities/page-like.ts b/src/models/entities/page-like.ts new file mode 100644 index 0000000000..ca84ece8fd --- /dev/null +++ b/src/models/entities/page-like.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { Page } from './page'; + +@Entity() +@Index(['userId', 'pageId'], { unique: true }) +export class PageLike { + @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 pageId: Page['id']; + + @ManyToOne(type => Page, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public page: Page | null; +} diff --git a/src/models/entities/page.ts b/src/models/entities/page.ts index f57ca8c7c3..05015ba175 100644 --- a/src/models/entities/page.ts +++ b/src/models/entities/page.ts @@ -95,6 +95,11 @@ export class Page { }) public visibleUserIds: User['id'][]; + @Column('integer', { + default: 0 + }) + public likedCount: number; + constructor(data: Partial<Page>) { if (data == null) return; diff --git a/src/models/index.ts b/src/models/index.ts index e402d6723d..a63bb2c2b5 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -36,6 +36,7 @@ import { AuthSessionRepository } from './repositories/auth-session'; import { UserProfile } from './entities/user-profile'; import { HashtagRepository } from './repositories/hashtag'; import { PageRepository } from './repositories/page'; +import { PageLikeRepository } from './repositories/page-like'; export const Apps = getCustomRepository(AppRepository); export const Notes = getCustomRepository(NoteRepository); @@ -74,3 +75,4 @@ export const ReversiGames = getCustomRepository(ReversiGameRepository); export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository); export const Logs = getRepository(Log); export const Pages = getCustomRepository(PageRepository); +export const PageLikes = getCustomRepository(PageLikeRepository); diff --git a/src/models/repositories/page-like.ts b/src/models/repositories/page-like.ts new file mode 100644 index 0000000000..3e7e803fdb --- /dev/null +++ b/src/models/repositories/page-like.ts @@ -0,0 +1,26 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { PageLike } from '../entities/page-like'; +import { Pages } from '..'; +import { ensure } from '../../prelude/ensure'; + +@EntityRepository(PageLike) +export class PageLikeRepository extends Repository<PageLike> { + public async pack( + src: PageLike['id'] | PageLike, + me?: any + ) { + const like = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + + return { + id: like.id, + page: await Pages.pack(like.page || like.pageId, me), + }; + } + + public packMany( + likes: any[], + me: any + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} diff --git a/src/models/repositories/page.ts b/src/models/repositories/page.ts index 2293edbc0d..3b41420025 100644 --- a/src/models/repositories/page.ts +++ b/src/models/repositories/page.ts @@ -1,24 +1,30 @@ import { EntityRepository, Repository } from 'typeorm'; import { Page } from '../entities/page'; import { SchemaType, types, bool } from '../../misc/schema'; -import { Users, DriveFiles } from '..'; +import { Users, DriveFiles, PageLikes } from '..'; import { awaitAll } from '../../prelude/await-all'; import { DriveFile } from '../entities/drive-file'; +import { User } from '../entities/user'; +import { ensure } from '../../prelude/ensure'; export type PackedPage = SchemaType<typeof packedPageSchema>; @EntityRepository(Page) export class PageRepository extends Repository<Page> { public async pack( - src: Page, + src: Page['id'] | Page, + me?: User['id'] | User | null | undefined, ): Promise<PackedPage> { + const meId = me ? typeof me === 'string' ? me : me.id : null; + const page = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const attachedFiles: Promise<DriveFile | undefined>[] = []; const collectFile = (xs: any[]) => { for (const x of xs) { if (x.type === 'image') { attachedFiles.push(DriveFiles.findOne({ id: x.fileId, - userId: src.userId + userId: page.userId })); } if (x.children) { @@ -26,7 +32,7 @@ export class PageRepository extends Repository<Page> { } } }; - collectFile(src.content); + collectFile(page.content); // 後方互換性のため let migrated = false; @@ -47,29 +53,31 @@ export class PageRepository extends Repository<Page> { } } }; - migrate(src.content); + migrate(page.content); if (migrated) { - this.update(src.id, { - content: src.content + this.update(page.id, { + content: page.content }); } return await awaitAll({ - id: src.id, - createdAt: src.createdAt.toISOString(), - updatedAt: src.updatedAt.toISOString(), - userId: src.userId, - user: Users.pack(src.user || src.userId), - content: src.content, - variables: src.variables, - title: src.title, - name: src.name, - summary: src.summary, - alignCenter: src.alignCenter, - font: src.font, - eyeCatchingImageId: src.eyeCatchingImageId, - eyeCatchingImage: src.eyeCatchingImageId ? await DriveFiles.pack(src.eyeCatchingImageId) : null, - attachedFiles: DriveFiles.packMany(await Promise.all(attachedFiles)) + id: page.id, + createdAt: page.createdAt.toISOString(), + updatedAt: page.updatedAt.toISOString(), + userId: page.userId, + user: Users.pack(page.user || page.userId), + content: page.content, + variables: page.variables, + title: page.title, + name: page.name, + summary: page.summary, + alignCenter: page.alignCenter, + font: page.font, + eyeCatchingImageId: page.eyeCatchingImageId, + eyeCatchingImage: page.eyeCatchingImageId ? await DriveFiles.pack(page.eyeCatchingImageId) : null, + attachedFiles: DriveFiles.packMany(await Promise.all(attachedFiles)), + likedCount: page.likedCount, + isLiked: meId ? await PageLikes.findOne({ pageId: page.id, userId: meId }).then(x => x != null) : undefined, }); } diff --git a/src/server/api/endpoints/i/page-likes.ts b/src/server/api/endpoints/i/page-likes.ts new file mode 100644 index 0000000000..23bde74c99 --- /dev/null +++ b/src/server/api/endpoints/i/page-likes.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { PageLikes } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + desc: { + 'ja-JP': '「いいね」したページ一覧を取得します。', + 'en-US': 'Get liked pages' + }, + + tags: ['account', 'pages'], + + requireCredential: true, + + kind: 'read:page-likes', + + params: { + 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(PageLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.page', 'page'); + + const likes = await query + .take(ps.limit!) + .getMany(); + + return await PageLikes.packMany(likes, user); +}); diff --git a/src/server/api/endpoints/pages/like.ts b/src/server/api/endpoints/pages/like.ts new file mode 100644 index 0000000000..5a50bd6c6c --- /dev/null +++ b/src/server/api/endpoints/pages/like.ts @@ -0,0 +1,79 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, PageLikes } from '../../../../models'; +import { genId } from '../../../../misc/gen-id'; + +export const meta = { + desc: { + 'ja-JP': '指定したページを「いいね」します。', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:page-likes', + + params: { + pageId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象のページのID', + 'en-US': 'Target page ID.' + } + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'cc98a8a2-0dc3-4123-b198-62c71df18ed3' + }, + + yourPage: { + message: 'You cannot like your page.', + code: 'YOUR_PAGE', + id: '28800466-e6db-40f2-8fae-bf9e82aa92b8' + }, + + alreadyLiked: { + message: 'The page has already been liked.', + code: 'ALREADY_LIKED', + id: 'cc98a8a2-0dc3-4123-b198-62c71df18ed3' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + if (page.userId === user.id) { + throw new ApiError(meta.errors.yourPage); + } + + // if already liked + const exist = await PageLikes.findOne({ + pageId: page.id, + userId: user.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await PageLikes.save({ + id: genId(), + createdAt: new Date(), + pageId: page.id, + userId: user.id + }); + + Pages.increment({ id: page.id }, 'likedCount', 1); +}); diff --git a/src/server/api/endpoints/pages/show.ts b/src/server/api/endpoints/pages/show.ts index dd1dc9f255..e3d6e6a15f 100644 --- a/src/server/api/endpoints/pages/show.ts +++ b/src/server/api/endpoints/pages/show.ts @@ -70,5 +70,5 @@ export default define(meta, async (ps, user) => { throw new ApiError(meta.errors.noSuchPage); } - return await Pages.pack(page); + return await Pages.pack(page, user); }); diff --git a/src/server/api/endpoints/pages/unlike.ts b/src/server/api/endpoints/pages/unlike.ts new file mode 100644 index 0000000000..49ad999b31 --- /dev/null +++ b/src/server/api/endpoints/pages/unlike.ts @@ -0,0 +1,62 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, PageLikes } from '../../../../models'; + +export const meta = { + desc: { + 'ja-JP': '指定したページの「いいね」を解除します。', + }, + + tags: ['pages'], + + requireCredential: true, + + kind: 'write:page-likes', + + params: { + pageId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象のページのID', + 'en-US': 'Target page ID.' + } + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'a0d41e20-1993-40bd-890e-f6e560ae648e' + }, + + notLiked: { + message: 'You have not liked that page.', + code: 'NOT_LIKED', + id: 'f5e586b0-ce93-4050-b0e3-7f31af5259ee' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + const exist = await PageLikes.findOne({ + pageId: page.id, + userId: user.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await PageLikes.delete(exist.id); + + Pages.decrement({ id: page.id }, 'likedCount', 1); +}); diff --git a/src/server/api/kinds.ts b/src/server/api/kinds.ts index 99c3795589..76d5a8a61a 100644 --- a/src/server/api/kinds.ts +++ b/src/server/api/kinds.ts @@ -21,4 +21,6 @@ export const kinds = [ 'write:votes', 'read:pages', 'write:pages', + 'write:page-likes', + 'read:page-likes', ];