[hub][vue] Publish Page
diff --git a/erpnext/hub_node/api.py b/erpnext/hub_node/api.py
index 85693c7..c4a7925 100644
--- a/erpnext/hub_node/api.py
+++ b/erpnext/hub_node/api.py
@@ -39,6 +39,7 @@
 
 		item['doctype'] = 'Hub Item'
 		item['hub_seller'] = hub_seller
+		item.pop('attachments', None)
 
 	return items
 
@@ -71,24 +72,19 @@
 	if not len(items_to_publish):
 		frappe.throw('No items to publish')
 
-	publishing_items = []
-
-	for item_additional_info in items_to_publish:
-		item_code = item_additional_info.get('item_code')
+	for item in items_to_publish:
+		item_code = item.get('item_code')
 		frappe.db.set_value('Item', item_code, 'publish_in_hub', 1)
 
 		frappe.get_doc({
 			'doctype': 'Hub Tracked Item',
 			'item_code': item_code,
-			'hub_category': item_additional_info.get('hub_category'),
-			'image_list': item_additional_info.get('image_list')
+			'hub_category': item.get('hub_category'),
+			'image_list': item.get('image_list')
 		}).insert()
 
-		item_data = frappe.get_doc("Item", item_code).as_dict().update(item_additional_info)
-		publishing_items.append(item_data)
 
-
-	items = map_fields(publishing_items)
+	items = map_fields(items_to_publish)
 
 	try:
 		item_sync_preprocess()
diff --git a/erpnext/public/js/hub/components/EmptyState.vue b/erpnext/public/js/hub/components/EmptyState.vue
index 1f36c3c..c0ad971 100644
--- a/erpnext/public/js/hub/components/EmptyState.vue
+++ b/erpnext/public/js/hub/components/EmptyState.vue
@@ -25,7 +25,7 @@
 
 	.empty-state {
 		height: 500px;
-		margin: 15px 0px;
+		margin: 15px;
 	}
 
 	.empty-state.bordered {
diff --git a/erpnext/public/js/hub/components/ItemCard.vue b/erpnext/public/js/hub/components/ItemCard.vue
index 7a7f821..4d72344 100644
--- a/erpnext/public/js/hub/components/ItemCard.vue
+++ b/erpnext/public/js/hub/components/ItemCard.vue
@@ -1,11 +1,18 @@
 <template>
 	<div class="col-md-3 col-sm-4 col-xs-6 hub-card-container">
-		<div class="hub-card is_local">
-			<div class="hub-card-header flex">
+		<div class="hub-card is_local"
+			@click="on_click(item_id)"
+		>
+			<div class="hub-card-header flex justify-between">
 				<div>
 					<div class="hub-card-title ellipsis bold">{{ title }}</div>
 					<div class="hub-card-subtitle ellipsis text-muted" v-html='subtitle'></div>
 				</div>
+				<i v-if="allow_clear"
+					class="octicon octicon-x text-extra-muted"
+					@click="$emit('remove-item', item_id)"
+				>
+				</i>
 			</div>
 			<div class="hub-card-body">
 				<img class="hub-card-image" :src="item.image"/>
@@ -27,16 +34,93 @@
 
 export default {
 	name: 'item-card',
-	props: ['item', 'is_local'],
+	props: ['item', 'item_id_fieldname', 'on_click', 'allow_clear'],
 	computed: {
 		title() {
 			return this.item.item_name
 		},
 		subtitle() {
 			return comment_when(this.item.creation);
+		},
+		item_id() {
+			return this.item[this.item_id_fieldname];
 		}
 	}
 }
 </script>
 
-<style scoped></style>
+<style lang="less" scoped>
+	@import "../../../../../../frappe/frappe/public/less/variables.less";
+
+	.hub-card {
+		margin-bottom: 25px;
+		position: relative;
+		border: 1px solid @border-color;
+		border-radius: 4px;
+		overflow: hidden;
+
+		&:hover .hub-card-overlay {
+			display: block;
+		}
+
+		.octicon-x {
+			display: block;
+			font-size: 20px;
+			margin-left: 10px;
+			cursor: pointer;
+		}
+	}
+
+	.hub-card.closable {
+		.octicon-x {
+			display: block;
+		}
+	}
+
+	.hub-card.is-local {
+		&.active {
+			.hub-card-header {
+				background-color: #f4ffe5;
+			}
+		}
+	}
+
+	.hub-card-header {
+		position: relative;
+		padding: 12px 15px;
+		height: 60px;
+		border-bottom: 1px solid @border-color;
+	}
+
+	.hub-card-body {
+		position: relative;
+		height: 200px;
+	}
+
+	.hub-card-overlay {
+		display: none;
+		position: absolute;
+		top: 0;
+		width: 100%;
+		height: 100%;
+		background-color: rgba(0, 0, 0, 0.05);
+	}
+
+	.hub-card-overlay-body {
+		position: relative;
+		height: 100%;
+	}
+
+	.hub-card-overlay-button {
+		position: absolute;
+		right: 15px;
+		bottom: 15px;
+	}
+
+	.hub-card-image {
+		width: 100%;
+		height: 100%;
+		object-fit: contain;
+	}
+
+</style>
diff --git a/erpnext/public/js/hub/components/ItemCardsContainer.vue b/erpnext/public/js/hub/components/ItemCardsContainer.vue
index 01fd01e..0e75795 100644
--- a/erpnext/public/js/hub/components/ItemCardsContainer.vue
+++ b/erpnext/public/js/hub/components/ItemCardsContainer.vue
@@ -1,5 +1,5 @@
 <template>
-	<div>
+	<div class="item-cards-container">
 		<empty-state
 			v-if="items.length === 0"
 			:message="empty_state_message"
@@ -9,9 +9,12 @@
 		</empty-state>
 		<item-card
 			v-for="item in items"
-			:key="item[item_id]"
+			:key="item[item_id_fieldname]"
 			:item="item"
-			:is_local="is_local"
+			:item_id_fieldname="item_id_fieldname"
+			:on_click="on_click"
+			:allow_clear="editable"
+			@remove-item="$emit('remove-item', item[item_id_fieldname])"
 		>
 		</item-card>
 	</div>
@@ -24,28 +27,31 @@
 export default {
 	name: 'item-cards-container',
 	props: {
-		'items': Array,
-		'is_local': Boolean,
+		items: Array,
+		item_id_fieldname: String,
+		on_click: Function,
+		editable: Boolean,
 
-		'empty_state_message': String,
-		'empty_state_height': Number,
-		'empty_state_bordered': Boolean
+		empty_state_message: String,
+		empty_state_height: Number,
+		empty_state_bordered: Boolean
 	},
 	components: {
 		ItemCard,
 		EmptyState
 	},
-	computed: {
-		item_id() {
-			return this.is_local ? 'item_code' : 'hub_item_code';
-		}
-	},
 	watch: {
 		items() {
+			// TODO: handling doesn't work
 			frappe.dom.handle_broken_images($(this.$el));
 		}
 	}
 }
 </script>
 
-<style scoped></style>
+<style scoped>
+	.item-cards-container {
+		margin: 0 -15px;
+		overflow: overlay;
+	}
+</style>
diff --git a/erpnext/public/js/hub/components/PublishPage.vue b/erpnext/public/js/hub/components/PublishPage.vue
index 2eaf295..2068813 100644
--- a/erpnext/public/js/hub/components/PublishPage.vue
+++ b/erpnext/public/js/hub/components/PublishPage.vue
@@ -13,14 +13,19 @@
 			<h5>{{ page_title }}</h5>
 
 			<button class="btn btn-primary btn-sm publish-items"
-				:disabled="no_selected_items">
+				:disabled="no_selected_items"
+				@click="publish_selected_items"
+			>
 				<span>{{ publish_button_text }}</span>
 			</button>
 		</div>
 
 		<item-cards-container
 			:items="selected_items"
-			:is_local="true"
+			:item_id_fieldname="item_id_fieldname"
+			:editable="true"
+			@remove-item="remove_item_from_selection"
+
 			:empty_state_message="empty_state_message"
 			:empty_state_bordered="true"
 			:empty_state_height="80"
@@ -38,7 +43,8 @@
 
 		<item-cards-container
 			:items="valid_items"
-			:is_local="true"
+			:item_id_fieldname="item_id_fieldname"
+			:on_click="show_publishing_dialog_for_item"
 		>
 		</item-cards-container>
 	</div>
@@ -48,6 +54,7 @@
 import SearchInput from './SearchInput.vue';
 import ItemCardsContainer from './ItemCardsContainer.vue';
 import NotificationMessage from './NotificationMessage.vue';
+import { ItemPublishDialog } from './item_publish_dialog';
 
 export default {
 	name: 'publish-page',
@@ -56,7 +63,9 @@
 			page_name: frappe.get_route()[1],
 			valid_items: [],
 			selected_items: [],
+			items_data_to_publish: {},
 			search_value: '',
+			item_id_fieldname: 'item_code',
 
 			// Constants
 			page_title: __('Publish Products'),
@@ -95,10 +104,20 @@
 				text = `Publish ${number} Products`;
 			}
 			return __(text);
-		}
+		},
+
+		items_dict() {
+			let items_dict = {};
+			this.valid_items.map(item => {
+				items_dict[item[this.item_id_fieldname]] = item
+			})
+
+			return items_dict;
+		},
 	},
 	created() {
 		this.get_valid_items();
+		this.make_publishing_dialog();
 	},
 	methods: {
 		get_valid_items() {
@@ -113,8 +132,86 @@
 			})
 		},
 
+		publish_selected_items() {
+			frappe.call(
+			'erpnext.hub_node.api.publish_selected_items',
+				{
+					items_to_publish: this.selected_items
+				}
+			)
+			.then((r) => {
+				this.selected_items = [];
+				return frappe.db.get_doc('Hub Settings');
+			})
+			.then(doc => {
+				hub.settings = doc;
+				this.add_last_sync_message();
+			});
+		},
+
+		add_last_sync_message() {
+			this.last_sync_message = __(`Last sync was
+				<a href="#marketplace/profile">
+					${comment_when(hub.settings.last_sync_datetime)}</a>.
+				<a href="#marketplace/my-products">
+					See your Published Products</a>.`);
+		},
+
 		clear_last_sync_message() {
 			this.last_sync_message = '';
+		},
+
+		remove_item_from_selection(item_code) {
+			this.selected_items = this.selected_items
+				.filter(item => item.item_code !== item_code);
+		},
+
+		make_publishing_dialog() {
+			this.item_publish_dialog = ItemPublishDialog(
+				{
+					fn: (values) => {
+						this.add_item_to_publish(values);
+						this.item_publish_dialog.hide();
+					}
+				},
+				{
+					fn: () => {
+						const values = this.item_publish_dialog.get_values(true);
+						this.update_items_data_to_publish(values);
+					}
+				}
+			);
+		},
+
+		add_item_to_publish(values) {
+			this.update_items_data_to_publish(values);
+
+			const item_code  = values.item_code;
+			let item_doc = this.items_dict[item_code];
+
+			const item_to_publish = Object.assign({}, item_doc, values);
+			this.selected_items.push(item_to_publish);
+		},
+
+		update_items_data_to_publish(values) {
+			this.items_data_to_publish[values.item_code] = values;
+		},
+
+		show_publishing_dialog_for_item(item_code) {
+			let item_data = this.items_data_to_publish[item_code];
+			if(!item_data) { item_data = { item_code }; };
+
+			this.item_publish_dialog.clear();
+
+			const item_doc = this.items_dict[item_code];
+			if(item_doc) {
+				this.item_publish_dialog.fields_dict.image_list.set_data(
+					item_doc.attachments.map(attachment => attachment.file_url)
+				);
+			}
+
+			this.item_publish_dialog.set_values(item_data);
+			this.item_publish_dialog.show();
 		}
 	}
 }
diff --git a/erpnext/public/js/hub/components/item_publish_dialog.js b/erpnext/public/js/hub/components/item_publish_dialog.js
index a5d312c..e49ba53 100644
--- a/erpnext/public/js/hub/components/item_publish_dialog.js
+++ b/erpnext/public/js/hub/components/item_publish_dialog.js
@@ -1,54 +1,42 @@
 function ItemPublishDialog(primary_action, secondary_action) {
-    let dialog = new frappe.ui.Dialog({
-        title: __('Edit Publishing Details'),
-        fields: [
-            {
-                "label": "Item Code",
-                "fieldname": "item_code",
-                "fieldtype": "Data",
-                "read_only": 1
-            },
-            {
-                "label": "Hub Category",
-                "fieldname": "hub_category",
-                "fieldtype": "Autocomplete",
-                "options": [],
-                "reqd": 1
-            },
-            {
-                "label": "Images",
-                "fieldname": "image_list",
-                "fieldtype": "MultiSelect",
-                "options": [],
-                "reqd": 1
-            }
-        ],
-        primary_action_label: primary_action.label || __('Set Details'),
-        primary_action: primary_action.fn,
-        secondary_action: secondary_action.fn
-    });
+	let dialog = new frappe.ui.Dialog({
+		title: __('Edit Publishing Details'),
+		fields: [
+			{
+				"label": "Item Code",
+				"fieldname": "item_code",
+				"fieldtype": "Data",
+				"read_only": 1
+			},
+			{
+				"label": "Hub Category",
+				"fieldname": "hub_category",
+				"fieldtype": "Autocomplete",
+				"options": [],
+				"reqd": 1
+			},
+			{
+				"label": "Images",
+				"fieldname": "image_list",
+				"fieldtype": "MultiSelect",
+				"options": [],
+				"reqd": 1
+			}
+		],
+		primary_action_label: primary_action.label || __('Set Details'),
+		primary_action: primary_action.fn,
+		secondary_action: secondary_action.fn
+	});
 
+	hub.call('get_categories')
+		.then(categories => {
+			categories = categories.map(d => d.name);
+			dialog.fields_dict.hub_category.set_data(categories);
+		});
 
-    function set_hub_category_options(data) {
-        dialog.fields_dict.hub_category.set_data(
-            data.map(d => d.name)
-        );
-    }
-
-    const hub_call_key = 'get_categories{}';
-    const categories_cache = erpnext.hub.cache[hub_call_key];
-
-    if(categories_cache) {
-        set_hub_category_options(categories_cache);
-    }
-
-    erpnext.hub.on(`response:${hub_call_key}`, (data) => {
-        set_hub_category_options(data.response);
-    });
-
-    return dialog;
+	return dialog;
 }
 
 export {
-    ItemPublishDialog
+	ItemPublishDialog
 }
diff --git a/erpnext/public/js/hub/components/publishing_area.js b/erpnext/public/js/hub/components/publishing_area.js
deleted file mode 100644
index d510ad4..0000000
--- a/erpnext/public/js/hub/components/publishing_area.js
+++ /dev/null
@@ -1,37 +0,0 @@
-function get_publishing_header() {
-    const title_html = `<h5>${__('Select Products to Publish')}</h5>`;
-
-    const subtitle_html = `<p class="text-muted">
-        ${__(`Only products with an image, description and category can be published.
-        Please update them if an item in your inventory does not appear.`)}
-    </p>`;
-
-    const publish_button_html = `<button class="btn btn-primary btn-sm publish-items" disabled>
-        <i class="visible-xs octicon octicon-check"></i>
-        <span class="hidden-xs">${__('Publish')}</span>
-    </button>`;
-
-    return $(`
-        <div class="publish-area empty">
-            <div class="publish-area-head">
-                ${title_html}
-                ${publish_button_html}
-            </div>
-            <div id="vue-area"></div>
-            <div class="empty-items-container flex align-center flex-column justify-center">
-                <p class="text-muted">${__('No Items Selected')}</p>
-            </div>
-            <div class="row hub-items-container selected-items"></div>
-        </div>
-
-        <div class='subpage-title flex'>
-            <div>
-                ${subtitle_html}
-            </div>
-        </div>
-    `);
-}
-
-export {
-    get_publishing_header
-}
diff --git a/erpnext/public/js/hub/pages/publish.js b/erpnext/public/js/hub/pages/publish.js
index 725fdc8..59b6c38 100644
--- a/erpnext/public/js/hub/pages/publish.js
+++ b/erpnext/public/js/hub/pages/publish.js
@@ -1,11 +1,3 @@
-import SubPage from './subpage';
-import { get_item_card_container_html } from '../components/items_container';
-import { get_local_item_card_html } from '../components/item_card';
-import { make_search_bar } from '../components/search_bar';
-import { get_publishing_header } from '../components/publishing_area';
-import { ItemPublishDialog } from '../components/item_publish_dialog';
-
-
 import PublishPage from '../components/PublishPage.vue';
 
 erpnext.hub.Publish = class Publish {
@@ -25,259 +17,4 @@
 		$('[data-page-name="publish"]').hide();
 	}
 
-
-
-
-
-
-	// this.items_data_to_publish = {};
-	// this.unpublished_items = [];
-	// this.fetched_items = [];
-	// this.fetched_items_dict = {};
-
-
-	show_message(message) {
-		this.$wrapper.prepend(NotificationMessage(message));
-	}
-
-	refresh() {
-		if(!hub.settings.sync_in_progress) {
-			this.make_publish_ready_state();
-		} else {
-			this.make_publish_in_progress_state();
-		}
-	}
-
-	make_publish_ready_state() {
-		this.$wrapper.empty();
-		this.$wrapper.append(get_publishing_header());
-
-		make_search_bar({
-			wrapper: this.$wrapper,
-			on_search: keyword => {
-				this.search_value = keyword;
-				this.get_items_and_render();
-			},
-			placeholder: __('Search Items')
-		});
-
-		this.setup_publishing_events();
-		this.show_last_sync_message();
-		this.get_items_and_render();
-	}
-
-	// show_last_sync_message() {
-	// 	if(hub.settings.last_sync_datetime) {
-	// 		this.show_message(`Last sync was <a href="#marketplace/profile">${comment_when(hub.settings.last_sync_datetime)}</a>.
-	// 			<a href="#marketplace/my-products">See your Published Products</a>.`);
-	// 	}
-	// }
-
-	setup_publishing_events() {
-		this.$wrapper.find('.publish-items').on('click', () => {
-			this.publish_selected_items()
-				.then(this.refresh.bind(this))
-		});
-
-		this.selected_items_container = this.$wrapper.find('.selected-items');
-
-		this.$current_selected_card = null;
-
-		this.make_publishing_dialog();
-
-		this.$wrapper.on('click', '[data-route]', (e) => {
-			e.stopPropagation();
-			const $target = $(e.currentTarget);
-			const route = $target.data().route;
-			frappe.set_route(route);
-		});
-
-		this.$wrapper.on('click', '.hub-card', (e) => {
-			const $target = $(e.currentTarget);
-			const item_code = $target.attr('data-id');
-			this.show_publishing_dialog_for_item(item_code);
-			this.$current_selected_card = $target.parent();
-		});
-	}
-
-	make_publishing_dialog() {
-		this.item_publish_dialog = ItemPublishDialog(
-			{
-				fn: (values) => {
-					this.add_item_to_publish(values);
-					this.item_publish_dialog.hide();
-				}
-			},
-			{
-				fn: () => {
-					const values = this.item_publish_dialog.get_values(true);
-					this.update_items_data_to_publish(values);
-				}
-			}
-		);
-	}
-
-	add_item_to_publish(values) {
-		this.update_items_data_to_publish(values);
-		this.select_current_card()
-	}
-
-	update_items_data_to_publish(values) {
-		// Add item additional data
-		this.items_data_to_publish[values.item_code] = values;
-	}
-
-	select_current_card() {
-		this.$current_selected_card.appendTo(this.selected_items_container);
-		this.$current_selected_card.find('.hub-card').toggleClass('active');
-
-		this.update_selected_items_count();
-	}
-
-	show_publishing_dialog_for_item(item_code) {
-		let item_data = this.items_data_to_publish[item_code];
-
-		if(!item_data) { item_data = { item_code }; };
-
-		this.item_publish_dialog.clear();
-
-		const item_doc = this.fetched_items_dict[item_code];
-		if(item_doc) {
-			this.item_publish_dialog.fields_dict.image_list.set_data(
-				item_doc.attachments.map(attachment => attachment.file_url)
-			);
-		}
-
-		this.item_publish_dialog.set_values(item_data);
-		this.item_publish_dialog.show();
-	}
-
-	update_selected_items_count() {
-		const total_items = this.$wrapper.find('.hub-card.active').length;
-
-		const is_empty = total_items === 0;
-
-		let button_label;
-		if (total_items > 0) {
-			const more_than_one = total_items > 1;
-			button_label = __('Publish {0} item{1}', [total_items, more_than_one ? 's' : '']);
-		} else {
-			button_label = __('Publish');
-		}
-
-		this.$wrapper.find('.publish-items')
-			.text(button_label)
-			.prop('disabled', is_empty);
-
-		this.$wrapper.find('.publish-area').toggleClass('empty', is_empty);
-		this.$wrapper.find('.publish-area').toggleClass('filled', !is_empty);
-	}
-
-	make_publish_in_progress_state() {
-		this.$wrapper.empty();
-
-		this.$wrapper.append(this.show_publish_progress());
-
-		const subtitle_html = `<p class="text-muted">
-			${__(`Only products with an image, description and category can be published.
-			Please update them if an item in your inventory does not appear.`)}
-		</p>`;
-
-		this.$wrapper.append(subtitle_html);
-
-		// Show search list with only description, and don't set any events
-		make_search_bar({
-			wrapper: this.$wrapper,
-			on_search: keyword => {
-				this.search_value = keyword;
-				this.get_items_and_render();
-			},
-			placeholder: __('Search Items')
-		});
-
-		this.get_items_and_render();
-	}
-
-	show_publish_progress() {
-		const items_to_publish = this.items_data_to_publish.length
-			? this.items_data_to_publish
-			: JSON.parse(hub.settings.custom_data || '[]');
-
-		const $publish_progress = $(`<div class="sync-progress">
-			<p><b>${__(`Syncing ${items_to_publish.length} Products`)}</b></p>
-			<div class="progress">
-				<div class="progress-bar" style="width: 1%"></div>
-			</div>
-
-		</div>`);
-
-		const items_to_publish_container = $(get_item_card_container_html(
-			items_to_publish, '', get_local_item_card_html));
-
-		items_to_publish_container.find('.hub-card').addClass('active');
-
-		$publish_progress.append(items_to_publish_container);
-
-		return $publish_progress;
-	}
-
-	get_items_and_render(wrapper = this.$wrapper) {
-		wrapper.find('.results').remove();
-		const items = this.get_valid_items();
-
-		if(!items.then) {
-			this.render(items, wrapper);
-		} else {
-			items.then(r => {
-				this.fetched_items = r.message;
-				this.render(r.message, wrapper);
-			});
-		}
-	}
-
-	render(items, wrapper) {
-		const items_container = $(get_item_card_container_html(items, '', get_local_item_card_html));
-		items_container.addClass('results');
-		wrapper.append(items_container);
-
-		items.map(item => {
-			this.fetched_items_dict[item.item_code] = item;
-		});
-
-		// remove the items which doesn't have a valid image
-		setTimeout(() => {
-			items_container.find('.no-image').each(function() {
-				$(this).closest('.hub-card-container').remove();
-			});
-		}, 1000);
-	}
-
-	get_valid_items() {
-		if(this.unpublished_items.length) {
-			return this.unpublished_items;
-		}
-		return frappe.call(
-			'erpnext.hub_node.api.get_valid_items',
-			{
-				search_value: this.search_value
-			}
-		);
-	}
-
-	publish_selected_items() {
-		const item_codes_to_publish = [];
-		this.$wrapper.find('.hub-card.active').map(function () {
-			item_codes_to_publish.push($(this).attr("data-id"));
-		});
-
-		// Retrieve item data
-		const items_data_to_publish = item_codes_to_publish.map(item_code => this.items_data_to_publish[item_code])
-
-		return frappe.call(
-			'erpnext.hub_node.api.publish_selected_items',
-			{
-				items_to_publish: items_data_to_publish
-			}
-		)
-	}
 }
diff --git a/erpnext/public/less/hub.less b/erpnext/public/less/hub.less
index 5fac085..bcf57f4 100644
--- a/erpnext/public/less/hub.less
+++ b/erpnext/public/less/hub.less
@@ -25,16 +25,6 @@
 		font-size: @text-medium;
 	}
 
-	// .btn-primary {
-	// 	background-color: #89da28;
-	// 	border-color: #61ca23;
-	// }
-
-	// .btn-primary:hover {
-	// 	background-color: #61ca23;
-	// 	border-color: #59b81c;
-	// }
-
 	.progress-bar {
 		background-color: #89da28;
 	}