diff --git a/.tests/tests/PositionTest.php b/.tests/tests/PositionTest.php index 3d04d4d..f13c577 100644 --- a/.tests/tests/PositionTest.php +++ b/.tests/tests/PositionTest.php @@ -1,5 +1,4 @@ assertEquals(2, $this->getConnection()->getRowCount('tracks'), "Wrong row count"); $this->assertEquals(4, $this->getConnection()->getRowCount('positions'), "Wrong row count"); $posArr = uPosition::getLastAllUsers(); - $this->assertEquals(2, count($posArr), "Wrong row count"); + $this->assertCount(2, $posArr, "Wrong row count"); foreach ($posArr as $position) { /** @var uPosition $position */ switch ($position->id) { @@ -152,15 +151,15 @@ class PositionTest extends UloggerDatabaseTestCase { $this->assertEquals(3, $this->getConnection()->getRowCount('positions'), "Wrong row count"); $posArr = uPosition::getAll(); - $this->assertEquals(3, count($posArr), "Wrong row count"); + $this->assertCount(3, $posArr, "Wrong row count"); $posArr = uPosition::getAll($userId); - $this->assertEquals(2, count($posArr), "Wrong row count"); + $this->assertCount(2, $posArr, "Wrong row count"); $posArr = uPosition::getAll($userId, $trackId); - $this->assertEquals(1, count($posArr), "Wrong row count"); + $this->assertCount(1, $posArr, "Wrong row count"); $posArr = uPosition::getAll(NULL, $trackId); - $this->assertEquals(1, count($posArr), "Wrong row count"); + $this->assertCount(1, $posArr, "Wrong row count"); $posArr = uPosition::getAll($userId3); - $this->assertEquals(0, count($posArr), "Wrong row count"); + $this->assertCount(0, $posArr, "Wrong row count"); } public function testDistanceTo() { @@ -169,7 +168,7 @@ class PositionTest extends UloggerDatabaseTestCase { $pos1 = $this->addTestPosition($userId, $trackId, $this->testTimestamp, 0, 0); $pos2 = $this->addTestPosition($userId, $trackId, $this->testTimestamp, 0, 1); $posArr = uPosition::getAll(); - $this->assertEquals(2, count($posArr), "Wrong row count"); + $this->assertCount(2, $posArr, "Wrong row count"); $this->assertEquals(111195, round($posArr[0]->distanceTo($posArr[1])), "Wrong distance"); } @@ -179,7 +178,7 @@ class PositionTest extends UloggerDatabaseTestCase { $pos1 = $this->addTestPosition($userId, $trackId, $this->testTimestamp); $pos2 = $this->addTestPosition($userId, $trackId, $this->testTimestamp + 1); $posArr = uPosition::getAll(); - $this->assertEquals(2, count($posArr), "Wrong row count"); + $this->assertCount(2, $posArr, "Wrong row count"); $this->assertEquals(-1, $posArr[0]->secondsTo($posArr[1]), "Wrong time difference"); } diff --git a/css/src/main.css b/css/src/main.css index 4b5fa66..045b28d 100644 --- a/css/src/main.css +++ b/css/src/main.css @@ -507,6 +507,10 @@ button > * { margin: 0 5px; } +.hidden { + display: none; +} + /* alert */ .alert { diff --git a/helpers/position.php b/helpers/position.php index e969253..96764ec 100644 --- a/helpers/position.php +++ b/helpers/position.php @@ -194,9 +194,7 @@ require_once(ROOT_DIR . "/helpers/upload.php"); $query = "DELETE FROM " . self::db()->table('positions') . " WHERE id = ?"; $stmt = self::db()->prepare($query); $stmt->execute([ $this->id ]); - if ($this->hasImage()) { - uUpload::delete($this->image); - } + $this->removeImage(); $ret = true; $this->id = NULL; $this->isValid = false; @@ -373,15 +371,9 @@ require_once(ROOT_DIR . "/helpers/upload.php"); */ public static function removeImages($userId, $trackId = NULL) { if (($positions = self::getAllWithImage($userId, $trackId)) !== false) { - /** @var uUpload $position */ foreach ($positions as $position) { try { - $query = "UPDATE " . self::db()->table('positions') . " - SET image = NULL WHERE id = ?"; - $stmt = self::db()->prepare($query); - $stmt->execute([ $position->id ]); - // ignore unlink errors - uUpload::delete($position->image); + $position->removeImage(); } catch (PDOException $e) { // TODO: handle exception syslog(LOG_ERR, $e->getMessage()); @@ -392,6 +384,38 @@ require_once(ROOT_DIR . "/helpers/upload.php"); return true; } + /** + * Add uploaded image + * @param array $imageMeta File metadata array + */ + public function setImage($imageMeta) { + if (!empty($imageMeta)) { + if ($this->hasImage()) { + $this->removeImage(); + } + $this->image = uUpload::add($imageMeta, $this->trackId); + $query = "UPDATE " . self::db()->table('positions') . " + SET image = ? WHERE id = ?"; + $stmt = self::db()->prepare($query); + $stmt->execute([ $this->image, $this->id ]); + } + } + + /** + * Delete image + */ + public function removeImage() { + if ($this->hasImage()) { + $query = "UPDATE " . self::db()->table('positions') . " + SET image = NULL WHERE id = ?"; + $stmt = self::db()->prepare($query); + $stmt->execute([ $this->id ]); + // ignore unlink errors + uUpload::delete($this->image); + $this->image = null; + } + } + /** * Calculate distance to target point using haversine formula * diff --git a/js/src/mapviewmodel.js b/js/src/mapviewmodel.js index 0d7ddbd..fe33509 100644 --- a/js/src/mapviewmodel.js +++ b/js/src/mapviewmodel.js @@ -210,7 +210,7 @@ export default class MapViewModel extends ViewModel { if (pos.hasImage()) { const image = node.querySelector('#pimage img'); image.onclick = () => { - const modal = new uDialog(`image`); + const modal = new uDialog(`image`); const closeEl = modal.element.querySelector('#modal-close'); closeEl.onclick = () => modal.destroy(); modal.element.classList.add('image'); diff --git a/js/src/position.js b/js/src/position.js index fc1e7bf..68e3ddd 100644 --- a/js/src/position.js +++ b/js/src/position.js @@ -85,6 +85,17 @@ export default class uPosition { return (this.image != null && this.image.length > 0); } + /** + * @return {?string} + */ + getImagePath() { + return this.hasImage() ? `uploads/${this.image}` : null; + } + + /** + * Get total speed in m/s + * @return {number} + */ get totalSpeed() { return this.totalSeconds ? this.totalMeters / this.totalSeconds : 0; } @@ -110,10 +121,37 @@ export default class uPosition { }); } + /** + * @return {Promise} + */ + imageDelete() { + return uPosition.update({ + action: 'imagedel', + posid: this.id + }).then(() => { this.image = null; }); + } + + /** + * @param {File} imageFile + * @return {Promise} + */ + imageAdd(imageFile) { + const data = new FormData(); + data.append('image', imageFile); + data.append('action', 'imageadd'); + data.append('posid', this.id); + return uPosition.update(data).then( + /** + * @param {Object} result + * @param {string} result.image + */ + (result) => { this.image = result.image; }); + } + /** * Save track data * @param {Object} data - * @return {Promise} + * @return {Promise} */ static update(data) { return uAjax.post('utils/handleposition.php', data); diff --git a/js/src/positiondialogmodel.js b/js/src/positiondialogmodel.js index 72d26d0..7e78389 100644 --- a/js/src/positiondialogmodel.js +++ b/js/src/positiondialogmodel.js @@ -17,13 +17,15 @@ * along with this program; if not, see . */ -import { lang as $ } from './initializer.js'; +import { lang as $, config } from './initializer.js'; import ViewModel from './viewmodel.js'; import uAlert from './alert.js'; import uDialog from './dialog.js'; import uObserve from './observe.js'; import uUtils from './utils.js'; +const hiddenClass = 'hidden'; + /** * @class PositionDialogModel */ @@ -38,14 +40,22 @@ export default class PositionDialogModel extends ViewModel { onPositionDelete: null, onPositionUpdate: null, onCancel: null, - comment: '' + comment: null, + image: null, + onImageDelete: null }); this.state = state; this.positionIndex = positionIndex; this.position = this.state.currentTrack.positions[positionIndex]; + this.model.comment = this.position.hasComment() ? this.position.comment : ''; + this.model.image = this.position.image; this.model.onPositionDelete = () => this.onPositionDelete(); this.model.onPositionUpdate = () => this.onPositionUpdate(); this.model.onCancel = () => this.onCancel(); + this.model.onImageDelete = () => this.onImageDelete(); + this.onChanged('image', (image) => { + if (image && image !== this.position.image) { this.readImage(); } + }); } init() { @@ -53,6 +63,61 @@ export default class PositionDialogModel extends ViewModel { this.dialog = new uDialog(html); this.dialog.show(); this.bindAll(this.dialog.element); + this.previewEl = this.getBoundElement('imagePreview'); + this.fileEl = this.getBoundElement('image'); + this.imageDeleteEl = this.getBoundElement('onImageDelete'); + this.initReader(); + } + + initReader() { + this.reader = new FileReader(); + this.reader.addEventListener('load', () => { + this.showThumbnail(); + }, false); + this.reader.addEventListener('error', () => { + this.model.image = this.position.image; + }, false); + } + + readImage() { + const file = this.fileEl.files[0]; + if (file) { + if (file.size > config.uploadMaxSize) { + uAlert.error($._('isizefailure', config.uploadMaxSize)); + this.model.image = this.position.image; + return; + } + this.reader.readAsDataURL(file); + } + } + + showThumbnail() { + this.previewEl.onload = () => this.toggleImage(); + this.previewEl.onerror = () => { + uAlert.error($._('iuploadfailure')); + this.model.image = this.position.image; + }; + this.previewEl.src = this.reader.result; + } + + /** + * Toggle image visibility + */ + toggleImage() { + if (this.previewEl.classList.contains(hiddenClass)) { + this.previewEl.classList.remove(hiddenClass); + this.imageDeleteEl.classList.remove(hiddenClass); + this.fileEl.classList.add(hiddenClass); + } else { + this.previewEl.classList.add(hiddenClass); + this.imageDeleteEl.classList.add(hiddenClass); + this.fileEl.classList.remove(hiddenClass); + } + } + + onImageDelete() { + this.model.image = null; + this.toggleImage(); } /** @@ -64,7 +129,13 @@ export default class PositionDialogModel extends ViewModel {

- + +

+
+ + +
@@ -86,10 +157,27 @@ export default class PositionDialogModel extends ViewModel { } } + /** + * @return {Promise} + */ + updateImage() { + let promise = Promise.resolve(); + if (this.model.image !== this.position.image) { + if (this.model.image === null) { + promise = this.position.imageDelete(); + } else { + promise = this.position.imageAdd(this.fileEl.files[0]); + } + } + return promise; + } + onPositionUpdate() { + this.model.comment.trim(); if (this.validate()) { this.position.comment = this.model.comment; - this.position.save() + this.updateImage() + .then(() => this.position.save()) .then(() => { uObserve.forceUpdate(this.state, 'currentTrack'); this.dialog.destroy() @@ -107,6 +195,7 @@ export default class PositionDialogModel extends ViewModel { * @return {boolean} True if valid */ validate() { - return this.model.comment !== this.position.comment; + return !(this.model.comment === this.position.comment && this.model.image === this.position.image); + } } diff --git a/js/test/position.test.js b/js/test/position.test.js index 77ca6e4..02dc174 100644 --- a/js/test/position.test.js +++ b/js/test/position.test.js @@ -241,6 +241,43 @@ describe('Position tests', () => { expect(uPosition.update).toHaveBeenCalledWith({ action: 'update', posid: posId, comment: comment }); }); + it('should delete image on server', (done) => { + // given + spyOn(uPosition, 'update').and.returnValue(Promise.resolve()); + const position = uPosition.fromJson(jsonPosition); + // when + position.imageDelete() + // then + setTimeout(() => { + expect(uPosition.update).toHaveBeenCalledWith({ action: 'imagedel', posid: posId }); + expect(position.image).toBeNull(); + done(); + }, 100); + }); + + it('should add image on server', (done) => { + // given + const newImage = 'new_image.jpg'; + const imageFile = 'imageFile'; + spyOn(uPosition, 'update').and.returnValue(Promise.resolve({ image: newImage })); + const position = uPosition.fromJson(jsonPosition); + // when + position.imageAdd(imageFile); + // then + setTimeout(() => { + expect(uPosition.update).toHaveBeenCalledWith(jasmine.any(FormData)); + + /** @var {FormData} */ + const data = uPosition.update.calls.mostRecent().args[0]; + + expect(data.get('image')).toBe(imageFile); + expect(data.get('action')).toBe('imageadd'); + expect(data.get('posid')).toBe(posId.toString()); + expect(position.image).toBe(newImage); + done(); + }, 100); + }); + it('should call ajax post with url and params', () => { // given const url = 'utils/handleposition.php'; diff --git a/lang/en.php b/lang/en.php index 5a2d67e..23fd653 100644 --- a/lang/en.php +++ b/lang/en.php @@ -116,7 +116,9 @@ $lang["edittrack"] = "Edit track"; $lang["positiondelwarn"] = "Warning!\n\nYou are going to permanently delete position %d of track %s.\n\nAre you sure?"; // substitutes position index and track name $lang["editingposition"] = "You are editing position #%d of track %s"; // substitutes position index and track name $lang["delposition"] = "Remove position"; +$lang["delimage"] = "Remove image"; $lang["comment"] = "Comment"; +$lang["image"] = "Image"; $lang["editposition"] = "Edit position"; $lang["passlenmin"] = "Password must be at least %d characters"; // substitutes password minimum length $lang["passrules_1"] = "It should contain at least one lower case letter, one upper case letter"; diff --git a/utils/handleposition.php b/utils/handleposition.php index 69dd43e..01c4f41 100644 --- a/utils/handleposition.php +++ b/utils/handleposition.php @@ -25,9 +25,9 @@ require_once(ROOT_DIR . "/helpers/config.php"); $auth = new uAuth(); -$action = uUtils::postString('action'); -$positionId = uUtils::postInt('posid'); -$comment = uUtils::postString('comment'); +$action = uUtils::postString("action"); +$positionId = uUtils::postInt("posid"); +$comment = uUtils::postString("comment"); $config = uConfig::getInstance(); $lang = (new uLang($config))->getStrings(); @@ -41,26 +41,52 @@ if (!$position->isValid || uUtils::exitWithError($lang["servererror"]); } +$data = null; + switch ($action) { - case 'update': + case "update": $position->comment = $comment; if ($position->update() === false) { uUtils::exitWithError($lang["servererror"]); } break; - case 'delete': + case "delete": if ($position->delete() === false) { uUtils::exitWithError($lang["servererror"]); } break; + case "imageadd": + try { + $fileMeta = uUtils::requireFile("image"); + if ($position->setImage($fileMeta) === false) { + uUtils::exitWithError($lang["servererror"]); + } + $data = [ "image" => $position->image ]; + } catch (ErrorException $ee) { + $message = $lang["servererror"]; + $message .= ": {$ee->getMessage()}"; + uUtils::exitWithError($message); + } catch (Exception $e) { + $message = $lang["iuploadfailure"]; + $message .= ": {$e->getMessage()}"; + uUtils::exitWithError($message); + } + break; + + case "imagedel": + if ($position->removeImage() === false) { + uUtils::exitWithError($lang["servererror"]); + } + break; + default: uUtils::exitWithError($lang["servererror"]); break; } -uUtils::exitWithSuccess(); +uUtils::exitWithSuccess($data); ?> \ No newline at end of file