From df8a2aea358ca3bcec60c878a6399df46390e3e1 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 24 Feb 2018 02:46:09 +0900
Subject: [PATCH] Implement #1098

---
 src/api/endpoints.ts                          |   7 +-
 src/api/endpoints/i/update_home.ts            |  11 +-
 src/api/endpoints/i/update_mobile_home.ts     |  50 +++++
 src/web/app/common/define-widget.ts           |  26 ++-
 .../app/common/scripts/check-for-update.ts    |   4 +-
 src/web/app/common/views/components/index.ts  |  30 +++
 .../views/components/widgets/access-log.vue   |  70 +++----
 .../views/components/widgets/broadcast.vue    |  10 +-
 .../views/components/widgets/calendar.vue     |   7 +
 .../views/components/widgets/donation.vue     |  15 +-
 .../views/components/widgets/nav.vue          |  12 +-
 .../views/components/widgets/photo-stream.vue | 104 +++++++++++
 .../views/components/widgets/profile.vue      |   0
 .../common/views/components/widgets/rss.vue   |  93 ++++++++++
 .../components/widgets/server.cpu-memory.vue  |   0
 .../views/components/widgets/server.cpu.vue   |   0
 .../views/components/widgets/server.disk.vue  |   0
 .../views/components/widgets/server.info.vue  |   0
 .../components/widgets/server.memory.vue      |   0
 .../views/components/widgets/server.pie.vue   |   0
 .../components/widgets/server.uptimes.vue     |   0
 .../views/components/widgets/server.vue       |  93 ++++++++++
 .../views/components/widgets/slideshow.vue    |   0
 .../views/components/widgets/tips.vue         |   0
 .../views/components/widgets/version.vue      |   0
 src/web/app/desktop/views/components/index.ts |  32 +---
 .../views/components/widget-container.vue     |  72 ++++++++
 .../views/components/widgets/photo-stream.vue | 122 ------------
 .../desktop/views/components/widgets/rss.vue  | 111 -----------
 .../views/components/widgets/server.vue       | 131 -------------
 .../activity.vue}                             |   4 +-
 src/web/app/mobile/views/components/home.vue  |  29 ---
 src/web/app/mobile/views/components/index.ts  |  14 +-
 .../app/mobile/views/components/ui.header.vue |   4 +-
 src/web/app/mobile/views/components/ui.vue    |   6 +-
 .../views/components/widget-container.vue     |  65 +++++++
 .../views/components/widgets/activity.vue     |  23 +++
 src/web/app/mobile/views/pages/drive.vue      |   4 +-
 src/web/app/mobile/views/pages/home.vue       | 174 +++++++++++++++++-
 .../app/mobile/views/pages/notifications.vue  |   4 +-
 src/web/app/mobile/views/pages/user.vue       |   1 -
 src/web/app/mobile/views/pages/user/home.vue  |   6 +-
 42 files changed, 823 insertions(+), 511 deletions(-)
 create mode 100644 src/api/endpoints/i/update_mobile_home.ts
 rename src/web/app/{desktop => common}/views/components/widgets/access-log.vue (61%)
 rename src/web/app/{desktop => common}/views/components/widgets/broadcast.vue (96%)
 rename src/web/app/{desktop => common}/views/components/widgets/calendar.vue (96%)
 rename src/web/app/{desktop => common}/views/components/widgets/donation.vue (79%)
 rename src/web/app/{desktop => common}/views/components/widgets/nav.vue (67%)
 create mode 100644 src/web/app/common/views/components/widgets/photo-stream.vue
 rename src/web/app/{desktop => common}/views/components/widgets/profile.vue (100%)
 create mode 100644 src/web/app/common/views/components/widgets/rss.vue
 rename src/web/app/{desktop => common}/views/components/widgets/server.cpu-memory.vue (100%)
 rename src/web/app/{desktop => common}/views/components/widgets/server.cpu.vue (100%)
 rename src/web/app/{desktop => common}/views/components/widgets/server.disk.vue (100%)
 rename src/web/app/{desktop => common}/views/components/widgets/server.info.vue (100%)
 rename src/web/app/{desktop => common}/views/components/widgets/server.memory.vue (100%)
 rename src/web/app/{desktop => common}/views/components/widgets/server.pie.vue (100%)
 rename src/web/app/{desktop => common}/views/components/widgets/server.uptimes.vue (100%)
 create mode 100644 src/web/app/common/views/components/widgets/server.vue
 rename src/web/app/{desktop => common}/views/components/widgets/slideshow.vue (100%)
 rename src/web/app/{desktop => common}/views/components/widgets/tips.vue (100%)
 rename src/web/app/{desktop => common}/views/components/widgets/version.vue (100%)
 create mode 100644 src/web/app/desktop/views/components/widget-container.vue
 delete mode 100644 src/web/app/desktop/views/components/widgets/photo-stream.vue
 delete mode 100644 src/web/app/desktop/views/components/widgets/rss.vue
 delete mode 100644 src/web/app/desktop/views/components/widgets/server.vue
 rename src/web/app/mobile/views/{pages/user/home.activity.vue => components/activity.vue} (96%)
 delete mode 100644 src/web/app/mobile/views/components/home.vue
 create mode 100644 src/web/app/mobile/views/components/widget-container.vue
 create mode 100644 src/web/app/mobile/views/components/widgets/activity.vue

diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts
index ff214c3004..cbc016f20f 100644
--- a/src/api/endpoints.ts
+++ b/src/api/endpoints.ts
@@ -182,7 +182,12 @@ const endpoints: Endpoint[] = [
 	{
 		name: 'i/update_home',
 		withCredential: true,
-		kind: 'account-write'
+		secure: true
+	},
+	{
+		name: 'i/update_mobile_home',
+		withCredential: true,
+		secure: true
 	},
 	{
 		name: 'i/change_password',
diff --git a/src/api/endpoints/i/update_home.ts b/src/api/endpoints/i/update_home.ts
index 429e88529a..5dfb7d7915 100644
--- a/src/api/endpoints/i/update_home.ts
+++ b/src/api/endpoints/i/update_home.ts
@@ -4,16 +4,7 @@
 import $ from 'cafy';
 import User from '../../models/user';
 
-/**
- * Update myself
- *
- * @param {any} params
- * @param {any} user
- * @param {any} _
- * @param {boolean} isSecure
- * @return {Promise<any>}
- */
-module.exports = async (params, user, _, isSecure) => new Promise(async (res, rej) => {
+module.exports = async (params, user) => new Promise(async (res, rej) => {
 	// Get 'home' parameter
 	const [home, homeErr] = $(params.home).optional.array().each(
 		$().strict.object()
diff --git a/src/api/endpoints/i/update_mobile_home.ts b/src/api/endpoints/i/update_mobile_home.ts
new file mode 100644
index 0000000000..a87d89cad7
--- /dev/null
+++ b/src/api/endpoints/i/update_mobile_home.ts
@@ -0,0 +1,50 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import User from '../../models/user';
+
+module.exports = async (params, user) => new Promise(async (res, rej) => {
+	// Get 'home' parameter
+	const [home, homeErr] = $(params.home).optional.array().each(
+		$().strict.object()
+			.have('name', $().string())
+			.have('id', $().string())
+			.have('data', $().object())).$;
+	if (homeErr) return rej('invalid home param');
+
+	// Get 'id' parameter
+	const [id, idErr] = $(params.id).optional.string().$;
+	if (idErr) return rej('invalid id param');
+
+	// Get 'data' parameter
+	const [data, dataErr] = $(params.data).optional.object().$;
+	if (dataErr) return rej('invalid data param');
+
+	if (home) {
+		await User.update(user._id, {
+			$set: {
+				'client_settings.mobile_home': home
+			}
+		});
+
+		res();
+	} else {
+		if (id == null && data == null) return rej('you need to set id and data params if home param unset');
+
+		const _home = user.client_settings.mobile_home || [];
+		const widget = _home.find(w => w.id == id);
+
+		if (widget == null) return rej('widget not found');
+
+		widget.data = data;
+
+		await User.update(user._id, {
+			$set: {
+				'client_settings.mobile_home': _home
+			}
+		});
+
+		res();
+	}
+});
diff --git a/src/web/app/common/define-widget.ts b/src/web/app/common/define-widget.ts
index fd13a3395b..60cd1969c0 100644
--- a/src/web/app/common/define-widget.ts
+++ b/src/web/app/common/define-widget.ts
@@ -8,6 +8,10 @@ export default function<T extends object>(data: {
 		props: {
 			widget: {
 				type: Object
+			},
+			isMobile: {
+				type: Boolean,
+				default: false
 			}
 		},
 		computed: {
@@ -21,6 +25,7 @@ export default function<T extends object>(data: {
 			};
 		},
 		created() {
+			if (this.widget.data == null) this.widget.data = {};
 			if (this.props) {
 				Object.keys(this.props).forEach(prop => {
 					if (this.widget.data.hasOwnProperty(prop)) {
@@ -30,12 +35,21 @@ export default function<T extends object>(data: {
 			}
 
 			this.$watch('props', newProps => {
-				(this as any).api('i/update_home', {
-					id: this.id,
-					data: newProps
-				}).then(() => {
-					(this as any).os.i.client_settings.home.find(w => w.id == this.id).data = newProps;
-				});
+				if (this.isMobile) {
+					(this as any).api('i/update_mobile_home', {
+						id: this.id,
+						data: newProps
+					}).then(() => {
+						(this as any).os.i.client_settings.mobile_home.find(w => w.id == this.id).data = newProps;
+					});
+				} else {
+					(this as any).api('i/update_home', {
+						id: this.id,
+						data: newProps
+					}).then(() => {
+						(this as any).os.i.client_settings.home.find(w => w.id == this.id).data = newProps;
+					});
+				}
 			}, {
 				deep: true
 			});
diff --git a/src/web/app/common/scripts/check-for-update.ts b/src/web/app/common/scripts/check-for-update.ts
index 0855676a42..fe539407da 100644
--- a/src/web/app/common/scripts/check-for-update.ts
+++ b/src/web/app/common/scripts/check-for-update.ts
@@ -9,7 +9,9 @@ export default async function(mios: MiOS) {
 
 		// Clear cache (serive worker)
 		try {
-			navigator.serviceWorker.controller.postMessage('clear');
+			if (navigator.serviceWorker.controller) {
+				navigator.serviceWorker.controller.postMessage('clear');
+			}
 
 			navigator.serviceWorker.getRegistrations().then(registrations => {
 				registrations.forEach(registration => registration.unregister());
diff --git a/src/web/app/common/views/components/index.ts b/src/web/app/common/views/components/index.ts
index ab0f1767d4..e66a323266 100644
--- a/src/web/app/common/views/components/index.ts
+++ b/src/web/app/common/views/components/index.ts
@@ -21,6 +21,21 @@ import urlPreview from './url-preview.vue';
 import twitterSetting from './twitter-setting.vue';
 import fileTypeIcon from './file-type-icon.vue';
 
+//#region widgets
+import wAccessLog from './widgets/access-log.vue';
+import wVersion from './widgets/version.vue';
+import wRss from './widgets/rss.vue';
+import wProfile from './widgets/profile.vue';
+import wServer from './widgets/server.vue';
+import wBroadcast from './widgets/broadcast.vue';
+import wCalendar from './widgets/calendar.vue';
+import wPhotoStream from './widgets/photo-stream.vue';
+import wSlideshow from './widgets/slideshow.vue';
+import wTips from './widgets/tips.vue';
+import wDonation from './widgets/donation.vue';
+import wNav from './widgets/nav.vue';
+//#endregion
+
 Vue.component('mk-signin', signin);
 Vue.component('mk-signup', signup);
 Vue.component('mk-forkit', forkit);
@@ -41,3 +56,18 @@ Vue.component('mk-messaging-room', messagingRoom);
 Vue.component('mk-url-preview', urlPreview);
 Vue.component('mk-twitter-setting', twitterSetting);
 Vue.component('mk-file-type-icon', fileTypeIcon);
+
+//#region widgets
+Vue.component('mkw-nav', wNav);
+Vue.component('mkw-calendar', wCalendar);
+Vue.component('mkw-photo-stream', wPhotoStream);
+Vue.component('mkw-slideshow', wSlideshow);
+Vue.component('mkw-tips', wTips);
+Vue.component('mkw-donation', wDonation);
+Vue.component('mkw-broadcast', wBroadcast);
+Vue.component('mkw-profile', wProfile);
+Vue.component('mkw-server', wServer);
+Vue.component('mkw-rss', wRss);
+Vue.component('mkw-version', wVersion);
+Vue.component('mkw-access-log', wAccessLog);
+//#endregion
diff --git a/src/web/app/desktop/views/components/widgets/access-log.vue b/src/web/app/common/views/components/widgets/access-log.vue
similarity index 61%
rename from src/web/app/desktop/views/components/widgets/access-log.vue
rename to src/web/app/common/views/components/widgets/access-log.vue
index a04da1daaf..c810c2d157 100644
--- a/src/web/app/desktop/views/components/widgets/access-log.vue
+++ b/src/web/app/common/views/components/widgets/access-log.vue
@@ -1,15 +1,16 @@
 <template>
 <div class="mkw-access-log">
-	<template v-if="props.design == 0">
-		<p class="title">%fa:server%%i18n:desktop.tags.mk-access-log-home-widget.title%</p>
-	</template>
-	<div ref="log">
-		<p v-for="req in requests">
-			<span class="ip" :style="`color:${ req.fg }; background:${ req.bg }`">{{ req.ip }}</span>
-			<b>{{ req.method }}</b>
-			<span>{{ req.path }}</span>
-		</p>
-	</div>
+	<mk-widget-container :show-header="props.design == 0">
+		<template slot="header">%fa:server%%i18n:desktop.tags.mk-access-log-home-widget.title%</template>
+
+		<div :class="$style.logs" ref="log">
+			<p v-for="req in requests">
+				<span :class="$style.ip" :style="`color:${ req.fg }; background:${ req.bg }`">{{ req.ip }}</span>
+				<b>{{ req.method }}</b>
+				<span>{{ req.path }}</span>
+			</p>
+		</div>
+	</mk-widget-container>
 </div>
 </template>
 
@@ -65,44 +66,25 @@ export default define({
 });
 </script>
 
-<style lang="stylus" scoped>
-.mkw-access-log
-	overflow hidden
-	background #fff
-	border solid 1px rgba(0, 0, 0, 0.075)
-	border-radius 6px
+<style lang="stylus" module>
+.logs
+	max-height 250px
+	overflow auto
 
-	> .title
-		z-index 1
+	> p
 		margin 0
-		padding 0 16px
-		line-height 42px
-		font-size 0.9em
-		font-weight bold
-		color #888
-		box-shadow 0 1px rgba(0, 0, 0, 0.07)
+		padding 8px
+		font-size 0.8em
+		color #555
 
-		> [data-fa]
+		&:nth-child(odd)
+			background rgba(0, 0, 0, 0.025)
+
+		> b
 			margin-right 4px
 
-	> div
-		max-height 250px
-		overflow auto
-
-		> p
-			margin 0
-			padding 8px
-			font-size 0.8em
-			color #555
-
-			&:nth-child(odd)
-				background rgba(0, 0, 0, 0.025)
-
-			> .ip
-				margin-right 4px
-				padding 0 4px
-
-			> b
-				margin-right 4px
+.ip
+	margin-right 4px
+	padding 0 4px
 
 </style>
diff --git a/src/web/app/desktop/views/components/widgets/broadcast.vue b/src/web/app/common/views/components/widgets/broadcast.vue
similarity index 96%
rename from src/web/app/desktop/views/components/widgets/broadcast.vue
rename to src/web/app/common/views/components/widgets/broadcast.vue
index e4b7e25321..0bb59caf43 100644
--- a/src/web/app/desktop/views/components/widgets/broadcast.vue
+++ b/src/web/app/common/views/components/widgets/broadcast.vue
@@ -1,5 +1,9 @@
 <template>
-<div class="mkw-broadcast" :data-found="broadcasts.length != 0" :data-melt="props.design == 1">
+<div class="mkw-broadcast"
+	:data-found="broadcasts.length != 0"
+	:data-melt="props.design == 1"
+	:data-mobile="isMobile"
+>
 	<div class="icon">
 		<svg height="32" version="1.1" viewBox="0 0 32 32" width="32">
 			<path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path>
@@ -150,4 +154,8 @@ export default define({
 		display block
 		font-size 0.7em
 
+	&[data-mobile]
+		> p
+			color #fff
+
 </style>
diff --git a/src/web/app/desktop/views/components/widgets/calendar.vue b/src/web/app/common/views/components/widgets/calendar.vue
similarity index 96%
rename from src/web/app/desktop/views/components/widgets/calendar.vue
rename to src/web/app/common/views/components/widgets/calendar.vue
index c16602db46..bfcbd7f68d 100644
--- a/src/web/app/desktop/views/components/widgets/calendar.vue
+++ b/src/web/app/common/views/components/widgets/calendar.vue
@@ -2,6 +2,7 @@
 <div class="mkw-calendar"
 	:data-melt="props.design == 1"
 	:data-special="special"
+	:data-mobile="isMobile"
 >
 	<div class="calendar" :data-is-holiday="isHoliday">
 		<p class="month-and-year">
@@ -66,6 +67,7 @@ export default define({
 	},
 	methods: {
 		func() {
+			if (this.isMobile) return;
 			if (this.props.design == 2) {
 				this.props.design = 0;
 			} else {
@@ -119,6 +121,11 @@ export default define({
 		background transparent
 		border none
 
+	&[data-mobile]
+		border none
+		border-radius 8px
+		box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
 	&:after
 		content ""
 		display block
diff --git a/src/web/app/desktop/views/components/widgets/donation.vue b/src/web/app/common/views/components/widgets/donation.vue
similarity index 79%
rename from src/web/app/desktop/views/components/widgets/donation.vue
rename to src/web/app/common/views/components/widgets/donation.vue
index fbab0fca6c..08aab8ecd1 100644
--- a/src/web/app/desktop/views/components/widgets/donation.vue
+++ b/src/web/app/common/views/components/widgets/donation.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="mkw-donation">
+<div class="mkw-donation" :data-mobile="isMobile">
 	<article>
 		<h1>%fa:heart%%i18n:desktop.tags.mk-donation-home-widget.title%</h1>
 		<p>
@@ -42,4 +42,17 @@ export default define({
 			font-size 0.8em
 			color #999
 
+	&[data-mobile]
+		border none
+		background #ead8bb
+		border-radius 8px
+		box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+
+		> article
+			> h1
+				color #7b8871
+
+			> p
+				color #777d71
+
 </style>
diff --git a/src/web/app/desktop/views/components/widgets/nav.vue b/src/web/app/common/views/components/widgets/nav.vue
similarity index 67%
rename from src/web/app/desktop/views/components/widgets/nav.vue
rename to src/web/app/common/views/components/widgets/nav.vue
index 5e04c266cf..ce88e587a8 100644
--- a/src/web/app/desktop/views/components/widgets/nav.vue
+++ b/src/web/app/common/views/components/widgets/nav.vue
@@ -1,6 +1,10 @@
 <template>
 <div class="mkw-nav">
-	<mk-nav/>
+	<mk-widget-container>
+		<div :class="$style.body">
+			<mk-nav/>
+		</div>
+	</mk-widget-container>
 </div>
 </template>
 
@@ -11,14 +15,12 @@ export default define({
 });
 </script>
 
-<style lang="stylus" scoped>
-.mkw-nav
+<style lang="stylus" module>
+.body
 	padding 16px
 	font-size 12px
 	color #aaa
 	background #fff
-	border solid 1px rgba(0, 0, 0, 0.075)
-	border-radius 6px
 
 	a
 		color #999
diff --git a/src/web/app/common/views/components/widgets/photo-stream.vue b/src/web/app/common/views/components/widgets/photo-stream.vue
new file mode 100644
index 0000000000..dcaa6624dd
--- /dev/null
+++ b/src/web/app/common/views/components/widgets/photo-stream.vue
@@ -0,0 +1,104 @@
+<template>
+<div class="mkw-photo-stream" :class="$style.root" :data-melt="props.design == 2">
+	<mk-widget-container :show-header="props.design == 0" :naked="props.design == 2">
+		<template slot="header">%fa:camera%%i18n:desktop.tags.mk-photo-stream-home-widget.title%</template>
+
+		<p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+		<div :class="$style.stream" v-if="!fetching && images.length > 0">
+			<div v-for="image in images" :key="image.id" :class="$style.img" :style="`background-image: url(${image.url}?thumbnail&size=256)`"></div>
+		</div>
+		<p :class="$style.empty" v-if="!fetching && images.length == 0">%i18n:desktop.tags.mk-photo-stream-home-widget.no-photos%</p>
+	</mk-widget-container>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../../common/define-widget';
+export default define({
+	name: 'photo-stream',
+	props: () => ({
+		design: 0
+	})
+}).extend({
+	data() {
+		return {
+			images: [],
+			fetching: true,
+			connection: null,
+			connectionId: null
+		};
+	},
+	mounted() {
+		this.connection = (this as any).os.stream.getConnection();
+		this.connectionId = (this as any).os.stream.use();
+
+		this.connection.on('drive_file_created', this.onDriveFileCreated);
+
+		(this as any).api('drive/stream', {
+			type: 'image/*',
+			limit: 9
+		}).then(images => {
+			this.images = images;
+			this.fetching = false;
+		});
+	},
+	beforeDestroy() {
+		this.connection.off('drive_file_created', this.onDriveFileCreated);
+		(this as any).os.stream.dispose(this.connectionId);
+	},
+	methods: {
+		onDriveFileCreated(file) {
+			if (/^image\/.+$/.test(file.type)) {
+				this.images.unshift(file);
+				if (this.images.length > 9) this.images.pop();
+			}
+		},
+		func() {
+			if (this.props.design == 2) {
+				this.props.design = 0;
+			} else {
+				this.props.design++;
+			}
+		}
+	}
+});
+</script>
+
+<style lang="stylus" module>
+.root[data-melt]
+	.stream
+		padding 0
+
+	.img
+		border solid 4px transparent
+		border-radius 8px
+
+.stream
+	display -webkit-flex
+	display -moz-flex
+	display -ms-flex
+	display flex
+	justify-content center
+	flex-wrap wrap
+	padding 8px
+
+	.img
+		flex 1 1 33%
+		width 33%
+		height 80px
+		background-position center center
+		background-size cover
+		border solid 2px transparent
+		border-radius 4px
+
+.fetching
+.empty
+	margin 0
+	padding 16px
+	text-align center
+	color #aaa
+
+	> [data-fa]
+		margin-right 4px
+
+</style>
diff --git a/src/web/app/desktop/views/components/widgets/profile.vue b/src/web/app/common/views/components/widgets/profile.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/profile.vue
rename to src/web/app/common/views/components/widgets/profile.vue
diff --git a/src/web/app/common/views/components/widgets/rss.vue b/src/web/app/common/views/components/widgets/rss.vue
new file mode 100644
index 0000000000..e80896bea6
--- /dev/null
+++ b/src/web/app/common/views/components/widgets/rss.vue
@@ -0,0 +1,93 @@
+<template>
+<div class="mkw-rss" :data-mobile="isMobile">
+	<mk-widget-container :show-header="!props.compact">
+		<template slot="header">%fa:rss-square%RSS</template>
+		<button slot="func" title="設定" @click="setting">%fa:cog%</button>
+
+		<p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+		<div :class="$style.feed" v-else>
+			<a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a>
+		</div>
+	</mk-widget-container>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../../common/define-widget';
+export default define({
+	name: 'rss',
+	props: () => ({
+		compact: false
+	})
+}).extend({
+	data() {
+		return {
+			url: 'http://news.yahoo.co.jp/pickup/rss.xml',
+			items: [],
+			fetching: true,
+			clock: null
+		};
+	},
+	mounted() {
+		this.fetch();
+		this.clock = setInterval(this.fetch, 60000);
+	},
+	beforeDestroy() {
+		clearInterval(this.clock);
+	},
+	methods: {
+		func() {
+			this.props.compact = !this.props.compact;
+		},
+		fetch() {
+			fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.url}`, {
+				cache: 'no-cache'
+			}).then(res => {
+				res.json().then(feed => {
+					this.items = feed.items;
+					this.fetching = false;
+				});
+			});
+		},
+		setting() {
+			alert('not implemented yet');
+		}
+	}
+});
+</script>
+
+<style lang="stylus" module>
+.feed
+	padding 12px 16px
+	font-size 0.9em
+
+	> a
+		display block
+		padding 4px 0
+		color #666
+		border-bottom dashed 1px #eee
+
+		&:last-child
+			border-bottom none
+
+.fetching
+	margin 0
+	padding 16px
+	text-align center
+	color #aaa
+
+	> [data-fa]
+		margin-right 4px
+
+&[data-mobile]
+	.feed
+		padding 0
+		font-size 1em
+
+		> a
+			padding 8px 16px
+
+			&:nth-child(even)
+				background #e2e2e2
+
+</style>
diff --git a/src/web/app/desktop/views/components/widgets/server.cpu-memory.vue b/src/web/app/common/views/components/widgets/server.cpu-memory.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/server.cpu-memory.vue
rename to src/web/app/common/views/components/widgets/server.cpu-memory.vue
diff --git a/src/web/app/desktop/views/components/widgets/server.cpu.vue b/src/web/app/common/views/components/widgets/server.cpu.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/server.cpu.vue
rename to src/web/app/common/views/components/widgets/server.cpu.vue
diff --git a/src/web/app/desktop/views/components/widgets/server.disk.vue b/src/web/app/common/views/components/widgets/server.disk.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/server.disk.vue
rename to src/web/app/common/views/components/widgets/server.disk.vue
diff --git a/src/web/app/desktop/views/components/widgets/server.info.vue b/src/web/app/common/views/components/widgets/server.info.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/server.info.vue
rename to src/web/app/common/views/components/widgets/server.info.vue
diff --git a/src/web/app/desktop/views/components/widgets/server.memory.vue b/src/web/app/common/views/components/widgets/server.memory.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/server.memory.vue
rename to src/web/app/common/views/components/widgets/server.memory.vue
diff --git a/src/web/app/desktop/views/components/widgets/server.pie.vue b/src/web/app/common/views/components/widgets/server.pie.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/server.pie.vue
rename to src/web/app/common/views/components/widgets/server.pie.vue
diff --git a/src/web/app/desktop/views/components/widgets/server.uptimes.vue b/src/web/app/common/views/components/widgets/server.uptimes.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/server.uptimes.vue
rename to src/web/app/common/views/components/widgets/server.uptimes.vue
diff --git a/src/web/app/common/views/components/widgets/server.vue b/src/web/app/common/views/components/widgets/server.vue
new file mode 100644
index 0000000000..4ebc5767d6
--- /dev/null
+++ b/src/web/app/common/views/components/widgets/server.vue
@@ -0,0 +1,93 @@
+<template>
+<div class="mkw-server">
+	<mk-widget-container :show-header="props.design == 0" :naked="props.design == 2">
+		<template slot="header">%fa:server%%i18n:desktop.tags.mk-server-home-widget.title%</template>
+		<button slot="func" @click="toggle" title="%i18n:desktop.tags.mk-server-home-widget.toggle%">%fa:sort%</button>
+
+		<p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
+		<template v-if="!fetching">
+			<x-cpu-memory v-show="props.view == 0" :connection="connection"/>
+			<x-cpu v-show="props.view == 1" :connection="connection" :meta="meta"/>
+			<x-memory v-show="props.view == 2" :connection="connection"/>
+			<x-disk v-show="props.view == 3" :connection="connection"/>
+			<x-uptimes v-show="props.view == 4" :connection="connection"/>
+			<x-info v-show="props.view == 5" :connection="connection" :meta="meta"/>
+		</template>
+	</mk-widget-container>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../../common/define-widget';
+import XCpuMemory from './server.cpu-memory.vue';
+import XCpu from './server.cpu.vue';
+import XMemory from './server.memory.vue';
+import XDisk from './server.disk.vue';
+import XUptimes from './server.uptimes.vue';
+import XInfo from './server.info.vue';
+
+export default define({
+	name: 'server',
+	props: () => ({
+		design: 0,
+		view: 0
+	})
+}).extend({
+	components: {
+		XCpuMemory,
+		XCpu,
+		XMemory,
+		XDisk,
+		XUptimes,
+		XInfo
+	},
+	data() {
+		return {
+			fetching: true,
+			meta: null,
+			connection: null,
+			connectionId: null
+		};
+	},
+	mounted() {
+		(this as any).os.getMeta().then(meta => {
+			this.meta = meta;
+			this.fetching = false;
+		});
+
+		this.connection = (this as any).os.streams.serverStream.getConnection();
+		this.connectionId = (this as any).os.streams.serverStream.use();
+	},
+	beforeDestroy() {
+		(this as any).os.streams.serverStream.dispose(this.connectionId);
+	},
+	methods: {
+		toggle() {
+			if (this.props.view == 5) {
+				this.props.view = 0;
+			} else {
+				this.props.view++;
+			}
+		},
+		func() {
+			if (this.props.design == 2) {
+				this.props.design = 0;
+			} else {
+				this.props.design++;
+			}
+		}
+	}
+});
+</script>
+
+<style lang="stylus" module>
+.fetching
+	margin 0
+	padding 16px
+	text-align center
+	color #aaa
+
+	> [data-fa]
+		margin-right 4px
+
+</style>
diff --git a/src/web/app/desktop/views/components/widgets/slideshow.vue b/src/web/app/common/views/components/widgets/slideshow.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/slideshow.vue
rename to src/web/app/common/views/components/widgets/slideshow.vue
diff --git a/src/web/app/desktop/views/components/widgets/tips.vue b/src/web/app/common/views/components/widgets/tips.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/tips.vue
rename to src/web/app/common/views/components/widgets/tips.vue
diff --git a/src/web/app/desktop/views/components/widgets/version.vue b/src/web/app/common/views/components/widgets/version.vue
similarity index 100%
rename from src/web/app/desktop/views/components/widgets/version.vue
rename to src/web/app/common/views/components/widgets/version.vue
diff --git a/src/web/app/desktop/views/components/index.ts b/src/web/app/desktop/views/components/index.ts
index da59d9219e..7584cb4983 100644
--- a/src/web/app/desktop/views/components/index.ts
+++ b/src/web/app/desktop/views/components/index.ts
@@ -27,27 +27,19 @@ import friendsMaker from './friends-maker.vue';
 import followers from './followers.vue';
 import following from './following.vue';
 import usersList from './users-list.vue';
-import wNav from './widgets/nav.vue';
-import wCalendar from './widgets/calendar.vue';
-import wPhotoStream from './widgets/photo-stream.vue';
-import wSlideshow from './widgets/slideshow.vue';
-import wTips from './widgets/tips.vue';
-import wDonation from './widgets/donation.vue';
+import widgetContainer from './widget-container.vue';
+
+//#region widgets
 import wNotifications from './widgets/notifications.vue';
-import wBroadcast from './widgets/broadcast.vue';
 import wTimemachine from './widgets/timemachine.vue';
-import wProfile from './widgets/profile.vue';
-import wServer from './widgets/server.vue';
 import wActivity from './widgets/activity.vue';
-import wRss from './widgets/rss.vue';
 import wTrends from './widgets/trends.vue';
-import wVersion from './widgets/version.vue';
 import wUsers from './widgets/users.vue';
 import wPolls from './widgets/polls.vue';
 import wPostForm from './widgets/post-form.vue';
 import wMessaging from './widgets/messaging.vue';
 import wChannel from './widgets/channel.vue';
-import wAccessLog from './widgets/access-log.vue';
+//#endregion
 
 Vue.component('mk-ui', ui);
 Vue.component('mk-ui-notification', uiNotification);
@@ -76,24 +68,16 @@ Vue.component('mk-friends-maker', friendsMaker);
 Vue.component('mk-followers', followers);
 Vue.component('mk-following', following);
 Vue.component('mk-users-list', usersList);
-Vue.component('mkw-nav', wNav);
-Vue.component('mkw-calendar', wCalendar);
-Vue.component('mkw-photo-stream', wPhotoStream);
-Vue.component('mkw-slideshow', wSlideshow);
-Vue.component('mkw-tips', wTips);
-Vue.component('mkw-donation', wDonation);
+Vue.component('mk-widget-container', widgetContainer);
+
+//#region widgets
 Vue.component('mkw-notifications', wNotifications);
-Vue.component('mkw-broadcast', wBroadcast);
 Vue.component('mkw-timemachine', wTimemachine);
-Vue.component('mkw-profile', wProfile);
-Vue.component('mkw-server', wServer);
 Vue.component('mkw-activity', wActivity);
-Vue.component('mkw-rss', wRss);
 Vue.component('mkw-trends', wTrends);
-Vue.component('mkw-version', wVersion);
 Vue.component('mkw-users', wUsers);
 Vue.component('mkw-polls', wPolls);
 Vue.component('mkw-post-form', wPostForm);
 Vue.component('mkw-messaging', wMessaging);
 Vue.component('mkw-channel', wChannel);
-Vue.component('mkw-access-log', wAccessLog);
+//#endregion
diff --git a/src/web/app/desktop/views/components/widget-container.vue b/src/web/app/desktop/views/components/widget-container.vue
new file mode 100644
index 0000000000..7b4e1f55f0
--- /dev/null
+++ b/src/web/app/desktop/views/components/widget-container.vue
@@ -0,0 +1,72 @@
+<template>
+<div class="mk-widget-container" :class="{ naked }">
+	<header v-if="showHeader">
+		<div class="title"><slot name="header"></slot></div>
+		<slot name="func"></slot>
+	</header>
+	<slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+	props: {
+		showHeader: {
+			type: Boolean,
+			default: true
+		},
+		naked: {
+			type: Boolean,
+			default: false
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-widget-container
+	background #fff
+	border solid 1px rgba(0, 0, 0, 0.075)
+	border-radius 6px
+	overflow hidden
+
+	&.naked
+		background transparent !important
+		border none !important
+
+	> header
+		> .title
+			z-index 1
+			margin 0
+			padding 0 16px
+			line-height 42px
+			font-size 0.9em
+			font-weight bold
+			color #888
+			box-shadow 0 1px rgba(0, 0, 0, 0.07)
+
+			> [data-fa]
+				margin-right 4px
+
+			&:empty
+				display none
+
+		> button
+			position absolute
+			z-index 2
+			top 0
+			right 0
+			padding 0
+			width 42px
+			font-size 0.9em
+			line-height 42px
+			color #ccc
+
+			&:hover
+				color #aaa
+
+			&:active
+				color #999
+
+</style>
diff --git a/src/web/app/desktop/views/components/widgets/photo-stream.vue b/src/web/app/desktop/views/components/widgets/photo-stream.vue
deleted file mode 100644
index 04b71975b3..0000000000
--- a/src/web/app/desktop/views/components/widgets/photo-stream.vue
+++ /dev/null
@@ -1,122 +0,0 @@
-<template>
-<div class="mkw-photo-stream" :data-melt="props.design == 2">
-	<p class="title" v-if="props.design == 0">%fa:camera%%i18n:desktop.tags.mk-photo-stream-home-widget.title%</p>
-	<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
-	<div class="stream" v-if="!fetching && images.length > 0">
-		<div v-for="image in images" :key="image.id" class="img" :style="`background-image: url(${image.url}?thumbnail&size=256)`"></div>
-	</div>
-	<p class="empty" v-if="!fetching && images.length == 0">%i18n:desktop.tags.mk-photo-stream-home-widget.no-photos%</p>
-</div>
-</template>
-
-<script lang="ts">
-import define from '../../../../common/define-widget';
-export default define({
-	name: 'photo-stream',
-	props: () => ({
-		design: 0
-	})
-}).extend({
-	data() {
-		return {
-			images: [],
-			fetching: true,
-			connection: null,
-			connectionId: null
-		};
-	},
-	mounted() {
-		this.connection = (this as any).os.stream.getConnection();
-		this.connectionId = (this as any).os.stream.use();
-
-		this.connection.on('drive_file_created', this.onDriveFileCreated);
-
-		(this as any).api('drive/stream', {
-			type: 'image/*',
-			limit: 9
-		}).then(images => {
-			this.images = images;
-			this.fetching = false;
-		});
-	},
-	beforeDestroy() {
-		this.connection.off('drive_file_created', this.onDriveFileCreated);
-		(this as any).os.stream.dispose(this.connectionId);
-	},
-	methods: {
-		onDriveFileCreated(file) {
-			if (/^image\/.+$/.test(file.type)) {
-				this.images.unshift(file);
-				if (this.images.length > 9) this.images.pop();
-			}
-		},
-		func() {
-			if (this.props.design == 2) {
-				this.props.design = 0;
-			} else {
-				this.props.design++;
-			}
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-.mkw-photo-stream
-	background #fff
-	border solid 1px rgba(0, 0, 0, 0.075)
-	border-radius 6px
-
-	&[data-melt]
-		background transparent !important
-		border none !important
-
-		> .stream
-			padding 0
-
-			> .img
-				border solid 4px transparent
-				border-radius 8px
-
-	> .title
-		z-index 1
-		margin 0
-		padding 0 16px
-		line-height 42px
-		font-size 0.9em
-		font-weight bold
-		color #888
-		box-shadow 0 1px rgba(0, 0, 0, 0.07)
-
-		> [data-fa]
-			margin-right 4px
-
-	> .stream
-		display -webkit-flex
-		display -moz-flex
-		display -ms-flex
-		display flex
-		justify-content center
-		flex-wrap wrap
-		padding 8px
-
-		> .img
-			flex 1 1 33%
-			width 33%
-			height 80px
-			background-position center center
-			background-size cover
-			border solid 2px transparent
-			border-radius 4px
-
-	> .fetching
-	> .empty
-		margin 0
-		padding 16px
-		text-align center
-		color #aaa
-
-		> [data-fa]
-			margin-right 4px
-
-</style>
diff --git a/src/web/app/desktop/views/components/widgets/rss.vue b/src/web/app/desktop/views/components/widgets/rss.vue
deleted file mode 100644
index 3507129716..0000000000
--- a/src/web/app/desktop/views/components/widgets/rss.vue
+++ /dev/null
@@ -1,111 +0,0 @@
-<template>
-<div class="mkw-rss">
-	<template v-if="!props.compact">
-		<p class="title">%fa:rss-square%RSS</p>
-		<button title="設定">%fa:cog%</button>
-	</template>
-	<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
-	<div class="feed" v-else>
-		<a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import define from '../../../../common/define-widget';
-export default define({
-	name: 'rss',
-	props: () => ({
-		compact: false
-	})
-}).extend({
-	data() {
-		return {
-			url: 'http://news.yahoo.co.jp/pickup/rss.xml',
-			items: [],
-			fetching: true,
-			clock: null
-		};
-	},
-	mounted() {
-		this.fetch();
-		this.clock = setInterval(this.fetch, 60000);
-	},
-	beforeDestroy() {
-		clearInterval(this.clock);
-	},
-	methods: {
-		func() {
-			this.props.compact = !this.props.compact;
-		},
-		fetch() {
-			fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.url}`, {
-				cache: 'no-cache'
-			}).then(res => {
-				res.json().then(feed => {
-					this.items = feed.items;
-					this.fetching = false;
-				});
-			});
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-.mkw-rss
-	background #fff
-	border solid 1px rgba(0, 0, 0, 0.075)
-	border-radius 6px
-
-	> .title
-		margin 0
-		padding 0 16px
-		line-height 42px
-		font-size 0.9em
-		font-weight bold
-		color #888
-		box-shadow 0 1px rgba(0, 0, 0, 0.07)
-
-		> [data-fa]
-			margin-right 4px
-
-	> button
-		position absolute
-		top 0
-		right 0
-		padding 0
-		width 42px
-		font-size 0.9em
-		line-height 42px
-		color #ccc
-
-		&:hover
-			color #aaa
-
-		&:active
-			color #999
-
-	> .feed
-		padding 12px 16px
-		font-size 0.9em
-
-		> a
-			display block
-			padding 4px 0
-			color #666
-			border-bottom dashed 1px #eee
-
-			&:last-child
-				border-bottom none
-
-	> .fetching
-		margin 0
-		padding 16px
-		text-align center
-		color #aaa
-
-		> [data-fa]
-			margin-right 4px
-
-</style>
diff --git a/src/web/app/desktop/views/components/widgets/server.vue b/src/web/app/desktop/views/components/widgets/server.vue
deleted file mode 100644
index 1c0da84225..0000000000
--- a/src/web/app/desktop/views/components/widgets/server.vue
+++ /dev/null
@@ -1,131 +0,0 @@
-<template>
-<div class="mkw-server" :data-melt="props.design == 2">
-	<template v-if="props.design == 0">
-		<p class="title">%fa:server%%i18n:desktop.tags.mk-server-home-widget.title%</p>
-		<button @click="toggle" title="%i18n:desktop.tags.mk-server-home-widget.toggle%">%fa:sort%</button>
-	</template>
-	<p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p>
-	<template v-if="!fetching">
-		<x-cpu-memory v-show="props.view == 0" :connection="connection"/>
-		<x-cpu v-show="props.view == 1" :connection="connection" :meta="meta"/>
-		<x-memory v-show="props.view == 2" :connection="connection"/>
-		<x-disk v-show="props.view == 3" :connection="connection"/>
-		<x-uptimes v-show="props.view == 4" :connection="connection"/>
-		<x-info v-show="props.view == 5" :connection="connection" :meta="meta"/>
-	</template>
-</div>
-</template>
-
-<script lang="ts">
-import define from '../../../../common/define-widget';
-import XCpuMemory from './server.cpu-memory.vue';
-import XCpu from './server.cpu.vue';
-import XMemory from './server.memory.vue';
-import XDisk from './server.disk.vue';
-import XUptimes from './server.uptimes.vue';
-import XInfo from './server.info.vue';
-
-export default define({
-	name: 'server',
-	props: () => ({
-		design: 0,
-		view: 0
-	})
-}).extend({
-	components: {
-		XCpuMemory,
-		XCpu,
-		XMemory,
-		XDisk,
-		XUptimes,
-		XInfo
-	},
-	data() {
-		return {
-			fetching: true,
-			meta: null,
-			connection: null,
-			connectionId: null
-		};
-	},
-	mounted() {
-		(this as any).os.getMeta().then(meta => {
-			this.meta = meta;
-			this.fetching = false;
-		});
-
-		this.connection = (this as any).os.streams.serverStream.getConnection();
-		this.connectionId = (this as any).os.streams.serverStream.use();
-	},
-	beforeDestroy() {
-		(this as any).os.streams.serverStream.dispose(this.connectionId);
-	},
-	methods: {
-		toggle() {
-			if (this.props.view == 5) {
-				this.props.view = 0;
-			} else {
-				this.props.view++;
-			}
-		},
-		func() {
-			if (this.props.design == 2) {
-				this.props.design = 0;
-			} else {
-				this.props.design++;
-			}
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-.mkw-server
-	background #fff
-	border solid 1px rgba(0, 0, 0, 0.075)
-	border-radius 6px
-
-	&[data-melt]
-		background transparent !important
-		border none !important
-
-	> .title
-		z-index 1
-		margin 0
-		padding 0 16px
-		line-height 42px
-		font-size 0.9em
-		font-weight bold
-		color #888
-		box-shadow 0 1px rgba(0, 0, 0, 0.07)
-
-		> [data-fa]
-			margin-right 4px
-
-	> button
-		position absolute
-		z-index 2
-		top 0
-		right 0
-		padding 0
-		width 42px
-		font-size 0.9em
-		line-height 42px
-		color #ccc
-
-		&:hover
-			color #aaa
-
-		&:active
-			color #999
-
-	> .fetching
-		margin 0
-		padding 16px
-		text-align center
-		color #aaa
-
-		> [data-fa]
-			margin-right 4px
-
-</style>
diff --git a/src/web/app/mobile/views/pages/user/home.activity.vue b/src/web/app/mobile/views/components/activity.vue
similarity index 96%
rename from src/web/app/mobile/views/pages/user/home.activity.vue
rename to src/web/app/mobile/views/components/activity.vue
index 87970795b2..b50044b3de 100644
--- a/src/web/app/mobile/views/pages/user/home.activity.vue
+++ b/src/web/app/mobile/views/components/activity.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="root activity">
+<div class="mk-activity">
 	<svg v-if="data" ref="canvas" viewBox="0 0 30 1" preserveAspectRatio="none">
 		<g v-for="(d, i) in data">
 			<rect width="0.8" :height="d.postsH"
@@ -47,7 +47,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-.root.activity
+.mk-activity
 	max-width 600px
 	margin 0 auto
 
diff --git a/src/web/app/mobile/views/components/home.vue b/src/web/app/mobile/views/components/home.vue
deleted file mode 100644
index 3feab581d2..0000000000
--- a/src/web/app/mobile/views/components/home.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-<template>
-<div class="mk-home">
-	<mk-timeline @loaded="onTlLoaded"/>
-</div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-export default Vue.extend({
-	methods: {
-		onTlLoaded() {
-			this.$emit('loaded');
-		}
-	}
-});
-</script>
-
-<style lang="stylus" scoped>
-.mk-home
-
-	> .mk-timeline
-		max-width 600px
-		margin 0 auto
-		padding 8px
-
-	@media (min-width 500px)
-		padding 16px
-
-</style>
diff --git a/src/web/app/mobile/views/components/index.ts b/src/web/app/mobile/views/components/index.ts
index 905baaf20d..d372f22332 100644
--- a/src/web/app/mobile/views/components/index.ts
+++ b/src/web/app/mobile/views/components/index.ts
@@ -1,7 +1,6 @@
 import Vue from 'vue';
 
 import ui from './ui.vue';
-import home from './home.vue';
 import timeline from './timeline.vue';
 import posts from './posts.vue';
 import imagesImage from './images-image.vue';
@@ -19,9 +18,14 @@ import notificationPreview from './notification-preview.vue';
 import usersList from './users-list.vue';
 import userPreview from './user-preview.vue';
 import userTimeline from './user-timeline.vue';
+import activity from './activity.vue';
+import widgetContainer from './widget-container.vue';
+
+//#region widgets
+import wActivity from './widgets/activity.vue';
+//#endregion
 
 Vue.component('mk-ui', ui);
-Vue.component('mk-home', home);
 Vue.component('mk-timeline', timeline);
 Vue.component('mk-posts', posts);
 Vue.component('mk-images-image', imagesImage);
@@ -39,3 +43,9 @@ Vue.component('mk-notification-preview', notificationPreview);
 Vue.component('mk-users-list', usersList);
 Vue.component('mk-user-preview', userPreview);
 Vue.component('mk-user-timeline', userTimeline);
+Vue.component('mk-activity', activity);
+Vue.component('mk-widget-container', widgetContainer);
+
+//#region widgets
+Vue.component('mkw-activity', wActivity);
+//#endregion
diff --git a/src/web/app/mobile/views/components/ui.header.vue b/src/web/app/mobile/views/components/ui.header.vue
index 2df5ea162e..026e7eb1b4 100644
--- a/src/web/app/mobile/views/components/ui.header.vue
+++ b/src/web/app/mobile/views/components/ui.header.vue
@@ -9,9 +9,7 @@
 			<h1>
 				<slot>Misskey</slot>
 			</h1>
-			<button v-if="func" @click="func">
-				<slot name="funcIcon"></slot>
-			</button>
+			<slot name="func"></slot>
 		</div>
 	</div>
 </div>
diff --git a/src/web/app/mobile/views/components/ui.vue b/src/web/app/mobile/views/components/ui.vue
index 91d7ea29b6..325ce9d40e 100644
--- a/src/web/app/mobile/views/components/ui.vue
+++ b/src/web/app/mobile/views/components/ui.vue
@@ -1,7 +1,7 @@
 <template>
 <div class="mk-ui">
-	<x-header :func="func">
-		<template slot="funcIcon"><slot name="funcIcon"></slot></template>
+	<x-header>
+		<template slot="func"><slot name="func"></slot></template>
 		<slot name="header"></slot>
 	</x-header>
 	<x-nav :is-open="isDrawerOpening"/>
@@ -23,7 +23,7 @@ export default Vue.extend({
 		XHeader,
 		XNav
 	},
-	props: ['title', 'func'],
+	props: ['title'],
 	data() {
 		return {
 			isDrawerOpening: false,
diff --git a/src/web/app/mobile/views/components/widget-container.vue b/src/web/app/mobile/views/components/widget-container.vue
new file mode 100644
index 0000000000..1775188a93
--- /dev/null
+++ b/src/web/app/mobile/views/components/widget-container.vue
@@ -0,0 +1,65 @@
+<template>
+<div class="mk-widget-container" :class="{ naked }">
+	<header v-if="showHeader">
+		<div class="title"><slot name="header"></slot></div>
+		<slot name="func"></slot>
+	</header>
+	<slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+	props: {
+		showHeader: {
+			type: Boolean,
+			default: true
+		},
+		naked: {
+			type: Boolean,
+			default: false
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-widget-container
+	background #eee
+	border-radius 8px
+	box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
+	overflow hidden
+
+	&.naked
+		background transparent !important
+		border none !important
+
+	> header
+		> .title
+			margin 0
+			padding 8px 10px
+			font-size 15px
+			font-weight normal
+			color #465258
+			background #fff
+			border-radius 8px 8px 0 0
+
+			> [data-fa]
+				margin-right 6px
+
+			&:empty
+				display none
+
+		> button
+			position absolute
+			z-index 2
+			top 0
+			right 0
+			padding 0
+			width 42px
+			height 100%
+			font-size 15px
+			color #465258
+
+</style>
diff --git a/src/web/app/mobile/views/components/widgets/activity.vue b/src/web/app/mobile/views/components/widgets/activity.vue
new file mode 100644
index 0000000000..c3fe63f264
--- /dev/null
+++ b/src/web/app/mobile/views/components/widgets/activity.vue
@@ -0,0 +1,23 @@
+<template>
+<div class="mkw-activity">
+	<mk-widget-container>
+		<template slot="header">%fa:chart-bar%アクティビティ</template>
+		<div :class="$style.body">
+			<mk-activity :user="os.i"/>
+		</div>
+	</mk-widget-container>
+</div>
+</template>
+
+<script lang="ts">
+import define from '../../../../common/define-widget';
+
+export default define({
+	name: 'activity',
+});
+</script>
+
+<style lang="stylus" module>
+.body
+	padding 8px
+</style>
diff --git a/src/web/app/mobile/views/pages/drive.vue b/src/web/app/mobile/views/pages/drive.vue
index 47aeb52f49..ea61661cf6 100644
--- a/src/web/app/mobile/views/pages/drive.vue
+++ b/src/web/app/mobile/views/pages/drive.vue
@@ -1,11 +1,11 @@
 <template>
-<mk-ui :func="fn">
+<mk-ui>
 	<span slot="header">
 		<template v-if="folder">%fa:R folder-open%{{ folder.name }}</template>
 		<template v-if="file"><mk-file-type-icon class="icon" :type="file.type"/>{{ file.name }}</template>
 		<template v-if="!folder && !file">%fa:cloud%%i18n:mobile.tags.mk-drive-page.drive%</template>
 	</span>
-	<template slot="funcIcon">%fa:ellipsis-h%</template>
+	<template slot="func"><button @click="fn">%fa:ellipsis-h%</button></template>
 	<mk-drive
 		ref="browser"
 		:init-folder="initFolder"
diff --git a/src/web/app/mobile/views/pages/home.vue b/src/web/app/mobile/views/pages/home.vue
index c81cbcadb3..0466fbbbf8 100644
--- a/src/web/app/mobile/views/pages/home.vue
+++ b/src/web/app/mobile/views/pages/home.vue
@@ -1,24 +1,112 @@
 <template>
-<mk-ui :func="fn">
-	<span slot="header">%fa:home%%i18n:mobile.tags.mk-home.home%</span>
-	<template slot="funcIcon">%fa:pencil-alt%</template>
-	<mk-home @loaded="onHomeLoaded"/>
+<mk-ui>
+	<span slot="header" @click="showTl = !showTl">
+		<template v-if="showTl">%fa:home%タイムライン</template>
+		<template v-else>%fa:home%ウィジェット</template>
+		<span style="margin-left:8px">
+			<template v-if="showTl">%fa:angle-down%</template>
+			<template v-else>%fa:angle-up%</template>
+		</span>
+	</span>
+	<template slot="func">
+		<button @click="fn" v-if="showTl">%fa:pencil-alt%</button>
+		<button @click="customizing = !customizing" v-else>%fa:cog%</button>
+	</template>
+	<main>
+		<div class="tl">
+			<mk-timeline @loaded="onLoaded" v-show="showTl"/>
+		</div>
+		<div class="widgets" v-if="!showTl">
+			<template v-if="customizing">
+				<header>
+					<select v-model="widgetAdderSelected">
+						<option value="profile">プロフィール</option>
+						<option value="calendar">カレンダー</option>
+						<option value="activity">アクティビティ</option>
+						<option value="rss">RSSリーダー</option>
+						<option value="photo-stream">フォトストリーム</option>
+						<option value="version">バージョン</option>
+						<option value="access-log">アクセスログ</option>
+						<option value="server">サーバー情報</option>
+						<option value="donation">寄付のお願い</option>
+						<option value="nav">ナビゲーション</option>
+						<option value="tips">ヒント</option>
+					</select>
+					<button @click="addWidget">追加</button>
+					<p>移動するには「三」をドラッグします。削除するには「x」をタップします。</p>
+				</header>
+				<x-draggable
+					:list="widgets"
+					:options="{ handle: '.handle', animation: 150 }"
+					@sort="onWidgetSort"
+				>
+					<div v-for="widget in widgets" class="customize-container" :key="widget.id">
+						<header>
+							<span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button>
+						</header>
+						<div>
+							<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-mobile="true"/>
+						</div>
+					</div>
+				</x-draggable>
+			</template>
+			<template v-else>
+				<component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/>
+			</template>
+		</div>
+	</main>
 </mk-ui>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
+import * as XDraggable from 'vuedraggable';
+import * as uuid from 'uuid';
 import Progress from '../../../common/scripts/loading';
 import getPostSummary from '../../../../../common/get-post-summary';
 
 export default Vue.extend({
+	components: {
+		XDraggable
+	},
 	data() {
 		return {
 			connection: null,
 			connectionId: null,
-			unreadCount: 0
+			unreadCount: 0,
+			showTl: true,
+			widgets: [],
+			customizing: false,
+			widgetAdderSelected: null
 		};
 	},
+	created() {
+		if ((this as any).os.i.client_settings.mobile_home == null) {
+			Vue.set((this as any).os.i.client_settings, 'mobile_home',  [{
+				name: 'calendar',
+				id: 'a'
+			}, {
+				name: 'activity',
+				id: 'b'
+			}, {
+				name: 'rss',
+				id: 'c'
+			}, {
+				name: 'photo-stream',
+				id: 'd'
+			}, {
+				name: 'donation',
+				id: 'e'
+			}, {
+				name: 'nav',
+				id: 'f'
+			}, {
+				name: 'version',
+				id: 'g'
+			}]);
+		}
+		this.widgets = (this as any).os.i.client_settings.mobile_home;
+	},
 	mounted() {
 		document.title = 'Misskey';
 		document.documentElement.style.background = '#313a42';
@@ -40,7 +128,7 @@ export default Vue.extend({
 		fn() {
 			(this as any).apis.post();
 		},
-		onHomeLoaded() {
+		onLoaded() {
 			Progress.done();
 		},
 		onStreamPost(post) {
@@ -54,7 +142,81 @@ export default Vue.extend({
 				this.unreadCount = 0;
 				document.title = 'Misskey';
 			}
+		},
+		onWidgetSort() {
+			this.saveHome();
+		},
+		addWidget() {
+			const widget = {
+				name: this.widgetAdderSelected,
+				id: uuid(),
+				data: {}
+			};
+
+			this.widgets.unshift(widget);
+			this.saveHome();
+		},
+		removeWidget(widget) {
+			this.widgets = this.widgets.filter(w => w.id != widget.id);
+			this.saveHome();
+		},
+		saveHome() {
+			(this as any).api('i/update_mobile_home', {
+				home: this.widgets
+			});
+		},
+		warp() {
+
 		}
 	}
 });
 </script>
+
+<style lang="stylus" scoped>
+main
+
+	> .tl
+		> .mk-timeline
+			max-width 600px
+			margin 0 auto
+			padding 8px
+
+			@media (min-width 500px)
+				padding 16px
+
+	> .widgets
+		margin 0 auto
+		max-width 500px
+
+		> header
+			padding 8px
+			background #fff
+
+		.widget
+			margin 8px
+
+		.customize-container
+			margin 8px
+			background #fff
+
+			> header
+				line-height 32px
+				background #eee
+
+				> .handle
+					padding 0 8px
+
+				> .remove
+					position absolute
+					top 0
+					right 0
+					padding 0 8px
+					line-height 32px
+
+			> div
+				padding 8px
+
+				> *
+					pointer-events none
+
+</style>
diff --git a/src/web/app/mobile/views/pages/notifications.vue b/src/web/app/mobile/views/pages/notifications.vue
index b1243dbc74..3dcfb2f38c 100644
--- a/src/web/app/mobile/views/pages/notifications.vue
+++ b/src/web/app/mobile/views/pages/notifications.vue
@@ -1,7 +1,7 @@
 <template>
-<mk-ui :func="fn">
+<mk-ui>
 	<span slot="header">%fa:R bell%%i18n:mobile.tags.mk-notifications-page.notifications%</span>
-	<span slot="funcIcon">%fa:check%</span>
+	<template slot="func"><button @click="fn">%fa:check%</button></template>
 	<mk-notifications @fetched="onFetched"/>
 </mk-ui>
 </template>
diff --git a/src/web/app/mobile/views/pages/user.vue b/src/web/app/mobile/views/pages/user.vue
index 27f65e623d..378beeaf13 100644
--- a/src/web/app/mobile/views/pages/user.vue
+++ b/src/web/app/mobile/views/pages/user.vue
@@ -1,7 +1,6 @@
 <template>
 <mk-ui>
 	<span slot="header" v-if="!fetching">%fa:user% {{ user.name }}</span>
-	<template slot="funcIcon">%fa:pencil-alt%</template>
 	<main v-if="!fetching">
 		<header>
 			<div class="banner" :style="user.banner_url ? `background-image: url(${user.banner_url}?thumbnail&size=1024)` : ''"></div>
diff --git a/src/web/app/mobile/views/pages/user/home.vue b/src/web/app/mobile/views/pages/user/home.vue
index 4c68317879..fdbfd1bf55 100644
--- a/src/web/app/mobile/views/pages/user/home.vue
+++ b/src/web/app/mobile/views/pages/user/home.vue
@@ -16,7 +16,7 @@
 	<section class="activity">
 		<h2>%fa:chart-bar%%i18n:mobile.tags.mk-user-overview.activity%</h2>
 		<div>
-			<x-activity :user="user"/>
+			<mk-activity :user="user"/>
 		</div>
 	</section>
 	<section class="frequently-replied-users">
@@ -41,15 +41,13 @@ import XPosts from './home.posts.vue';
 import XPhotos from './home.photos.vue';
 import XFriends from './home.friends.vue';
 import XFollowersYouKnow from './home.followers-you-know.vue';
-import XActivity from './home.activity.vue';
 
 export default Vue.extend({
 	components: {
 		XPosts,
 		XPhotos,
 		XFriends,
-		XFollowersYouKnow,
-		XActivity
+		XFollowersYouKnow
 	},
 	props: ['user']
 });