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',
 ];