diff --git a/vipra-backend/src/main/java/de/vipra/rest/model/ResponseWrapper.java b/vipra-backend/src/main/java/de/vipra/rest/model/ResponseWrapper.java index d797207bd16913681fe7e0ed2c0c8420f3062b30..67f155f34631b0c1fe0dfa32b259005855729f68 100644 --- a/vipra-backend/src/main/java/de/vipra/rest/model/ResponseWrapper.java +++ b/vipra-backend/src/main/java/de/vipra/rest/model/ResponseWrapper.java @@ -58,7 +58,9 @@ public class ResponseWrapper<T> { * Status 201 */ public Response created(final T data, final URI loc) { - final ResponseBuilder builder = Response.created(loc).entity(data); + final ResponseBuilder builder = Response.created(loc); + if (data != null) + builder.entity(data); addHeaders(builder); return builder.build(); } diff --git a/vipra-backend/src/main/java/de/vipra/rest/resource/BugReportResource.java b/vipra-backend/src/main/java/de/vipra/rest/resource/BugReportResource.java new file mode 100644 index 0000000000000000000000000000000000000000..ccd6f962d59b6ff1a811cd3baf4efc3aef9b393c --- /dev/null +++ b/vipra-backend/src/main/java/de/vipra/rest/resource/BugReportResource.java @@ -0,0 +1,121 @@ +package de.vipra.rest.resource; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; + +import javax.servlet.ServletContext; +import javax.ws.rs.Consumes; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +import org.bson.types.ObjectId; + +import de.vipra.rest.Messages; +import de.vipra.rest.model.APIError; +import de.vipra.rest.model.ResponseWrapper; +import de.vipra.util.Config; +import de.vipra.util.MongoUtils; +import de.vipra.util.StringUtils; +import de.vipra.util.ex.ConfigException; +import de.vipra.util.ex.DatabaseException; +import de.vipra.util.model.BugReport; +import de.vipra.util.service.MongoService; +import de.vipra.util.service.Service.QueryBuilder; + +@Path("bugreports") +public class BugReportResource { + + @Context + UriInfo uri; + + final MongoService<BugReport, ObjectId> dbBugReports; + + public BugReportResource(@Context final ServletContext servletContext) throws ConfigException, IOException { + final Config config = Config.getConfig(); + dbBugReports = MongoService.getDatabaseService(config, BugReport.class); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getBugReports(@QueryParam("skip") final Integer skip, @QueryParam("limit") final Integer limit, + @QueryParam("sort") @DefaultValue("id") final String sortBy, @QueryParam("fields") final String fields) { + final ResponseWrapper<List<BugReport>> res = new ResponseWrapper<>(); + + if (res.hasErrors()) + return res.badRequest(); + + try { + final QueryBuilder query = QueryBuilder.builder().skip(skip).limit(limit).sortBy(sortBy); + if (fields != null && !fields.isEmpty()) + query.fields(true, StringUtils.getFields(fields)); + + final List<BugReport> bugReports = dbBugReports.getMultiple(query); + + if ((skip != null && skip > 0) || (limit != null && limit > 0)) + res.addHeader("total", dbBugReports.count(null)); + else + res.addHeader("total", bugReports.size()); + + return res.ok(bugReports); + } catch (final Exception e) { + e.printStackTrace(); + res.addError(new APIError(Response.Status.BAD_REQUEST, "Error", e.getMessage())); + return res.badRequest(); + } + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + @Path("{id}") + public Response getBugReport(@PathParam("id") final String id, @QueryParam("fields") final String fields) { + final ResponseWrapper<BugReport> res = new ResponseWrapper<>(); + if (id == null || id.trim().length() == 0) { + res.addError(new APIError(Response.Status.BAD_REQUEST, "ID is empty", String.format(Messages.BAD_REQUEST, "id cannot be empty"))); + return res.badRequest(); + } + + BugReport bugReport; + try { + bugReport = dbBugReports.getSingle(MongoUtils.objectId(id), StringUtils.getFields(fields)); + } catch (final Exception e) { + e.printStackTrace(); + res.addError(new APIError(Response.Status.BAD_REQUEST, "Error", e.getMessage())); + return res.badRequest(); + } + + if (bugReport != null) { + return res.ok(bugReport); + } else { + res.addError(new APIError(Response.Status.NOT_FOUND, "Resource not found", String.format(Messages.NOT_FOUND, "bugreport", id))); + return res.notFound(); + } + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + public Response createBugReport(final BugReport bugReport) throws URISyntaxException { + ResponseWrapper<BugReport> res = new ResponseWrapper<>(); + + try { + dbBugReports.createSingle(bugReport); + } catch (DatabaseException e) { + e.printStackTrace(); + res.addError(new APIError(Response.Status.BAD_REQUEST, "Error", e.getMessage())); + return res.badRequest(); + } + return res.created(null, new URI(uri.getPath().toString() + "/" + bugReport.getId().toString())); + } + +} diff --git a/vipra-backend/src/main/java/de/vipra/rest/resource/WordResource.java b/vipra-backend/src/main/java/de/vipra/rest/resource/WordResource.java index 646c64fde3fa65c2490d606f71e123751f4824fa..a4247aacfb48c6f844c49823bf8cee08c7af7c2c 100644 --- a/vipra-backend/src/main/java/de/vipra/rest/resource/WordResource.java +++ b/vipra-backend/src/main/java/de/vipra/rest/resource/WordResource.java @@ -14,7 +14,6 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriInfo; import de.vipra.rest.Messages; import de.vipra.rest.model.APIError; @@ -30,9 +29,6 @@ import de.vipra.util.service.Service.QueryBuilder; @Path("words") public class WordResource { - @Context - UriInfo uri; - final MongoService<WordFull, String> dbWords; public WordResource(@Context final ServletContext servletContext) throws ConfigException, IOException { diff --git a/vipra-ui/app/html/articles/show.html b/vipra-ui/app/html/articles/show.html index 709ce32340057f697e9ec88c8d9a4a5115955b15..efc9fb98b732053dfedd9a4c88a8e03cec78773a 100644 --- a/vipra-ui/app/html/articles/show.html +++ b/vipra-ui/app/html/articles/show.html @@ -24,7 +24,7 @@ <div class="row"> <div class="col-md-8"> <h3>Info</h3> - <table class="table table-bordered table-condensed table-infos"> + <table class="table table-bordered table-condensed table-fixed"> <tbody> <tr> <th class="infocol">ID</th> @@ -50,12 +50,12 @@ </tbody> </table> <h3>Topics</h3> - <table class="table table-bordered table-condensed" ng-show="article.topics.length"> + <table class="table table-bordered table-condensed table-fixed" ng-show="article.topics.length"> <thead> <tr> <th class="infocol" ng-model="articlesShowModels.topicsSort" sort-by="share">Share</th> <th ng-model="articlesShowModels.topicsSort" sort-by="topic.name">Name</th> - <th style="width:1px"></th> + <th class="colorcol"></th> </tr> </thead> <tbody> diff --git a/vipra-ui/app/html/directives/topic-link.html b/vipra-ui/app/html/directives/topic-link.html index e0821bd97f71be400d115983a988501de1277f60..411e1e14cf765c4deb571ba7f3d487ee0413c434 100644 --- a/vipra-ui/app/html/directives/topic-link.html +++ b/vipra-ui/app/html/directives/topic-link.html @@ -1,8 +1,10 @@ <span> - <topic-menu topic="topic" ng-if="::showMenu" /> - <a class="topic-link" ui-sref="topics.show({id:topic.id})"> - <span ng-bind="topic.name"></span> - <ng-transclude/> - </a> - <span class="badge pull-right" ng-bind="::topic.articlesCount" ng-attr-title="{{::topic.articlesCount}} article(s)" ng-if="::showBadge"></span> + <topic-menu topic="topic" class="menu-button" ng-if="::showMenu" /> + <span class="ellipsis"> + <a class="topic-link" ui-sref="topics.show({id:topic.id})"> + <span ng-bind="topic.name"></span> + <ng-transclude/> + </a> + <span class="badge pull-right" ng-bind="::topic.articlesCount" ng-attr-title="{{::topic.articlesCount}} article(s)" ng-if="::showBadge"></span> + </span> </span> diff --git a/vipra-ui/app/html/directives/word-link.html b/vipra-ui/app/html/directives/word-link.html index 06ae886f9c6011d69385fb4e25e28ed29ac96b50..268cec13ec0ac7f7cb68b1c15e950b3e11410e3e 100644 --- a/vipra-ui/app/html/directives/word-link.html +++ b/vipra-ui/app/html/directives/word-link.html @@ -1,4 +1,6 @@ <span> - <word-menu word="word" ng-if="::showMenu" /> - <a ui-sref="words.show({id: word.id})" ng-bind="word.id"></a> + <word-menu class="menu-button" word="word" ng-if="::showMenu" /> + <span class="ellipsis"> + <a ui-sref="words.show({id: word.id})" ng-bind="word.id"></a> + </span> </span> \ No newline at end of file diff --git a/vipra-ui/app/html/explorer.html b/vipra-ui/app/html/explorer.html index 35409e6cea155f12a588198c017a2e0bc7499fad..727a593a0ab0c5a737cb63ef19a9d24a67ff4145 100644 --- a/vipra-ui/app/html/explorer.html +++ b/vipra-ui/app/html/explorer.html @@ -26,8 +26,8 @@ <span class="valuebar" ng-style="{width:topic.topicCurrValue}"></span> <input tabindex="0" type="checkbox" ng-model="topic.selected" ng-attr-id="{{::topic.id}}" ng-change="redrawGraph()"> <label class="check" ng-attr-for="{{::topic.id}}"> - <topic-menu topic="topic" /> - <span class="ellipsis topic"> + <topic-menu topic="topic" class="menu-button" /> + <span class="ellipsis" ng-attr-title="{{::topic.name}}"> <span ng-bind="topic.name"></span> </span> </label> diff --git a/vipra-ui/app/index.html b/vipra-ui/app/index.html index f65433a562f62d6621c6a5b72b1d3580ab59fb8b..df85c4507feaa5186ed39685983625c634f98989 100644 --- a/vipra-ui/app/index.html +++ b/vipra-ui/app/index.html @@ -81,9 +81,9 @@ <li ng-class="{'text-italic active':rootModels.topicModel}"> <a tabindex="0" ng-click="chooseTopicModel()" ng-bind-template="{{rootModels.topicModel ? rootModels.topicModel.id : 'Models'}}" ng-attr-title="{{rootModels.topicModel.modelConfig.description}}"></a> </li> - <li ui-sref-active="active"> - <a tabindex="0" ui-sref="about"> - About + <li title="Report a bug"> + <a tabindex="0" ng-click="reportBug()"> + <i class="fa fa-bug text-danger"></i> </a> </li> <li title="Keyboard cheatsheet"> @@ -91,6 +91,11 @@ <i class="fa fa-keyboard-o"></i> </a> </li> + <li ui-sref-active="active" title="About"> + <a tabindex="0" ui-sref="about"> + <i class="fa fa-question-circle"></i> + </a> + </li> </ul> </div> </div> @@ -141,6 +146,51 @@ </div> </div> </div> + <div id="bugReportModal" class="modal" tabindex="-1" role="dialog" data-backdrop="static" data-keyboard="false" ng-controller="BugReportController"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> + <h4 class="modal-title">Report a bug/problem</h4> + </div> + <div class="modal-body"> + <form class="form-horizontal" name="bugReportForm"> + <div class="form-group"> + <div class="col-sm-offset-2 col-sm-10"> + <div class="checkbox"> + <input tabindex="0" type="checkbox" ng-model="bug.reproducible" id="reproducible"> + <label class="check" for="reproducible">Problem is reproducible</label> + </div> + </div> + </div> + <div class="form-group"> + <label for="description" class="col-sm-2 control-label">Description</label> + <div class="col-sm-10"> + <textarea class="form-control resize-vertical" id="description" rows="10" placeholder="Describe the problem..." ng-model="bug.description"></textarea> + </div> + </div> + <div class="form-group"> + <label class="col-sm-2 control-label">Screenshot</label> + <div class="col-sm-10"> + <div class="input-group"> + <span class="input-group-btn"> + <span class="btn btn-primary btn-file"> + Browse… <input type="file" id="bugScreenshot" accept="image/*"> + </span> + </span> + <input type="text" class="form-control readonly-white" readonly> + </div> + </div> + </div> + </form> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-default" data-dismiss="modal" ng-click="clear()">Close</button> + <button type="button" class="btn btn-primary" ng-click="sendBugReport()" ng-disabled="!bug.description">Report</button> + </div> + </div> + </div> + </div> <div class="alerts"> <bs-alert ng-model="alert" type="alert.type" ng-repeat="alert in alerts"/> </div> diff --git a/vipra-ui/app/js/app.js b/vipra-ui/app/js/app.js index 59d89ad57e97b4fdbd1ee125399a17d452a46386..36f433a40c5b9b516a6b71a220c55a519b18bff0 100644 --- a/vipra-ui/app/js/app.js +++ b/vipra-ui/app/js/app.js @@ -217,4 +217,22 @@ }]); + $(document).on('change', '.btn-file :file', function() { + var input = $(this), + numFiles = input.get(0).files ? input.get(0).files.length : 1, + label = input.val().replace(/\\/g, '/').replace(/.*\//, ''); + input.trigger('fileselect', [numFiles, label]); + }); + + $(document).ready( function() { + $('.btn-file :file').on('fileselect', function(event, numFiles, label) { + var input = $(this).parents('.input-group').find(':text'), + log = numFiles > 1 ? numFiles + ' files selected' : label; + + if(input.length) { + input.val(log); + } + }); + }); + })(); \ No newline at end of file diff --git a/vipra-ui/app/js/controllers.js b/vipra-ui/app/js/controllers.js index 6510ac2395214b089f5230722c1b62fbf7a19944..ece0c83d6a5a9784362dcf3ab1af1a69eeb06492 100644 --- a/vipra-ui/app/js/controllers.js +++ b/vipra-ui/app/js/controllers.js @@ -56,6 +56,10 @@ localStorage.tm = topicModel.id; }; + $scope.reportBug = function() { + $('#bugReportModal').modal(); + }; + $scope.menubarSearch = function(query) { $state.transitionTo('index', { q: query @@ -1197,6 +1201,51 @@ } ]); + /**************************************************************************** + * Bug Report Controller + ****************************************************************************/ + + app.controller('BugReportController', ['$scope', '$state', '$q', 'BugReportFactory', + function($scope, $state, $q, BugReportFactory) { + + $scope.sendBugReport = function() { + var defer = $q.defer(), + file = document.getElementById('bugScreenshot').files[0]; + if(file) { + var reader = new FileReader(); + reader.onload = function() { + defer.resolve(reader.result); + }; + reader.onabort = function() { + defer.resolve(); + } + reader.readAsDataURL(file); + } else { + defer.resolve(); + } + + defer.promise.then(function(screenshot) { + BugReportFactory.save({ + userAgent: navigator.userAgent, + route: $state.current.name, + reproducible: $scope.bug.reproducible, + description: $scope.bug.description, + screenshot: screenshot + }, function() { + $('#bugReportModal').modal('hide'); + $scope.clear(); + }); + }); + }; + + $scope.clear = function() { + delete $scope.bug; + document.forms.bugReportForm.reset(); + }; + + } + ]); + /**************************************************************************** * Error Controllers ****************************************************************************/ diff --git a/vipra-ui/app/js/factories.js b/vipra-ui/app/js/factories.js index 4fe9d1474b0a61489f7717551ccd0a45e1f127ab..b9195e4e164d984a216ce33093a628bff0a36881 100644 --- a/vipra-ui/app/js/factories.js +++ b/vipra-ui/app/js/factories.js @@ -80,4 +80,8 @@ return $myResource(Vipra.config.restUrl + '/entities/:id'); }]); + app.factory('BugReportFactory', ['$myResource', function($myResource) { + return $myResource(Vipra.config.restUrl + '/bugreports/:id'); + }]); + })(); \ No newline at end of file diff --git a/vipra-ui/app/less/app.less b/vipra-ui/app/less/app.less index ef6918e769fde26096e1168f1b14a9bc040aa5ce..bc84dcf53790fa5d64a27dcd23e3187550e1f69f 100644 --- a/vipra-ui/app/less/app.less +++ b/vipra-ui/app/less/app.less @@ -285,9 +285,6 @@ a:hover { margin-left: -18px; } } - .topic { - padding-left: 15px; - } .popover-area { position: absolute; right: 0; @@ -406,6 +403,10 @@ entity-menu { width: 100px; } +.colorcol { + width: 21px; +} + .page-header.no-border { border-color: transparent; } @@ -441,6 +442,43 @@ entity-menu { background-repeat: no-repeat; } +.resize-vertical { + resize: vertical; +} + +.btn-file { + position: relative; + overflow: hidden; +} +.btn-file input[type=file] { + position: absolute; + top: 0; + right: 0; + min-width: 100%; + min-height: 100%; + font-size: 100px; + text-align: right; + filter: alpha(opacity=0); + opacity: 0; + outline: none; + background: white; + cursor: inherit; + display: block; +} + +.readonly-white { + background-color: white !important; + cursor: text !important; +} + +.menu-button { + position: absolute; +} + +.menu-button + * { + padding-left: 15px; +} + @-moz-keyframes spin { 100% { -moz-transform: rotateY(360deg); diff --git a/vipra-util/src/main/java/de/vipra/util/model/BugReport.java b/vipra-util/src/main/java/de/vipra/util/model/BugReport.java new file mode 100644 index 0000000000000000000000000000000000000000..1e04e568b3e306515be7feeb7beef2bc325ad9cf --- /dev/null +++ b/vipra-util/src/main/java/de/vipra/util/model/BugReport.java @@ -0,0 +1,101 @@ +package de.vipra.util.model; + +import java.io.Serializable; +import java.util.Date; + +import org.bson.types.ObjectId; +import org.mongodb.morphia.annotations.Entity; +import org.mongodb.morphia.annotations.Id; +import org.mongodb.morphia.annotations.PrePersist; + +import de.vipra.util.an.QueryIgnore; + +@SuppressWarnings("serial") +@Entity(value = "bugreport", noClassnameStored = true) +public class BugReport implements Model<ObjectId>, Serializable { + + @Id + private ObjectId id = new ObjectId(); + + @QueryIgnore(multi = true) + private String route; + + @QueryIgnore(multi = true) + private String userAgent; + + @QueryIgnore(multi = true) + private String description; + + @QueryIgnore(multi = true) + private Boolean reproducible; + + @QueryIgnore(multi = true) + private Date created; + + @QueryIgnore(multi = true) + private String screenshot; + + @Override + public ObjectId getId() { + return id; + } + + @Override + public void setId(ObjectId id) { + this.id = id; + } + + public String getRoute() { + return route; + } + + public void setRoute(String route) { + this.route = route; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Boolean isReproducible() { + return reproducible; + } + + public void setReproducible(Boolean reproducible) { + this.reproducible = reproducible; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public String getScreenshot() { + return screenshot; + } + + public void setScreenshot(String screenshot) { + this.screenshot = screenshot; + } + + @PrePersist + private void prePersist() { + this.created = new Date(); + } + +}