aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorrr-2016-10-22 10:03:38 +0200
committerrr-2016-10-22 14:05:56 +0200
commitf1445b9c24b286a9f74a2fead655ab5c169bc74b (patch)
treeeba026eb8dcd53f8d4db58e9dc50af1b857d92ed
parent8c0fa7f49ea24606e1887b76e89c2bfd155b1776 (diff)
client/posts: add post merging
-rw-r--r--client/css/post-detail-view.styl33
-rw-r--r--client/css/post-main-view.styl (renamed from client/css/post-view.styl)0
-rw-r--r--client/html/post_detail.tpl12
-rw-r--r--client/html/post_edit_sidebar.tpl5
-rw-r--r--client/html/post_main.tpl (renamed from client/html/post.tpl)0
-rw-r--r--client/html/post_merge.tpl23
-rw-r--r--client/html/post_merge_side.tpl48
-rw-r--r--client/js/controllers/base_post_controller.js20
-rw-r--r--client/js/controllers/post_detail_controller.js86
-rw-r--r--client/js/controllers/post_main_controller.js (renamed from client/js/controllers/post_controller.js)37
-rw-r--r--client/js/controls/post_edit_sidebar_control.js19
-rw-r--r--client/js/main.js3
-rw-r--r--client/js/models/post.js25
-rw-r--r--client/js/views/post_detail_view.js80
-rw-r--r--client/js/views/post_main_view.js (renamed from client/js/views/post_view.js)6
-rw-r--r--client/js/views/post_merge_view.js129
16 files changed, 502 insertions, 24 deletions
diff --git a/client/css/post-detail-view.styl b/client/css/post-detail-view.styl
new file mode 100644
index 0000000..32e7497
--- /dev/null
+++ b/client/css/post-detail-view.styl
@@ -0,0 +1,33 @@
+#post
+ width: 100%
+ max-width: 40em
+ h1
+ margin-top: 0
+ form
+ width: 100%
+ .buttons i
+ margin-right: 0.5em
+ .post-merge
+ .left-post-container
+ width: 47%
+ float: left
+ .right-post-container
+ width: 47%
+ float: right
+ input[type=text]
+ width: 8em
+ margin-top: -2px
+ .post-mirror
+ margin-bottom: 1em
+ &:after
+ display: block
+ height: 1px
+ content: ' '
+ clear: both
+ .post-thumbnail .thumbnail
+ width: 100%
+ height: 9em
+ .target-post .thumbnail
+ margin-right: 0.35em
+ .target-post, .target-post-content
+ margin: 1em 0
diff --git a/client/css/post-view.styl b/client/css/post-main-view.styl
index 689ec73..689ec73 100644
--- a/client/css/post-view.styl
+++ b/client/css/post-main-view.styl
diff --git a/client/html/post_detail.tpl b/client/html/post_detail.tpl
new file mode 100644
index 0000000..5e04ab2
--- /dev/null
+++ b/client/html/post_detail.tpl
@@ -0,0 +1,12 @@
+<div class='content-wrapper' id='post'>
+ <h1>Post #<%- ctx.post.id %></h1>
+ <nav class='buttons'><!--
+ --><ul><!--
+ --><li><a href='/post/<%- ctx.post.id %>'><i class='fa fa-reply'></i> Main view</a></li><!--
+ --><% if (ctx.canMerge) { %><!--
+ --><li data-name='merge'><a href='/post/<%- ctx.post.id %>/merge'>Merge with&hellip;</a></li><!--
+ --><% } %><!--
+ --></ul><!--
+ --></nav>
+ <div class='post-content-holder'></div>
+</div>
diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl
index 9b08891..d36a0e4 100644
--- a/client/html/post_edit_sidebar.tpl
+++ b/client/html/post_edit_sidebar.tpl
@@ -84,12 +84,15 @@
</section>
<% } %>
- <% if (ctx.canFeaturePosts || ctx.canDeletePosts) { %>
+ <% if (ctx.canFeaturePosts || ctx.canDeletePosts || ctx.canMergePosts) { %>
<section class='management'>
<ul>
<% if (ctx.canFeaturePosts) { %>
<li><a href class='feature'>Feature this post on main page</a></li>
<% } %>
+ <% if (ctx.canMergePosts) { %>
+ <li><a href class='merge'>Merge this post with another</a></li>
+ <% } %>
<% if (ctx.canDeletePosts) { %>
<li><a href class='delete'>Delete this post</a></li>
<% } %>
diff --git a/client/html/post.tpl b/client/html/post_main.tpl
index 07ade0b..07ade0b 100644
--- a/client/html/post.tpl
+++ b/client/html/post_main.tpl
diff --git a/client/html/post_merge.tpl b/client/html/post_merge.tpl
new file mode 100644
index 0000000..59b3f49
--- /dev/null
+++ b/client/html/post_merge.tpl
@@ -0,0 +1,23 @@
+<div class='post-merge'>
+ <form>
+ <ul>
+ <li class='post-mirror'>
+ <div class='left-post-container'></div>
+ <div class='right-post-container'></div>
+ </li>
+
+ <li>
+ <p>Tags, relations, scores, favorites and comments will be
+ merged. All other properties need to be handled manually.</p>
+
+ <%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge these posts.'}) %>
+ </li>
+ </ul>
+
+ <div class='messages'></div>
+
+ <div class='buttons'>
+ <input type='submit' value='Merge posts'/>
+ </div>
+ </form>
+</div>
diff --git a/client/html/post_merge_side.tpl b/client/html/post_merge_side.tpl
new file mode 100644
index 0000000..60cc080
--- /dev/null
+++ b/client/html/post_merge_side.tpl
@@ -0,0 +1,48 @@
+<% if (ctx.editable) { %>
+ <p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>'/></p>
+<% } else { %>
+ <p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>' readonly/></p>
+<% } %>
+
+<% if (ctx.post) { %>
+ <div class='post-thumbnail'>
+ <a rel='external' href='<%- ctx.post.contentUrl %>'>
+ <%= ctx.makeThumbnail(ctx.post.thumbnailUrl) %>
+ </a>
+ </div>
+ <div class='target-post'>
+ <%= ctx.makeRadio({
+ required: true,
+ text: 'Merge to this post<br/><small>' +
+ ctx.makeUserLink(ctx.post.user) +
+ ', ' +
+ ctx.makeRelativeTime(ctx.post.creationTime) +
+ '</small>',
+ name: 'target-post',
+ value: ctx.name,
+ }) %>
+ </div>
+ <div class='target-post-content'>
+ <%= ctx.makeRadio({
+ required: true,
+ text: 'Use this file<br/><small>' +
+ ctx.makeFileSize(ctx.post.fileSize) + ' ' +
+ {
+ 'image/gif': 'GIF',
+ 'image/jpeg': 'JPEG',
+ 'image/png': 'PNG',
+ 'video/webm': 'WEBM',
+ 'application/x-shockwave-flash': 'SWF',
+ }[ctx.post.mimeType] +
+ ' (' +
+ (ctx.post.canvasWidth ?
+ `${ctx.post.canvasWidth}x${ctx.post.canvasHeight}` :
+ '?') +
+ ')</small>',
+ name: 'target-post-content',
+ value: ctx.name,
+ }) %>
+ <p>
+ </p>
+ </div>
+<% } %>
diff --git a/client/js/controllers/base_post_controller.js b/client/js/controllers/base_post_controller.js
new file mode 100644
index 0000000..b86aea7
--- /dev/null
+++ b/client/js/controllers/base_post_controller.js
@@ -0,0 +1,20 @@
+'use strict';
+
+const api = require('../api.js');
+const topNavigation = require('../models/top_navigation.js');
+const EmptyView = require('../views/empty_view.js');
+
+class BasePostController {
+ constructor(ctx) {
+ if (!api.hasPrivilege('posts:view')) {
+ this._view = new EmptyView();
+ this._view.showError('You don\'t have privileges to view posts.');
+ return;
+ }
+
+ topNavigation.activate('posts');
+ topNavigation.setTitle('Post #' + ctx.parameters.id.toString());
+ }
+}
+
+module.exports = BasePostController;
diff --git a/client/js/controllers/post_detail_controller.js b/client/js/controllers/post_detail_controller.js
new file mode 100644
index 0000000..cdff681
--- /dev/null
+++ b/client/js/controllers/post_detail_controller.js
@@ -0,0 +1,86 @@
+'use strict';
+
+const router = require('../router.js');
+const api = require('../api.js');
+const misc = require('../util/misc.js');
+const settings = require('../models/settings.js');
+const Comment = require('../models/comment.js');
+const Post = require('../models/post.js');
+const PostList = require('../models/post_list.js');
+const PostDetailView = require('../views/post_detail_view.js');
+const BasePostController = require('./base_post_controller.js');
+const EmptyView = require('../views/empty_view.js');
+
+class PostDetailController extends BasePostController {
+ constructor(ctx, section) {
+ super(ctx);
+
+ Post.get(ctx.parameters.id).then(post => {
+ this._id = ctx.parameters.id;
+ post.addEventListener('change', e => this._evtSaved(e, section));
+
+ this._view = new PostDetailView({
+ post: post,
+ section: section,
+ canMerge: api.hasPrivilege('posts:merge'),
+ });
+
+ this._view.addEventListener('select', e => this._evtSelect(e));
+ this._view.addEventListener('merge', e => this._evtMerge(e));
+ }, errorMessage => {
+ this._view = new EmptyView();
+ this._view.showError(errorMessage);
+ });
+ }
+
+ showSuccess(message) {
+ this._view.showSuccess(message);
+ }
+
+ _evtSelect(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ Post.get(e.detail.postId).then(post => {
+ this._view.selectPost(post);
+ this._view.enableForm();
+ }, errorMessage => {
+ this._view.showError(errorMessage);
+ this._view.enableForm();
+ });
+ }
+
+ _evtSaved(e, section) {
+ misc.disableExitConfirmation();
+ if (this._id !== e.detail.post.id) {
+ router.replace(
+ '/post/' + e.detail.post.id + '/' + section, null, false);
+ }
+ }
+
+ _evtMerge(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent)
+ .then(() => {
+ this._view = new PostDetailView({
+ post: e.detail.targetPost,
+ section: 'merge',
+ canMerge: api.hasPrivilege('posts:merge'),
+ });
+ this._view.showSuccess('Post merged.');
+ router.replace(
+ '/post/' + e.detail.targetPost.id + '/merge', null, false);
+ }, errorMessage => {
+ this._view.showError(errorMessage);
+ this._view.enableForm();
+ });
+ }
+}
+
+module.exports = router => {
+ router.enter(
+ '/post/:id/merge',
+ (ctx, next) => {
+ ctx.controller = new PostDetailController(ctx, 'merge');
+ });
+};
diff --git a/client/js/controllers/post_controller.js b/client/js/controllers/post_main_controller.js
index 384643a..561ecb4 100644
--- a/client/js/controllers/post_controller.js
+++ b/client/js/controllers/post_main_controller.js
@@ -7,26 +7,19 @@ const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
-const topNavigation = require('../models/top_navigation.js');
-const PostView = require('../views/post_view.js');
+const PostMainView = require('../views/post_main_view.js');
+const BasePostController = require('./base_post_controller.js');
const EmptyView = require('../views/empty_view.js');
-class PostController {
- constructor(id, editMode, ctx) {
- if (!api.hasPrivilege('posts:view')) {
- this._view = new EmptyView();
- this._view.showError('You don\'t have privileges to view posts.');
- return;
- }
-
- topNavigation.activate('posts');
- topNavigation.setTitle('Post #' + id.toString());
+class PostMainController extends BasePostController {
+ constructor(ctx, editMode) {
+ super(ctx);
let parameters = ctx.parameters;
Promise.all([
- Post.get(id),
+ Post.get(ctx.parameters.id),
PostList.getAround(
- id, this._decorateSearchQuery(
+ ctx.parameters.id, this._decorateSearchQuery(
parameters ? parameters.query : '')),
]).then(responses => {
const [post, aroundResponse] = responses;
@@ -36,13 +29,13 @@ class PostController {
if (parameters.query) {
ctx.state.parameters = parameters;
const url = editMode ?
- '/post/' + id + '/edit' :
- '/post/' + id;
+ '/post/' + ctx.parameters.id + '/edit' :
+ '/post/' + ctx.parameters.id;
router.replace(url, ctx.state, false);
}
this._post = post;
- this._view = new PostView({
+ this._view = new PostMainView({
post: post,
editMode: editMode,
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null,
@@ -72,6 +65,8 @@ class PostController {
'feature', e => this._evtFeaturePost(e));
this._view.sidebarControl.addEventListener(
'delete', e => this._evtDeletePost(e));
+ this._view.sidebarControl.addEventListener(
+ 'merge', e => this._evtMergePost(e));
}
if (this._view.commentFormControl) {
@@ -128,6 +123,10 @@ class PostController {
});
}
+ _evtMergePost(e) {
+ router.show('/post/' + e.detail.post.id + '/merge');
+ }
+
_evtDeletePost(e) {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
@@ -262,7 +261,7 @@ module.exports = router => {
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
- ctx.controller = new PostController(ctx.parameters.id, true, ctx);
+ ctx.controller = new PostMainController(ctx, true);
});
router.enter(
'/post/:id/:parameters(.*)?',
@@ -272,6 +271,6 @@ module.exports = router => {
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
- ctx.controller = new PostController(ctx.parameters.id, false, ctx);
+ ctx.controller = new PostMainController(ctx, false);
});
};
diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js
index 2dc3629..49576a1 100644
--- a/client/js/controls/post_edit_sidebar_control.js
+++ b/client/js/controls/post_edit_sidebar_control.js
@@ -36,6 +36,7 @@ class PostEditSidebarControl extends events.EventTarget {
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
canDeletePosts: api.hasPrivilege('posts:delete'),
canFeaturePosts: api.hasPrivilege('posts:feature'),
+ canMergePosts: api.hasPrivilege('posts:merge'),
}));
new ExpanderControl(
@@ -108,6 +109,11 @@ class PostEditSidebarControl extends events.EventTarget {
'click', e => this._evtFeatureClick(e));
}
+ if (this._mergeLinkNode) {
+ this._mergeLinkNode.addEventListener(
+ 'click', e => this._evtMergeClick(e));
+ }
+
if (this._deleteLinkNode) {
this._deleteLinkNode.addEventListener(
'click', e => this._evtDeleteClick(e));
@@ -186,6 +192,15 @@ class PostEditSidebarControl extends events.EventTarget {
}
}
+ _evtMergeClick(e) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('merge', {
+ detail: {
+ post: this._post,
+ },
+ }));
+ }
+
_evtDeleteClick(e) {
e.preventDefault();
if (confirm('Are you sure you want to delete this post?')) {
@@ -314,6 +329,10 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector('.management .feature');
}
+ get _mergeLinkNode() {
+ return this._formNode.querySelector('.management .merge');
+ }
+
get _deleteLinkNode() {
return this._formNode.querySelector('.management .delete');
}
diff --git a/client/js/main.js b/client/js/main.js
index 25b6469..0f324b6 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -34,7 +34,8 @@ controllers.push(require('./controllers/auth_controller.js'));
controllers.push(require('./controllers/password_reset_controller.js'));
controllers.push(require('./controllers/comments_controller.js'));
controllers.push(require('./controllers/snapshots_controller.js'));
-controllers.push(require('./controllers/post_controller.js'));
+controllers.push(require('./controllers/post_detail_controller.js'));
+controllers.push(require('./controllers/post_main_controller.js'));
controllers.push(require('./controllers/post_list_controller.js'));
controllers.push(require('./controllers/post_upload_controller.js'));
controllers.push(require('./controllers/tag_controller.js'));
diff --git a/client/js/models/post.js b/client/js/models/post.js
index f5f8f00..56a9450 100644
--- a/client/js/models/post.js
+++ b/client/js/models/post.js
@@ -190,6 +190,31 @@ class Post extends events.EventTarget {
});
}
+ merge(targetId, useOldContent) {
+ return api.get('/post/' + encodeURIComponent(targetId))
+ .then(response => {
+ return api.post('/post-merge/', {
+ removeVersion: this._version,
+ remove: this._id,
+ mergeToVersion: response.version,
+ mergeTo: targetId,
+ replaceContent: useOldContent,
+ });
+ }, response => {
+ return Promise.reject(response);
+ }).then(response => {
+ this._updateFromResponse(response);
+ this.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ post: this,
+ },
+ }));
+ return Promise.resolve();
+ }, response => {
+ return Promise.reject(response.description);
+ });
+ }
+
setScore(score) {
return api.put('/post/' + this._id + '/score', {score: score})
.then(response => {
diff --git a/client/js/views/post_detail_view.js b/client/js/views/post_detail_view.js
new file mode 100644
index 0000000..4c19059
--- /dev/null
+++ b/client/js/views/post_detail_view.js
@@ -0,0 +1,80 @@
+'use strict';
+
+const events = require('../events.js');
+const views = require('../util/views.js');
+const PostMergeView = require('./post_merge_view.js');
+const EmptyView = require('../views/empty_view.js');
+
+const template = views.getTemplate('post-detail');
+
+class PostDetailView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._ctx = ctx;
+ ctx.post.addEventListener('change', e => this._evtChange(e));
+ ctx.section = ctx.section || 'summary';
+
+ this._hostNode = document.getElementById('content-holder');
+ this._install();
+ }
+
+ _install() {
+ const ctx = this._ctx;
+ views.replaceContent(this._hostNode, template(ctx));
+
+ for (let item of this._hostNode.querySelectorAll('[data-name]')) {
+ item.classList.toggle(
+ 'active', item.getAttribute('data-name') === ctx.section);
+ }
+
+ ctx.hostNode = this._hostNode.querySelector('.post-content-holder');
+ if (ctx.section === 'merge') {
+ if (!this._ctx.canMerge) {
+ this._view = new EmptyView();
+ this._view.showError(
+ 'You don\'t have privileges to merge posts.');
+ } else {
+ this._view = new PostMergeView(ctx);
+ events.proxyEvent(this._view, this, 'select');
+ events.proxyEvent(this._view, this, 'submit', 'merge');
+ }
+
+ } else {
+ // this._view = new PostSummaryView(ctx);
+ }
+
+ views.syncScrollPosition();
+ }
+
+ clearMessages() {
+ this._view.clearMessages();
+ }
+
+ enableForm() {
+ this._view.enableForm();
+ }
+
+ disableForm() {
+ this._view.disableForm();
+ }
+
+ showSuccess(message) {
+ this._view.showSuccess(message);
+ }
+
+ showError(message) {
+ this._view.showError(message);
+ }
+
+ selectPost(post) {
+ this._view.selectPost(post);
+ }
+
+ _evtChange(e) {
+ this._ctx.post = e.detail.post;
+ this._install(this._ctx);
+ }
+}
+
+module.exports = PostDetailView;
diff --git a/client/js/views/post_view.js b/client/js/views/post_main_view.js
index 256eb2d..446297d 100644
--- a/client/js/views/post_view.js
+++ b/client/js/views/post_main_view.js
@@ -13,9 +13,9 @@ const PostEditSidebarControl =
const CommentListControl = require('../controls/comment_list_control.js');
const CommentFormControl = require('../controls/comment_form_control.js');
-const template = views.getTemplate('post');
+const template = views.getTemplate('post-main');
-class PostView {
+class PostMainView {
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
@@ -118,4 +118,4 @@ class PostView {
}
}
-module.exports = PostView;
+module.exports = PostMainView;
diff --git a/client/js/views/post_merge_view.js b/client/js/views/post_merge_view.js
new file mode 100644
index 0000000..fb17527
--- /dev/null
+++ b/client/js/views/post_merge_view.js
@@ -0,0 +1,129 @@
+'use strict';
+
+const config = require('../config.js');
+const events = require('../events.js');
+const views = require('../util/views.js');
+
+const KEY_RETURN = 13;
+const template = views.getTemplate('post-merge');
+const sideTemplate = views.getTemplate('post-merge-side');
+
+class PostMergeView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._ctx = ctx;
+ this._post = ctx.post;
+ this._hostNode = ctx.hostNode;
+
+ this._leftPost = ctx.post;
+ this._rightPost = null;
+ views.replaceContent(this._hostNode, template(this._ctx));
+ views.decorateValidator(this._formNode);
+
+ this._refreshLeftSide();
+ this._refreshRightSide();
+
+ this._formNode.addEventListener('submit', e => this._evtSubmit(e));
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ selectPost(post) {
+ this._rightPost = post;
+ this._refreshRightSide();
+ }
+
+ _refreshLeftSide() {
+ views.replaceContent(
+ this._leftSideNode,
+ sideTemplate(Object.assign({}, this._ctx, {
+ post: this._leftPost,
+ name: 'left',
+ editable: false})));
+ }
+
+ _refreshRightSide() {
+ views.replaceContent(
+ this._rightSideNode,
+ sideTemplate(Object.assign({}, this._ctx, {
+ post: this._rightPost,
+ name: 'right',
+ editable: true})));
+
+ if (this._targetPostFieldNode) {
+ this._targetPostFieldNode.addEventListener(
+ 'keydown', e => this._evtTargetPostFieldKeyDown(e));
+ }
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+ const checkedTargetPost = this._formNode.querySelector(
+ '.target-post :checked').value;
+ const checkedTargetPostContent = this._formNode.querySelector(
+ '.target-post-content :checked').value;
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ post: checkedTargetPost == 'left' ?
+ this._rightPost :
+ this._leftPost,
+ targetPost: checkedTargetPost == 'left' ?
+ this._leftPost :
+ this._rightPost,
+ useOldContent: checkedTargetPostContent !== checkedTargetPost,
+ },
+ }));
+ }
+
+ _evtTargetPostFieldKeyDown(e) {
+ const key = e.which;
+ if (key !== KEY_RETURN) {
+ return;
+ }
+ e.target.blur();
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('select', {
+ detail: {
+ postId: this._targetPostFieldNode.value,
+ },
+ }));
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('form');
+ }
+
+ get _leftSideNode() {
+ return this._hostNode.querySelector('.left-post-container');
+ }
+
+ get _rightSideNode() {
+ return this._hostNode.querySelector('.right-post-container');
+ }
+
+ get _targetPostFieldNode() {
+ return this._formNode.querySelector(
+ '.post-mirror input:not([readonly])[type=text]');
+ }
+}
+
+module.exports = PostMergeView;

© 2015 - 2026 Jakob L. Kreuze