scala-news-reader

rss/atom news reader in scala

git clone https://9o.is/git/scala-news-reader.git

commit fc2d0140bbdf7bf60aadcc2dedc3cf116a318413
parent 3ae953e1dbfbd17ce013800641c0e3ba23b81994
Author: Jul <jul@9o.is>
Date:   Wed, 31 Jul 2013 13:13:29 -0400

ready for signups

Diffstat:
Mproject/Build.scala | 1+
Msbt | 2+-
Msbt-debug | 2+-
Msrc/main/javascript/customs/fileupload.js | 14++++++--------
Msrc/main/javascript/customs/other.js | 46+++++++++++++++++++++++++++++++++++++++++++++-
Asrc/main/javascript/libs/colorwheel.js | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/javascript/libs/jquery.keynav.js | 22++++++++++++++--------
Asrc/main/javascript/libs/jquery.waypoints.js | 521+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/javascript/libs/jquery.waypoints.sticky.js | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/javascript/script.jsm | 7+++++--
Msrc/main/less/styles.less | 440+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/main/less/variables.less | 10++++++++--
Msrc/main/resources/props/production.default.props | 24+++++++++++++++++++++++-
Asrc/main/resources/rome.properties | 7+++++++
Msrc/main/scala/bootstrap/liftweb/Boot.scala | 12++++++++----
Msrc/main/scala/com/joereader/config/ErrorHandler.scala | 22++++++++++++++--------
Asrc/main/scala/com/joereader/config/GoogleAnalytics.scala | 26++++++++++++++++++++++++++
Msrc/main/scala/com/joereader/config/MongoConfig.scala | 26+++++++++++++++-----------
Msrc/main/scala/com/joereader/config/Site.scala | 160++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Msrc/main/scala/com/joereader/config/SmtpMailer.scala | 4++--
Dsrc/main/scala/com/joereader/lib/EmailMsgs.scala | 9---------
Dsrc/main/scala/com/joereader/lib/Event.scala | 47-----------------------------------------------
Msrc/main/scala/com/joereader/lib/Helper.scala | 72+++++++++++-------------------------------------------------------------
Msrc/main/scala/com/joereader/lib/ImageUpload.scala | 210++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Asrc/main/scala/com/joereader/lib/URLFormatter.scala | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/scala/com/joereader/lib/VideoInfo.scala | 29++++++++++++++++++-----------
Msrc/main/scala/com/joereader/lib/Wordpress.scala | 86+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Msrc/main/scala/com/joereader/lib/aws/AmazonS3.scala | 26+++++++++++++++++---------
Msrc/main/scala/com/joereader/lib/aws/S3.scala | 27++++++++-------------------
Asrc/main/scala/com/joereader/lib/rss/Feed.scala | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/lib/rss/FeedEntry.scala | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/lib/rss/MetaVerification.scala | 14++++++++++++++
Dsrc/main/scala/com/joereader/lib/rss/Rss.scala | 281-------------------------------------------------------------------------------
Asrc/main/scala/com/joereader/lib/rss/gdata/GDataModule.java | 19+++++++++++++++++++
Asrc/main/scala/com/joereader/lib/rss/gdata/GDataModuleImpl.java | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/lib/rss/gdata/io/GDataModuleGenerator.java | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/lib/rss/gdata/io/GDataModuleParser.java | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/lib/rss/package.scala | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/scala/com/joereader/model/BetaUser.scala | 7-------
Msrc/main/scala/com/joereader/model/Blog.scala | 129+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Asrc/main/scala/com/joereader/model/BlogWriter.scala | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/model/BlogWriterUser.scala | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/scala/com/joereader/model/Category.scala | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/main/scala/com/joereader/model/InviteToken.scala | 10++++------
Msrc/main/scala/com/joereader/model/User.scala | 320++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Asrc/main/scala/com/joereader/snippet/ArticleSnip.scala | 43+++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/BackgroundSnip.scala | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/BlogSnipEdit.scala | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/BlogSnipView.scala | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/scala/com/joereader/snippet/BlogSnips.scala | 410++++++++++++-------------------------------------------------------------------
Asrc/main/scala/com/joereader/snippet/BlogWriterCategoriesSnip.scala | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/BlogWriterColorSnip.scala | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/BlogWriterSnipView.scala | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/BlogWritersSnipView.scala | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/FollowSnip.scala | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/scala/com/joereader/snippet/LiftExtras.scala | 123+++++++++++++++++++++++++++----------------------------------------------------
Asrc/main/scala/com/joereader/snippet/MyBlog.scala | 42++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/PasswordReset.scala | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/ReaderSnip.scala | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/Search.scala | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/scala/com/joereader/snippet/SignUp.scala | 88+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Asrc/main/scala/com/joereader/snippet/SitemapContent.scala | 39+++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/UserBlogsSnip.scala | 31+++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/UserLogin.scala | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/UserPassword.scala | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/UserReaderSnipEdit.scala | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/UserReaderSnipView.scala | 31+++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/UserRecovery.scala | 46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/scala/com/joereader/snippet/UserSnips.scala | 543+++++++------------------------------------------------------------------------
Asrc/main/scala/com/joereader/snippet/UserTopbar.scala | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/UserWriterSnipEdit.scala | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/scala/com/joereader/snippet/UserWriterSnipView.scala | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/scala/com/joereader/snippet/Verification.scala | 196+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Msrc/main/webapp/blogwriter.html | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/main/webapp/error.html | 1+
Msrc/main/webapp/following.html | 24++++++++++++++++++++++--
Asrc/main/webapp/img/colorwheel.png | 0
Asrc/main/webapp/img/features/categories.jpg | 0
Asrc/main/webapp/img/features/profile-phone.jpg | 0
Asrc/main/webapp/img/features/reader-ipad.jpg | 0
Asrc/main/webapp/img/features/share-laptop1.jpg | 0
Asrc/main/webapp/img/features/video1.jpg | 0
Asrc/main/webapp/img/features/writers-8.jpg | 0
Msrc/main/webapp/index.html | 229+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Msrc/main/webapp/preview.html | 10++++++----
Msrc/main/webapp/reader.html | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Asrc/main/webapp/search/categories.html | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/webapp/search/writers.html | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/webapp/settings/account.html | 59+++++++++++++++++++++++++++++++++++++----------------------
Asrc/main/webapp/settings/blog.html | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/webapp/settings/blogs.html | 29++++++++++++++++++++++++++---
Msrc/main/webapp/settings/following.html | 22+++++++++++++++++++++-
Msrc/main/webapp/signup/blog.html | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/main/webapp/signup/password.html | 2+-
Msrc/main/webapp/signup/user.html | 16+++++++++++++---
Msrc/main/webapp/signup/verify.html | 1-
Asrc/main/webapp/sitemap.html | 14++++++++++++++
Msrc/main/webapp/templates-hidden/base-wrap.html | 2+-
Msrc/main/webapp/templates-hidden/default-wide.html | 1-
Msrc/main/webapp/templates-hidden/default.html | 1-
Msrc/main/webapp/templates-hidden/parts/blog-profile.html | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Msrc/main/webapp/templates-hidden/parts/categories.html | 33+++++++++++++++++++++++++++++----
Asrc/main/webapp/templates-hidden/parts/social.html | 48++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/webapp/templates-hidden/parts/user-profile.html | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/main/webapp/templates-hidden/parts/verification.html | 9+++++----
105 files changed, 5876 insertions(+), 2169 deletions(-)

diff --git a/project/Build.scala b/project/Build.scala @@ -21,6 +21,7 @@ object LiftProjectBuild extends Build { "net.databinder.dispatch" %% "dispatch-core" % "0.10.0", "org.jsoup" % "jsoup" % "1.7.2", "rome" % "rome" % "1.0", + "org.rometools" % "rome-modules" % "1.0", "com.typesafe.akka" %% "akka-actor" % "2.1.4" ) ) diff --git a/sbt b/sbt @@ -1 +1 @@ -java -Xms512M -Xmx2048M -Xss1M -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=512M -jar `dirname $0`/project/sbt-launch.jar "$@" +java -Drebel.lift_plugin=true -noverify -javaagent:/opt/jrebel/jrebel.jar -Xms512M -Xmx2048M -Xss2M -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=512M -jar project/sbt-launch.jar "$@" diff --git a/sbt-debug b/sbt-debug @@ -1 +1 @@ -java -Xms512M -Xmx2048M -Xss1M -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=512M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -jar project/sbt-launch.jar "$@" +java -Drebel.lift_plugin=true -noverify -javaagent:/opt/jrebel/jrebel.jar -Xms512M -Xmx2048M -Xss2M -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=512M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -jar project/sbt-launch.jar "$@" diff --git a/src/main/javascript/customs/fileupload.js b/src/main/javascript/customs/fileupload.js @@ -12,17 +12,16 @@ $(function () { $('#bg-progress-bar').css('width', progress); }, done: function (e, data) { - $.each(data.files, function (index, file) { - $('<p/>').text(file.name).appendTo(document.body); - }); $('#bg-progress').fadeOut(); + var b = eval("(function(){" + data.result.callback + "})"); + b(-1); }, fail: function (e, data) { $('#bg-progress').hide(); $('div[data-alerts="alerts"]').append('<div class="alert alert-error fade in">'+ '<button type="button" class="close" data-dismiss="alert">×</button>'+ - data.errorThrown+'</div>'); + data.jqXHR.responseText+'</div>'); } }); @@ -39,17 +38,16 @@ $(function () { $('#pic-progress-bar').css('width', progress); }, done: function (e, data) { - $.each(data.files, function (index, file) { - $('<p/>').text(file.name).appendTo(document.body); - }); $('#pic-progress').fadeOut(); + var b = eval("(function(){" + data.result.callback + "})"); + b(-1); }, fail: function (e, data) { $('#pic-progress').hide(); $('div[data-alerts="alerts"]').append('<div class="alert alert-error fade in">'+ '<button type="button" class="close" data-dismiss="alert">×</button>'+ - data.errorThrown+'</div>'); + data.jqXHR.responseText+'</div>'); } }); }); \ No newline at end of file diff --git a/src/main/javascript/customs/other.js b/src/main/javascript/customs/other.js @@ -1,12 +1,56 @@ + $(function () { $(document).ready(function(){ + + $('#reader-writers').find('li:first').siblings().hide(); + + $('.boxed-articles-span').find('.article').waypoint({ handler: function(e, dir) { + $(this).addClass('active').siblings().removeClass('active'); + var id = $(this).find(".article-key").attr('class').split(/\s+/).pop(); + + var writer = $(document.getElementById(id)); + writer.show(); + writer.siblings().hide(); + + function attr() { + return $('.article.active').find('.article-inner').css('border-right-color') + } + + $('.reader-nav').css('border-left','3px solid '+attr()); + + }, offset: 'bottom-in-view'}); + + $('.boxed-articles-span').find('.article').waypoint({ handler: function(e, dir) { + $(this).addClass('active').siblings().removeClass('active'); + var id = $(this).find(".article-key").attr('class').split(/\s+/).pop(); + + var writer = $(document.getElementById(id)); + writer.show(); + writer.siblings().hide(); + + function attr() { + return $('.article.active').find('.article-inner').css('border-right-color') + } + + $('.reader-nav').css('border-left','3px solid '+attr()); + + }, offset: '50%' }); + + $('#reader-categories').tinyscrollbar({ axis: 'y' }); $('#blog-writers').tinyscrollbar({ axis: 'x' }); + + $('.reader-nav-blog').waypoint('sticky', { offset: 50 }); + $(document).on("click","#writer-list label",function() { $(this).addClass('selected').siblings().removeClass('selected') }); + $(document).on("click","#backgrounds-list label",function() { + $(this).addClass('selected').siblings().removeClass('selected') + }); + $('#following-search').on("input", function(e) { var qry = $(this).val().toLowerCase(); $('#writers-container .writer').each(function() { @@ -24,7 +68,7 @@ $(function () { $("[rel='popover']").popover(); - $('.article').keynav(); + $('.keynav-article').keynav(); $('#nav-buttons a').click(function (e) { e.preventDefault(); diff --git a/src/main/javascript/libs/colorwheel.js b/src/main/javascript/libs/colorwheel.js @@ -0,0 +1,56 @@ +$(function(){ + var bCanPreview = true; // can preview + + // create canvas and context objects + var colorpicker = $('#colorpicker-wrapper').find('.colorpicker'); + var preview = $('#colorpicker-wrapper').find('.preview'); + var canvas = $('#colorpicker-wrapper').find("#picker"); + + var picker = document.getElementById("picker"); + var ctx; + if(typeof picker !== 'undefined' && picker !== null) { + ctx = picker.getContext('2d'); + } + + // drawing active image + var image = new Image(); + image.onload = function () { + if(typeof ctx !== 'undefined' && ctx !== null) { + ctx.drawImage(image, 0, 0, image.width, image.height); // draw the image on the canvas + } + } + + // select desired colorwheel + var imageSrc = '/img/colorwheel.png'; + + image.src = imageSrc; + + canvas.mousemove(function(e) { // mouse move handler + if (bCanPreview) { + // get coordinates of current position + var canvasOffset = $(canvas).offset(); + var canvasX = Math.floor(e.pageX - canvasOffset.left); + var canvasY = Math.floor(e.pageY - canvasOffset.top); + + if(typeof ctx !== 'undefined' && ctx !== null) { + // get current pixel + var imageData = ctx.getImageData(canvasX, canvasY, 1, 1); + var pixel = imageData.data; + + // update preview color + var pixelColor = "rgb("+pixel[0]+", "+pixel[1]+", "+pixel[2]+")"; + preview.css('backgroundColor', pixelColor); + + var dColor = pixel[2] + 256 * pixel[1] + 65536 * pixel[0]; + $('#colorpicker-wrapper').find('#hexVal').val('#' + ('0000' + dColor.toString(16)).substr(-6)); + } + } + }); + canvas.click(function(e) { // click event handler + bCanPreview = !bCanPreview; + }); + preview.click(function(e) { // preview click + colorpicker.fadeToggle("fast", "linear"); + bCanPreview = true; + }); +}); diff --git a/src/main/javascript/libs/jquery.keynav.js b/src/main/javascript/libs/jquery.keynav.js @@ -2,8 +2,10 @@ $.fn.keynav = (function () { //a couple of global things for the plugin var nodes = $(), positions = [], recalculate_positions, params; params = { - keynext: 40, - keyprev: 38 + keynext: 40, + keyprev: 38, + keynextJ: 74, + keyprevK: 75 }; //we're watching keypresses globally as well. $(document).keydown(function(e) { @@ -12,36 +14,40 @@ $.fn.keynav = (function () { if (e.target && e.target.tagName == 'TEXTAREA' || e.target.tagName == 'INPUT') { return true; } - if(e.keyCode == params.keynext || e.keyCode == params.keyprev) { + if(e.keyCode == params.keynext || e.keyCode == params.keynextJ || e.keyCode == params.keyprevK || e.keyCode == params.keyprev) { st = $(window).scrollTop(); //we're checking if our positions are still correct. it may not be necessary all the time, but just to be safe if(positions[positions.length-1].top !== $(positions[positions.length-1].obj).offset().top) { recalculate_positions(); } - if(e.keyCode === params.keynext) { + if(e.keyCode === params.keynext || e.keyCode === params.keynextJ) { for(i = positions.length-1; i > -1; i--) { if(positions[i].top > st + 1) {desired = i;} } - } else if(e.keyCode === params.keyprev) { + } else if(e.keyCode === params.keyprev || e.keyCode === params.keyprevK) { for(i = 0; i < positions.length; i++) { if(positions[i].top < st - 1) {desired = i;} } } if(positions[desired]) { - $('html,body').scrollTop(positions[desired].top); + $('html,body').animate({scrollTop: positions[desired].top}, 1); } else { - $('html,body').scrollTop(e.keyCode === params.keynext ? ($('body').height() - $(window).height()) : 0); + $('html,body').animate({scrollTop: (e.keyCode === params.keynext || e.keyCode === params.keynextJ) ? + ($('body').height() - $(window).height()) : 0}, 1); } } }); recalculate_positions = function () { //this recalculates the positions of nodes + var deduct = 0; + if($(window).width() > 979) { deduct = 50; } + positions = []; nodes.each(function() { - positions.push({top: Math.floor($(this).offset().top - 80),obj: this}); + positions.push({top: Math.floor($(this).offset().top - deduct),obj: this}); }); }; diff --git a/src/main/javascript/libs/jquery.waypoints.js b/src/main/javascript/libs/jquery.waypoints.js @@ -0,0 +1,520 @@ +// Generated by CoffeeScript 1.6.2 +/* +jQuery Waypoints - v2.0.2 +Copyright (c) 2011-2013 Caleb Troughton +Dual licensed under the MIT license and GPL license. +https://github.com/imakewebthings/jquery-waypoints/blob/master/licenses.txt +*/ + + +(function() { + var __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }, + __slice = [].slice; + + (function(root, factory) { + if (typeof define === 'function' && define.amd) { + return define('waypoints', ['jquery'], function($) { + return factory($, root); + }); + } else { + return factory(root.jQuery, root); + } + })(this, function($, window) { + var $w, Context, Waypoint, allWaypoints, contextCounter, contextKey, contexts, isTouch, jQMethods, methods, resizeEvent, scrollEvent, waypointCounter, waypointKey, wp, wps; + + $w = $(window); + isTouch = __indexOf.call(window, 'ontouchstart') >= 0; + allWaypoints = { + horizontal: {}, + vertical: {} + }; + contextCounter = 1; + contexts = {}; + contextKey = 'waypoints-context-id'; + resizeEvent = 'resize.waypoints'; + scrollEvent = 'scroll.waypoints'; + waypointCounter = 1; + waypointKey = 'waypoints-waypoint-ids'; + wp = 'waypoint'; + wps = 'waypoints'; + Context = (function() { + function Context($element) { + var _this = this; + + this.$element = $element; + this.element = $element[0]; + this.didResize = false; + this.didScroll = false; + this.id = 'context' + contextCounter++; + this.oldScroll = { + x: $element.scrollLeft(), + y: $element.scrollTop() + }; + this.waypoints = { + horizontal: {}, + vertical: {} + }; + $element.data(contextKey, this.id); + contexts[this.id] = this; + $element.bind(scrollEvent, function() { + var scrollHandler; + + if (!(_this.didScroll || isTouch)) { + _this.didScroll = true; + scrollHandler = function() { + _this.doScroll(); + return _this.didScroll = false; + }; + return window.setTimeout(scrollHandler, $[wps].settings.scrollThrottle); + } + }); + $element.bind(resizeEvent, function() { + var resizeHandler; + + if (!_this.didResize) { + _this.didResize = true; + resizeHandler = function() { + $[wps]('refresh'); + return _this.didResize = false; + }; + return window.setTimeout(resizeHandler, $[wps].settings.resizeThrottle); + } + }); + } + + Context.prototype.doScroll = function() { + var axes, + _this = this; + + axes = { + horizontal: { + newScroll: this.$element.scrollLeft(), + oldScroll: this.oldScroll.x, + forward: 'right', + backward: 'left' + }, + vertical: { + newScroll: this.$element.scrollTop(), + oldScroll: this.oldScroll.y, + forward: 'down', + backward: 'up' + } + }; + if (isTouch && (!axes.vertical.oldScroll || !axes.vertical.newScroll)) { + $[wps]('refresh'); + } + $.each(axes, function(aKey, axis) { + var direction, isForward, triggered; + + triggered = []; + isForward = axis.newScroll > axis.oldScroll; + direction = isForward ? axis.forward : axis.backward; + $.each(_this.waypoints[aKey], function(wKey, waypoint) { + var _ref, _ref1; + + if ((axis.oldScroll < (_ref = waypoint.offset) && _ref <= axis.newScroll)) { + return triggered.push(waypoint); + } else if ((axis.newScroll < (_ref1 = waypoint.offset) && _ref1 <= axis.oldScroll)) { + return triggered.push(waypoint); + } + }); + triggered.sort(function(a, b) { + return a.offset - b.offset; + }); + if (!isForward) { + triggered.reverse(); + } + return $.each(triggered, function(i, waypoint) { + if (waypoint.options.continuous || i === triggered.length - 1) { + return waypoint.trigger([direction]); + } + }); + }); + return this.oldScroll = { + x: axes.horizontal.newScroll, + y: axes.vertical.newScroll + }; + }; + + Context.prototype.refresh = function() { + var axes, cOffset, isWin, + _this = this; + + isWin = $.isWindow(this.element); + cOffset = this.$element.offset(); + this.doScroll(); + axes = { + horizontal: { + contextOffset: isWin ? 0 : cOffset.left, + contextScroll: isWin ? 0 : this.oldScroll.x, + contextDimension: this.$element.width(), + oldScroll: this.oldScroll.x, + forward: 'right', + backward: 'left', + offsetProp: 'left' + }, + vertical: { + contextOffset: isWin ? 0 : cOffset.top, + contextScroll: isWin ? 0 : this.oldScroll.y, + contextDimension: isWin ? $[wps]('viewportHeight') : this.$element.height(), + oldScroll: this.oldScroll.y, + forward: 'down', + backward: 'up', + offsetProp: 'top' + } + }; + return $.each(axes, function(aKey, axis) { + return $.each(_this.waypoints[aKey], function(i, waypoint) { + var adjustment, elementOffset, oldOffset, _ref, _ref1; + + adjustment = waypoint.options.offset; + oldOffset = waypoint.offset; + elementOffset = $.isWindow(waypoint.element) ? 0 : waypoint.$element.offset()[axis.offsetProp]; + if ($.isFunction(adjustment)) { + adjustment = adjustment.apply(waypoint.element); + } else if (typeof adjustment === 'string') { + adjustment = parseFloat(adjustment); + if (waypoint.options.offset.indexOf('%') > -1) { + adjustment = Math.ceil(axis.contextDimension * adjustment / 100); + } + } + waypoint.offset = elementOffset - axis.contextOffset + axis.contextScroll - adjustment; + if ((waypoint.options.onlyOnScroll && (oldOffset != null)) || !waypoint.enabled) { + return; + } + if (oldOffset !== null && (oldOffset < (_ref = axis.oldScroll) && _ref <= waypoint.offset)) { + return waypoint.trigger([axis.backward]); + } else if (oldOffset !== null && (oldOffset > (_ref1 = axis.oldScroll) && _ref1 >= waypoint.offset)) { + return waypoint.trigger([axis.forward]); + } else if (oldOffset === null && axis.oldScroll >= waypoint.offset) { + return waypoint.trigger([axis.forward]); + } + }); + }); + }; + + Context.prototype.checkEmpty = function() { + if ($.isEmptyObject(this.waypoints.horizontal) && $.isEmptyObject(this.waypoints.vertical)) { + this.$element.unbind([resizeEvent, scrollEvent].join(' ')); + return delete contexts[this.id]; + } + }; + + return Context; + + })(); + Waypoint = (function() { + function Waypoint($element, context, options) { + var idList, _ref; + + options = $.extend({}, $.fn[wp].defaults, options); + if (options.offset === 'bottom-in-view') { + options.offset = function() { + var contextHeight; + + contextHeight = $[wps]('viewportHeight'); + if (!$.isWindow(context.element)) { + contextHeight = context.$element.height(); + } + return contextHeight - $(this).outerHeight(); + }; + } + this.$element = $element; + this.element = $element[0]; + this.axis = options.horizontal ? 'horizontal' : 'vertical'; + this.callback = options.handler; + this.context = context; + this.enabled = options.enabled; + this.id = 'waypoints' + waypointCounter++; + this.offset = null; + this.options = options; + context.waypoints[this.axis][this.id] = this; + allWaypoints[this.axis][this.id] = this; + idList = (_ref = $element.data(waypointKey)) != null ? _ref : []; + idList.push(this.id); + $element.data(waypointKey, idList); + } + + Waypoint.prototype.trigger = function(args) { + if (!this.enabled) { + return; + } + if (this.callback != null) { + this.callback.apply(this.element, args); + } + if (this.options.triggerOnce) { + return this.destroy(); + } + }; + + Waypoint.prototype.disable = function() { + return this.enabled = false; + }; + + Waypoint.prototype.enable = function() { + this.context.refresh(); + return this.enabled = true; + }; + + Waypoint.prototype.destroy = function() { + delete allWaypoints[this.axis][this.id]; + delete this.context.waypoints[this.axis][this.id]; + return this.context.checkEmpty(); + }; + + Waypoint.getWaypointsByElement = function(element) { + var all, ids; + + ids = $(element).data(waypointKey); + if (!ids) { + return []; + } + all = $.extend({}, allWaypoints.horizontal, allWaypoints.vertical); + return $.map(ids, function(id) { + return all[id]; + }); + }; + + return Waypoint; + + })(); + methods = { + init: function(f, options) { + var _ref; + + if (options == null) { + options = {}; + } + if ((_ref = options.handler) == null) { + options.handler = f; + } + this.each(function() { + var $this, context, contextElement, _ref1; + + $this = $(this); + contextElement = (_ref1 = options.context) != null ? _ref1 : $.fn[wp].defaults.context; + if (!$.isWindow(contextElement)) { + contextElement = $this.closest(contextElement); + } + contextElement = $(contextElement); + context = contexts[contextElement.data(contextKey)]; + if (!context) { + context = new Context(contextElement); + } + return new Waypoint($this, context, options); + }); + $[wps]('refresh'); + return this; + }, + disable: function() { + return methods._invoke(this, 'disable'); + }, + enable: function() { + return methods._invoke(this, 'enable'); + }, + destroy: function() { + return methods._invoke(this, 'destroy'); + }, + prev: function(axis, selector) { + return methods._traverse.call(this, axis, selector, function(stack, index, waypoints) { + if (index > 0) { + return stack.push(waypoints[index - 1]); + } + }); + }, + next: function(axis, selector) { + return methods._traverse.call(this, axis, selector, function(stack, index, waypoints) { + if (index < waypoints.length - 1) { + return stack.push(waypoints[index + 1]); + } + }); + }, + _traverse: function(axis, selector, push) { + var stack, waypoints; + + if (axis == null) { + axis = 'vertical'; + } + if (selector == null) { + selector = window; + } + waypoints = jQMethods.aggregate(selector); + stack = []; + this.each(function() { + var index; + + index = $.inArray(this, waypoints[axis]); + return push(stack, index, waypoints[axis]); + }); + return this.pushStack(stack); + }, + _invoke: function($elements, method) { + $elements.each(function() { + var waypoints; + + waypoints = Waypoint.getWaypointsByElement(this); + return $.each(waypoints, function(i, waypoint) { + waypoint[method](); + return true; + }); + }); + return this; + } + }; + $.fn[wp] = function() { + var args, method; + + method = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : []; + if (methods[method]) { + return methods[method].apply(this, args); + } else if ($.isFunction(method)) { + return methods.init.apply(this, arguments); + } else if ($.isPlainObject(method)) { + return methods.init.apply(this, [null, method]); + } else if (!method) { + return $.error("jQuery Waypoints needs a callback function or handler option."); + } else { + return $.error("The " + method + " method does not exist in jQuery Waypoints."); + } + }; + $.fn[wp].defaults = { + context: window, + continuous: true, + enabled: true, + horizontal: false, + offset: 0, + triggerOnce: false + }; + jQMethods = { + refresh: function() { + return $.each(contexts, function(i, context) { + return context.refresh(); + }); + }, + viewportHeight: function() { + var _ref; + + return (_ref = window.innerHeight) != null ? _ref : $w.height(); + }, + aggregate: function(contextSelector) { + var collection, waypoints, _ref; + + collection = allWaypoints; + if (contextSelector) { + collection = (_ref = contexts[$(contextSelector).data(contextKey)]) != null ? _ref.waypoints : void 0; + } + if (!collection) { + return []; + } + waypoints = { + horizontal: [], + vertical: [] + }; + $.each(waypoints, function(axis, arr) { + $.each(collection[axis], function(key, waypoint) { + return arr.push(waypoint); + }); + arr.sort(function(a, b) { + return a.offset - b.offset; + }); + waypoints[axis] = $.map(arr, function(waypoint) { + return waypoint.element; + }); + return waypoints[axis] = $.unique(waypoints[axis]); + }); + return waypoints; + }, + above: function(contextSelector) { + if (contextSelector == null) { + contextSelector = window; + } + return jQMethods._filter(contextSelector, 'vertical', function(context, waypoint) { + return waypoint.offset <= context.oldScroll.y; + }); + }, + below: function(contextSelector) { + if (contextSelector == null) { + contextSelector = window; + } + return jQMethods._filter(contextSelector, 'vertical', function(context, waypoint) { + return waypoint.offset > context.oldScroll.y; + }); + }, + left: function(contextSelector) { + if (contextSelector == null) { + contextSelector = window; + } + return jQMethods._filter(contextSelector, 'horizontal', function(context, waypoint) { + return waypoint.offset <= context.oldScroll.x; + }); + }, + right: function(contextSelector) { + if (contextSelector == null) { + contextSelector = window; + } + return jQMethods._filter(contextSelector, 'horizontal', function(context, waypoint) { + return waypoint.offset > context.oldScroll.x; + }); + }, + enable: function() { + return jQMethods._invoke('enable'); + }, + disable: function() { + return jQMethods._invoke('disable'); + }, + destroy: function() { + return jQMethods._invoke('destroy'); + }, + extendFn: function(methodName, f) { + return methods[methodName] = f; + }, + _invoke: function(method) { + var waypoints; + + waypoints = $.extend({}, allWaypoints.vertical, allWaypoints.horizontal); + return $.each(waypoints, function(key, waypoint) { + waypoint[method](); + return true; + }); + }, + _filter: function(selector, axis, test) { + var context, waypoints; + + context = contexts[$(selector).data(contextKey)]; + if (!context) { + return []; + } + waypoints = []; + $.each(context.waypoints[axis], function(i, waypoint) { + if (test(context, waypoint)) { + return waypoints.push(waypoint); + } + }); + waypoints.sort(function(a, b) { + return a.offset - b.offset; + }); + return $.map(waypoints, function(waypoint) { + return waypoint.element; + }); + } + }; + $[wps] = function() { + var args, method; + + method = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : []; + if (jQMethods[method]) { + return jQMethods[method].apply(null, args); + } else { + return jQMethods.aggregate.call(null, method); + } + }; + $[wps].settings = { + resizeThrottle: 100, + scrollThrottle: 30 + }; + return $w.load(function() { + return $[wps]('refresh'); + }); + }); + +}).call(this); +\ No newline at end of file diff --git a/src/main/javascript/libs/jquery.waypoints.sticky.js b/src/main/javascript/libs/jquery.waypoints.sticky.js @@ -0,0 +1,55 @@ +// Generated by CoffeeScript 1.6.2 +/* +Sticky Elements Shortcut for jQuery Waypoints - v2.0.2 +Copyright (c) 2011-2013 Caleb Troughton +Dual licensed under the MIT license and GPL license. +https://github.com/imakewebthings/jquery-waypoints/blob/master/licenses.txt +*/ + + +(function() { + (function(root, factory) { + if (typeof define === 'function' && define.amd) { + return define(['jquery', 'waypoints'], factory); + } else { + return factory(root.jQuery); + } + })(this, function($) { + var defaults, wrap; + + defaults = { + wrapper: '<div class="sticky-wrapper" />', + stuckClass: 'stuck' + }; + wrap = function($elements, options) { + $elements.wrap(options.wrapper); + return $elements.parent(); + }; + $.waypoints('extendFn', 'sticky', function(opt) { + var $wrap, options, originalHandler; + + options = $.extend({}, $.fn.waypoint.defaults, defaults, opt); + $wrap = wrap(this, options); + originalHandler = options.handler; + options.handler = function(direction) { + var $sticky, shouldBeStuck; + + $sticky = $(this).children(':first'); + shouldBeStuck = direction === 'down' || direction === 'right'; + $sticky.toggleClass(options.stuckClass, shouldBeStuck); + $wrap.height(shouldBeStuck ? $sticky.outerHeight() : ''); + if (originalHandler != null) { + return originalHandler.call(this, direction); + } + }; + $wrap.waypoint(options); + return this.data('stuckClass', options.stuckClass); + }); + return $.waypoints('extendFn', 'unsticky', function() { + this.parent().waypoint('destroy'); + this.unwrap(); + return this.removeClass(this.data('stuckClass')); + }); + }); + +}).call(this); +\ No newline at end of file diff --git a/src/main/javascript/script.jsm b/src/main/javascript/script.jsm @@ -1,4 +1,4 @@ -# je +# libs/jquery-1.9.1.min.js # eltimn @@ -15,7 +15,6 @@ libs/bootstrap/bootstrap-tab.js libs/bootstrap/bootstrap-tooltip.js libs/bootstrap/bootstrap-popover.js libs/bootstrap/bootstrap-collapse.js -libs/bootstrap/bootstrap-affix.js # jquery plugins libs/jquery.ui.widget.js @@ -24,6 +23,10 @@ libs/jquery.fileupload.js libs/jquery.timeago.js libs/jquery.tinyscrollbar.js libs/jquery.keynav.js +libs/jquery.waypoints.js +libs/jquery.waypoints.sticky.js + +libs/colorwheel.js # customs customs/fileupload.js diff --git a/src/main/less/styles.less b/src/main/less/styles.less @@ -91,35 +91,275 @@ body { ***************************************************/ + /* READER + ---------------------------------------------------*/ + + .reader-nav { + background-color: @white; + top: 90px; + + & > div, & > ul { margin: 15px 0; } + + #reader-categories { + height: 200px; + overflow: hidden; + + .nav { padding-left: 7px; } + ul > li { + line-height: 30px; + a { + padding-left: 10px; + } + } + } + + #reader-writers { + display: block; + + & > li { + img { width: 150px; height: 150px;} + #writer-name { margin: 0 auto; } + a:hover, a:focus { background-color: transparent; } + } + + } + + } + + .reader-span-spacing { + padding: 10px; + h5 { margin: 0; } + .nav { margin-bottom: 0; } + } + + #reader-ad-nav { + .nav { margin-top: 30px; } + + & > ul > li > { + margin: 15px 0; + + img { width: 80px; height: 80px;} + a { + width:100px; + margin: 0 auto; + font-size: 85%; + line-height: 150%; + &:hover, &:focus { background-color: transparent; } + } + } + } + + #reader-nav, #reader-ad-nav, .reader-nav-blog.stuck { + position:fixed; + width: 195px; + } + + #reader-nav, .reader-nav-blog.stuck { top: @navbarHeight; } + + .boxed-articles-span { + min-width: @boxedArticlesWidth; + + .article { + .header { display: none; } + } + + } + + + @media (min-width: 980px) and (max-width: 1199px) { + .boxed-articles-span { min-width: @boxedArticlesWidth * 0.98; } + #reader-nav, .reader-nav-blog.stuck { width: 157px; } + .reader-nav { + #reader-writers > li { + img { width: 135px; height: 135px; } + a { width: 110px; font-size: 90%; line-height: 170%; } + } + } + #reader-ad-nav { display: none; } + } + @media (min-width: 768px) and (max-width: 979px) { + .boxed-articles-span { min-width: @boxedArticlesWidth * 0.755; } + #reader-ad-nav { display: none; } + + #reader-nav, .reader-nav-blog.stuck { width: 121px; } + .reader-nav { + #reader-writers > li { + img { width: 100px; height: 100px;} + a { width: 95px; font-size: 85%; line-height: 150%; } + } + } + } + @media (max-width: 767px) { + .boxed-articles-span { + min-width: 0; + .article .header { display: block; } + } + #reader-ad-nav { display: none; } + + #boxed-articles-wrapper { width: 100% } + #reader-nav-wrapper { width: 100% } + #reader-nav, .reader-nav-blog.stuck { + position:static; + width:auto; + } + .reader-nav { + display: none; + #reader-writers { display: none; } + } + } + /* INDEX - -----------------------------------------------------*/ + ----------------------------------------------------*/ + + .profile-img-size { + width: 300px; + height: 300px; + } .readBlue { color: @readBlue; font-family: @actaFontFamily; + font-weight: normal; } .readOrange { color: @readOrange; font-family: @actaFontFamily; + font-weight: normal; } .marketing { - margin-bottom: 100px; padding: 20px; text-align: center; ul.inline > li { padding: 0 30px } - .logo { margin: 50px 0 } + .logo { margin: 5px 0 } + + #marketing-main-wrapper { + background-color:white; + border-radius: 500px; + padding:5px; + + #marketing-main { + border-radius: 500px; + padding:20px; + border: 2px solid @readBlue; + #marketing-main-inner { + margin: 50px 100px; + } + } + } + } + + #top-index-bg { + position:absolute; + left:0; + top:0; + z-index:-100; + width:100%; + height: 400px; + + background: rgb(30,118,155); /* Old browsers */ + background: -moz-radial-gradient(center, ellipse cover, rgba(30,118,155,1) 0%, rgba(91,193,233,1) 60%); + background: -webkit-gradient(radial, center center, 0px, center center, 100%, color-stop(0%,rgba(30,118,155,1)), color-stop(60%,rgba(91,193,233,1))); /* Chrome,Safari4+ */ + background: -webkit-radial-gradient(center, ellipse cover, rgba(30,118,155,1) 0%,rgba(91,193,233,1) 60%); /* Chrome10+,Safari5.1+ */ + background: -o-radial-gradient(center, ellipse cover, rgba(30,118,155,1) 0%,rgba(91,193,233,1) 60%); /* Opera 12+ */ + background: -ms-radial-gradient(center, ellipse cover, rgba(30,118,155,1) 0%,rgba(91,193,233,1) 60%); /* IE10+ */ + background: radial-gradient(ellipse at center, rgba(30,118,155,1) 0%,rgba(91,193,233,1) 60%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1e769b', endColorstr='#5bc1e9',GradientType=1 ); /* IE6-9 fallback on horizontal gradient */ + } @media (min-width: 768px) { .marketing { font-size: @marketingFontSize; line-height: @marketingLineHeight; - .small { font-size: @marketingFontSize * 0.6 } + .small { font-size: @marketingFontSize * 0.5 } + #marketing-main-inner { + margin: 50px 0; + } } } + .features { + padding: 10px; + font-size: @baseFontSize * 1.3; + line-height: @baseLineHeight * 1.3; + background-color: @white; + + [class*="span"].content { + height: 350px; + font-weight: bold; + margin: 0; + div.text { padding: 20px; } + } + + h2 { + padding: 20px; + padding-left: 80px; + color: @white; + margin-bottom: 50px; + border-radius: 3px; + } + } + + .row-fluid .offset0 { + margin-left: 0 + } + .row-fluid .span12.offset0 { + width: 99.99999999999999%; + *width: 99.93055555555554%; + } + .row-fluid .span11.offset0 { + width: 91.66666666666666%; + *width: 91.59722222222221%; + } + .row-fluid .span10.offset0 { + width: 83.33333333333331%; + *width: 83.26388888888887%; + } + .row-fluid .span9.offset0 { + width: 74.99999999999999%; + *width: 74.93055555555554%; + } + .row-fluid .span8.offset0 { + width: 66.66666666666666%; + *width: 66.59722222222221%; + } + .row-fluid .span7.offset0 { + width: 58.33333333333333%; + *width: 58.263888888888886%; + } + .row-fluid .span6.offset0 { + width: 49.99999999999999%; + *width: 49.93055555555555%; + } + .row-fluid .span5.offset0 { + width: 41.66666666666666%; + *width: 41.597222222222214%; + } + .row-fluid .span4.offset0 { + width: 33.33333333333333%; + *width: 33.263888888888886%; + } + .row-fluid .span3.offset0 { + width: 24.999999999999996%; + *width: 24.930555555555554%; + } + .row-fluid .span2.offset0 { + width: 16.666666666666664%; + *width: 16.59722222222222%; + } + .row-fluid .span1.offset0 { + width: 8.333333333333332%; + *width: 8.263888888888888%; + } + + @media (max-width: 768px) { + .row-fluid [class*="offset"] { + width: auto !important; + } + } + #email-signup { text-align: center; padding: 20px; @@ -139,9 +379,8 @@ body { div.b-bottom { height: @sideBorderThickness } } @media (max-width: 767px) { - @sideBorderThickness: 4px; - div.b-left, div.b-right { width: @sideBorderThickness } - div.b-bottom { height: @sideBorderThickness } + div.b-left, div.b-right, div.b-bottom, div.b-top { display: none } + .navbar-fixed-top { top: 0 } } #border { @@ -193,7 +432,6 @@ body { .navbar-fixed-top { top: 4px } } - /* BLOG PROFILE -------------------------------------------------------*/ @writersListImgSize: 200px; @@ -203,7 +441,8 @@ body { @padding: 30px; width: @boxedArticlesWidth - (@padding * 2); height: @writersListHeight; - background-color: @bodyBackground; + background-color: @white; + .border-radius(0 0 20px 20px); position: absolute; padding: 0 @padding; .header { @@ -222,13 +461,21 @@ body { border-radius:5px; } } - #blog-url { font-size: 16px; line-height: 34px; } + div#blog-url { font-size: 16px; line-height: 34px; margin-top: 20px; } + #blog-writers { + background-color: @white; + border-radius: 0px 0px 20px 20px; + } @media (min-width: 450px) { #blog-writers { .viewport { height: @writersListHeight; } .blog-writer img { width: @writersListImgSize; height: @writersListImgSize; } + .blog-writer .name { + line-height: @baseLineHeight * 0.8; + width: @writersListImgSize; + } } } @media (max-width: 449px) { @@ -236,11 +483,14 @@ body { margin-bottom: 30px; .viewport { height: @writersListHeight * 0.5; } .blog-writer img { width: @writersListImgSize * 0.5; height: @writersListImgSize * 0.5; } - .blog-writer .name { font-size: 80% } + .blog-writer .name { + font-size: 80%; + max-width: @writersListImgSize * 0.5 + } } } - #blog-writers { + #blog-writers, #reader-categories { a { cursor: pointer } .blog-writer { float: left; @@ -266,7 +516,7 @@ body { .scrollbar { position: relative; clear: both; - height: 10px; + height: 3px; .track { width: 100%; @@ -275,7 +525,7 @@ body { .thumb { background-color: @grayLight; - height: 10px; + height: 3px; cursor: pointer; overflow: hidden; position: absolute; @@ -285,7 +535,7 @@ body { .end { overflow: hidden; height: 25px; - width: 5px; + width: 3px; } } } @@ -293,16 +543,53 @@ body { } + #colorpicker-wrapper { + + .colorpicker { + background-color: transparent; + color: black; + width: auto; + height: 350px; + } + + #picker { + cursor: crosshair; + float: left; + margin: 10px; + border: 0; + } + + .preview { + .border-radius(@inputBorderRadius); + border: 2px solid #000; + cursor: pointer; + height: @inputHeight - 4px; + width: @inputHeight - 4px; + float: left; + } + + #hexVal { margin-left: 10px; } + } + /* USER PROFILE ------------------------------------------------------*/ + #img-profile { + .border-radius(20px); + margin: 20px; + border: 3px solid @white; + } + #profile { - background-color: @bodyBackground; + #profile-inner { - padding: 0 30px 30px 30px; + margin: 20px 100px; + background-color: @white; + border-radius: 20px; + padding-bottom: 50px; } - #profile-img img { .border-radius(0 0 5px 5px); } + } #bg { @@ -363,32 +650,55 @@ body { } } + @media (max-width: 767px) { + #profile #profile-inner { margin: 0 auto; border-radius:0; } + } - @media (min-width: 450px) { + @media (min-width: 635px) { #user-name, #blog-name { font-size: 70px; + padding-left: 70px; + padding-right: 70px; line-height: 80px; input[type="text"] { font-size: 70px; line-height: 70px; + width: @boxedArticlesWidth * 0.5; } } } - @media (max-width: 449px) { + + @media (min-width: 480px) and (max-width: 634px) { + #user-name, #blog-name { + font-size: 70px * 0.8; + line-height: 80px * 0.8; + padding-left: 70px * 0.8; + padding-right: 70px * 0.8; + input[type="text"] { + font-size: 70px * 0.8; + line-height: 70px * 0.8; + width: @boxedArticlesWidth * 0.4; + } + } + } + + @media (max-width: 479px) { #user-name, #blog-name { font-size: 70px * 0.6; line-height: 80px * 0.6; + padding-left: 70px * 0.3; + padding-right: 70px * 0.3; input[type="text"] { font-size: 70px * 0.6; line-height: 70px * 0.6; - width: 300px; + width: @boxedArticlesWidth * 0.3; } } } div#user-about, div#blog-description { overflow: hidden; - padding: 0 100px; + padding: 0 80px; background: transparent; padding-top:0; padding-bottom:0; @@ -400,6 +710,8 @@ body { color: @textColor; } img { + width: 100px; + height: 100px; margin-bottom: 5px; } } @@ -411,24 +723,24 @@ body { @articlePadding: 100px; @articleTitleFont: 35px; @media (min-width: 980px) { - .boxed-articles .article, div#user-about, div#blog-description { padding-left: @articlePadding; padding-right: @articlePadding; } + .boxed-articles .article-inner, div#user-about, div#blog-description { padding-left: @articlePadding; padding-right: @articlePadding; } } @media (min-width: 768px) and (max-width: 979px) { - .boxed-articles .article, div#user-about, div#blog-description { padding-left: @articlePadding * 0.80; padding-right: @articlePadding * 0.80; } + .boxed-articles .article-inner, div#user-about, div#blog-description { padding-left: @articlePadding * 0.80; padding-right: @articlePadding * 0.80; } } @media (min-width: 450px) and (max-width: 767px) { - .boxed-articles .article, div#user-about, div#blog-description { padding-left: @articlePadding * 0.30; padding-right: @articlePadding * 0.30; } + .boxed-articles .article-inner, div#user-about, div#blog-description { padding-left: @articlePadding * 0.30; padding-right: @articlePadding * 0.30; } } @media (max-width: 449px) { - .boxed-articles .article, div#user-about, div#blog-description { padding-left: @articlePadding * 0.10; padding-right: @articlePadding * 0.10; } + .boxed-articles .article-inner, div#user-about, div#blog-description { padding-left: @articlePadding * 0.10; padding-right: @articlePadding * 0.10; } } - @media (min-width: 700px) { + @media (min-width: 980px) { .article iframe { width: @boxedArticlesWidth - (@articlePadding * 2); height: (@boxedArticlesWidth - (@articlePadding * 2)) * 0.625; } } - @media (max-width: 699px) { + @media (max-width: 979px) { .article iframe { width: (@boxedArticlesWidth - (@articlePadding * 2)) * 0.36; height: ((@boxedArticlesWidth - (@articlePadding * 2)) * 0.625) * 0.36; @@ -478,32 +790,41 @@ body { margin-right: auto; .article { - padding-top: 50px; - padding-bottom: 50px; margin-bottom: 70px; background-color: @white; + .article-inner { + padding-top: 25px; + padding-bottom: 25px; + } + .header { - padding-bottom: 3px; - border-bottom: 3px solid @readBlue; + padding-bottom: @lineHeightArticle * 16px; // line height is em .writer-name { font-size: 130%; vertical-align: bottom; margin-left: 10px; } - time { - color: @grayLight; - font-size: @fontSizeSmall; - } + img { width: 100px; height: 100px; } } + + .sub-header { + color: @grayLight; + font-size: @fontSizeSmall; + padding-top: @lineHeightArticle * 8px; // line height is em + padding-bottom: @lineHeightArticle * 8px; // line height is em + * { + margin-left: 15px; + float:right; + } + } .title { text-align: center; - padding-bottom: @lineHeightArticle * 16px; // line height is em a { text-decoration: none; @@ -547,6 +868,10 @@ body { padding: 20px 0; } + .writer .categories { + color: darken(@readBlue, 30%); + } + // golden frames for writers img{ &.writer { @@ -561,6 +886,14 @@ body { font-size: 150%; margin: 10px 0; } + img { + width: 150px; + height: 150px; + } + } + + @media (max-width: 768px) { + #writers-container { text-align: center; } } @@ -593,7 +926,7 @@ body { label { display: inline-block; cursor: pointer; - min-width:150px; + width:150px; padding: 20px; text-align: center; img { @@ -605,6 +938,21 @@ body { .selected { background-color: #ccc; } } + #backgrounds-list { + label { + display: inline-block; + cursor: pointer; + padding: 20px; + text-align: center; + img { + width: 150px; + height: 100px; + } + input[type="radio"] { display: none; } + } + .selected { background-color: #ccc; } + } + /* GALLERY OF WRITERS ----------------------------------------------------*/ @@ -690,9 +1038,11 @@ body { /* MY FIXES - -----------------------------------------------------------*/ + ----------------------------------------------------------*/ + + .btn:hover, .btn:focus { color: @white; } - #features, #myblog, #writerswelove { + #myblog, #writerswelove { margin-bottom: @navbarHeight + @emailSignupHeight + 5px; } @@ -767,9 +1117,19 @@ body { &, &:hover, &:focus { color: @textColor; text-decoration: none; + + .btn { color: @white; } } } + .nolink-decoration { + &, &:hover, &:focus { + color: @textColor; + text-decoration: none; + .btn { color: @white; } + } + } + /****************** BELOW IS BY ELTIMN diff --git a/src/main/less/variables.less b/src/main/less/variables.less @@ -227,8 +227,8 @@ // Marketing // ------------------------- -@marketingFontSize: 55px; -@marketingLineHeight: 70px; +@marketingFontSize: 50px; +@marketingLineHeight: 55px; // Pagination @@ -304,6 +304,12 @@ @gridRowWidth768: (@gridColumns * @gridColumnWidth768) + (@gridGutterWidth768 * (@gridColumns - 1)); +@gridColumnWidth768NoGutter: 62px; +@gridRowWidth768NoGutter: (@gridColumns * @gridColumnWidth768); +@gridColumnWidth1200NoGutter: 100px; +@gridRowWidth1200NoGutter: (@gridColumns * @gridColumnWidth1200); + + // Fluid grid // ------------------------- @fluidGridColumnWidth: percentage(@gridColumnWidth/@gridRowWidth); diff --git a/src/main/resources/props/production.default.props b/src/main/resources/props/production.default.props @@ -1,3 +1,24 @@ -mail.smtp.host=smtp.gmail.com +# AWS Mail +mail.smtp.host=email-smtp.us-east-1.amazonaws.com mail.smtp.user= mail.smtp.pass= +mail.smtp.port=587 +mail.smtp.auth=true + +# AWS S3 +aws.access.key= +aws.secret.access.key= +aws.s3.bucket= + +# Wordpress Oauth +wordpress.baseurl= +wordpress.key= +wordpress.secret= + +# Mongo DB +mongo.default.url= +mongo.default.user= +mongo.default.pwd= + +# Google Analytics +google.analytics.id= +\ No newline at end of file diff --git a/src/main/resources/rome.properties b/src/main/resources/rome.properties @@ -0,0 +1,6 @@ +# rome.properties +atom_1.0.person.ModuleParser.classes=com.joereader.lib.rss.gdata.io.GDataModuleParser +atom_1.0.person.ModuleGenerator.classes=com.joereader.lib.rss.gdata.io.GDataModuleGenerator + +atom_0.3.feed.ModuleParser.classes=com.joereader.lib.rss.gdata.io.GDataModuleParser +atom_0.3.feed.ModuleGenerator.classes=com.joereader.lib.rss.gdata.io.GDataModuleGenerator +\ No newline at end of file diff --git a/src/main/scala/bootstrap/liftweb/Boot.scala b/src/main/scala/bootstrap/liftweb/Boot.scala @@ -23,7 +23,7 @@ import net.liftweb.http.js.jquery.JqJsCmds.FadeIn * to modify lift's environment */ class Boot extends Loggable { - def boot { + def boot() { logger.info("Run Mode: "+Props.mode.toString) // init mongodb @@ -43,11 +43,14 @@ class Boot extends Loggable { Gravatar.defaultImage.default.set("jcabrra.files.wordpress.com/2013/06/mr_noman.jpg") // config an email sender - SmtpMailer.init + SmtpMailer.init() // where to search snippet LiftRules.addToPackages("com.joereader") + // Google analytics + if(Props.productionMode) GoogleAnalytics.init + // set the default htmlProperties LiftRules.htmlProperties.default.set((r: Req) => new Html5Properties(r.userAgent)) @@ -57,12 +60,12 @@ class Boot extends Loggable { LiftRules.setSiteMap(Site.siteMap) // Error handler - ErrorHandler.init + ErrorHandler.init() // 404 handler LiftRules.uriNotFound.prepend(NamedPF("404handler") { case (req, failure) => - NotFoundAsTemplate(ParsePath(List("404"), "html", false, false)) + NotFoundAsTemplate(ParsePath(List("404"), "html", absolute = false, endSlash = false)) }) // Show the spinny image when an Ajax call starts @@ -89,5 +92,6 @@ class Boot extends Loggable { // API LiftRules.dispatch.append(com.joereader.lib.ImageUpload) + LiftRules.statelessDispatch.append(com.joereader.snippet.Sitemap) } } diff --git a/src/main/scala/com/joereader/config/ErrorHandler.scala b/src/main/scala/com/joereader/config/ErrorHandler.scala @@ -10,14 +10,20 @@ import util.Props object ErrorHandler extends Factory with Loggable { // config - val errorUrl = new FactoryMaker[String]("/error") {} // where to send the user when an error occurs + val errorUrl = new FactoryMaker[String](Site.error.url) {} def init(): Unit = { LiftRules.exceptionHandler.prepend { case (Props.RunModes.Development, r, e) => logException(r, e) XhtmlResponse( - (<html><body>Exception occured while processing {r.uri}<pre>{showException(e)}</pre></body></html>), + <html> + <body>Exception occured while processing + {r.uri}<pre> + {showException(e)} + </pre> + </body> + </html>, S.htmlProperties.docType, List("Content-Type" -> "text/html; charset=utf-8"), Nil, @@ -45,14 +51,14 @@ object ErrorHandler extends Factory with Loggable { } /** - * A utility method to convert an exception to a string of stack traces - * @param le the exception - * - * @return the stack trace - */ + * A utility method to convert an exception to a string of stack traces + * @param le the exception + * + * @return the stack trace + */ def showException(le: Throwable): String = { val ret = "Message: " + le.toString + "\n\t" + - le.getStackTrace.map(_.toString).mkString("\n\t") + "\n" + le.getStackTrace.map(_.toString).mkString("\n\t") + "\n" val also = le.getCause match { case null => "" diff --git a/src/main/scala/com/joereader/config/GoogleAnalytics.scala b/src/main/scala/com/joereader/config/GoogleAnalytics.scala @@ -0,0 +1,26 @@ +package com.joereader.config + +import scala.xml.Unparsed +import net.liftweb.util.Props +import net.liftweb.http._ + +object GoogleAnalytics { + + def init: Unit = Props.get("google.analytics.id") map headJs foreach { js => + def addTracking(s: LiftSession, r: Req): Unit = S.putInHead(js) + LiftSession.onBeginServicing = addTracking _ :: LiftSession.onBeginServicing + } + + def headJs(id: String) = <script type="text/javascript"> + var _gaq = _gaq || []; + _gaq.push(['_setAccount', '{Unparsed(id)}']); + _gaq.push(['_trackPageview']); + (function() {{ + var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; + ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; + var s = document.getElementsByTagName('script')[0]; + s.parentNode.insertBefore(ga, s); + }})(); + </script> + +} diff --git a/src/main/scala/com/joereader/config/MongoConfig.scala b/src/main/scala/com/joereader/config/MongoConfig.scala @@ -3,7 +3,6 @@ package config import net.liftweb._ import common._ -import json._ import mongodb._ import util.Props @@ -13,20 +12,21 @@ object MongoConfig extends Loggable { def init() { /** - * First checks for existence of mongo.default.url. If not found, then - * checks for mongo.default.host, port, and name. Uses defaults if those - * are not found. - */ + * First checks for existence of mongo.default.url. If not found, then + * checks for mongo.default.host, port, and name. Uses defaults if those + * are not found. + */ val defaultDbAddress = Props.get("mongo.default.url") .map(url => new DBAddress(url)) .openOr(new DBAddress( - Props.get("mongo.default.host", "127.0.0.1"), - Props.getInt("mongo.default.port", 27017), - Props.get("mongo.default.name", "joe-reader") - )) + Props.get("mongo.default.host", "127.0.0.1"), + Props.getInt("mongo.default.port", 27017), + Props.get("mongo.default.name", "joe-reader") + )) /* - * If mongo.default.user, and pwd are defined, configure Mongo using authentication. + * If mongo.default.user, and pwd are defined, + * configure Mongo using authentication. */ (Props.get("mongo.default.user"), Props.get("mongo.default.pwd")) match { case (Full(user), Full(pwd)) => @@ -37,13 +37,17 @@ object MongoConfig extends Loggable { user, pwd ) - logger.info("MongoDB inited using authentication: %s".format(defaultDbAddress.toString)) + + logger.info("MongoDB inited using authentication: %s". + format(defaultDbAddress.toString)) + case _ => MongoDB.defineDb( DefaultMongoIdentifier, new Mongo(defaultDbAddress), defaultDbAddress.getDBName ) + logger.info("MongoDB inited: %s".format(defaultDbAddress.toString)) } } diff --git a/src/main/scala/com/joereader/config/Site.scala b/src/main/scala/com/joereader/config/Site.scala @@ -5,75 +5,50 @@ import model._ import lib._ import net.liftweb._ -import net.liftweb.http.{Templates, S} -import sitemap._ -import sitemap.Loc._ +import http._ +import sitemap._, Loc._ +import common._ import net.liftmodules.mongoauth.Locs -import net.liftweb.common.{Box, Full} object MenuGroups { val SettingsGroup = LocGroup("settings") val TopBarGroup = LocGroup("topbar") + val SitemapGroup = LocGroup("sitemap") } -/* - * Wrapper for Menu locations - */ +/* Wrapper for Menu locations */ case class MenuLoc(menu: Menu) { - lazy val url: String = S.contextPath+menu.loc.calcDefaultHref - lazy val fullUrl: String = S.hostAndPath+menu.loc.calcDefaultHref + lazy val url: String = S.contextPath + menu.loc.calcDefaultHref + lazy val fullUrl: String = S.hostAndPath + menu.loc.calcDefaultHref } -case class ParamInfo(theParam: String) object Site extends Locs { + import MenuGroups._ + // others + val reader = MenuLoc(Menu.i("Reader") / "reader" >> RequireLoggedIn) + val searchCategories = MenuLoc(Menu.i("Search Categories") / "search" / "categories" >> RequireLoggedIn) + + // tmp: should be a menu param that gets a category + val searchWriters = MenuLoc(Menu.i("Search Writers") / "search" / "writers" >> RequireLoggedIn) + + // top level val notFound = MenuLoc(Menu.i("404") / "404" >> Hidden) val error = MenuLoc(Menu.i("Error") / "error" >> Hidden) + val home = MenuLoc(Menu.i("Home") / "index" >> TopBarGroup >> Loc.EarlyResponse( + () => Full(RedirectResponse(reader.url)).filter(ignore => User.isLoggedIn))) - // locations (menu entries) - val home = MenuLoc(Menu.i("Home") / "index" >> TopBarGroup) - val purpose = MenuLoc(Menu.i("Purpose") / "purpose" >> TopBarGroup) + val myBlog = MenuLoc(Menu.i("Blog") / "blog" / "readmeans" >> TopBarGroup >> SitemapGroup) val login = MenuLoc(Menu.i("Login") / "login" >> RequireNotLoggedIn) val loginToken = MenuLoc(buildLoginTokenMenu) val inviteToken = MenuLoc(buildInviteTokenMenu) val logout = MenuLoc(buildLogoutMenu) - val wordpressSignIn = MenuLoc(Wordpress.buildWordpressSignInMenu) val wordpressCallback = MenuLoc(Wordpress.buildWordpressCallbackMenu) - private val userProfileParamMenu = Menu.param[User]("User", "User Profile", - User.findByUsername, _.username.is) / * >> TemplateBox(() => Templates("user" :: Nil)) - lazy val userProfileLoc = userProfileParamMenu.toLoc - - private val userPreviewParamMenu = Menu.param[User]("User Preview", "User Preview", - User.findByUsername, _.username.is) / * / "preview" >> TemplateBox(() => Templates("preview" :: Nil)) - lazy val userPreviewLoc = userPreviewParamMenu.toLoc - - private val userFollowingParamMenu = Menu.param[User]("User Following", "User Following", - User.findByUsername, _.username.is) / * / "following" >> TemplateBox(() => Templates("following" :: Nil)) - lazy val userFollowingLoc = userFollowingParamMenu.toLoc - - private val blogProfileParamMenu = Menu.param[Blog]("Blog", "Blog Profile", - Blog.findByBlogName, _.blogname.is) / "blog" - lazy val blogProfileLoc = blogProfileParamMenu.toLoc - - - case class BlogWriterContent(blog: Blog, writer: BlogWriter) - object BlogWriterContent { - def parse(p: List[String]): Box[BlogWriterContent] = - for{ - blog <- Blog.findByBlogName(p(0)) - blogWriter <- blog.writer(p(1).replace('+',' ')) - } yield BlogWriterContent(blog, blogWriter) - - def encode(bw: BlogWriterContent): List[String] = bw.blog.blogname.get :: bw.writer.name.get :: Nil - } - private val blogWriterProfileParamMenu = Menu.params[BlogWriterContent]("Blog Writer", "Blog Writer", - BlogWriterContent.parse, BlogWriterContent.encode) / "blog" / * / * >> TemplateBox(() => Templates("blogwriter" :: Nil)) - lazy val blogWriterProfileLoc = blogWriterProfileParamMenu.toLoc - + // sign up wizard val signUp1 = MenuLoc(Menu.i("Sign Up ⋅ Writer") / "signup" / "writer" >> RequireNotLoggedIn) val signUp2 = MenuLoc(Menu.i("Sign Up ⋅ Verify") / "signup" / "verify" >> RequireNotLoggedIn) val signUp3 = MenuLoc(Menu.i("Sign Up ⋅ Categories") / "signup" / "categories" >> RequireLoggedIn) @@ -81,23 +56,78 @@ object Site extends Locs { val signUp5 = MenuLoc(Menu.i("Sign Up ⋅ User") / "signup" / "user" >> RequireLoggedIn) val signUp6 = MenuLoc(Menu.i("Sign Up ⋅ Password") / "signup" / "password" >> RequireLoggedIn) - val reader = MenuLoc(Menu.i("Reader") / "reader" >> RequireLoggedIn) + // help pages + val passwordRecovery = MenuLoc(Menu.i("Password Recovery") / "help" / "recovery" >> RequireNotLoggedIn) + val bloggerHelp = MenuLoc(Menu.i("Help Verify Blogger") / "help" / "verify" / "blogger") + val tumblrHelp = MenuLoc(Menu.i("Help Verify Tumblr") / "help" / "verify" / "tumblr") + // settings val password = MenuLoc(Menu.i("Password Reset") / "settings" / "password" >> RequireLoggedIn >> Hidden) val editAccount = MenuLoc(Menu.i("Account") / "settings" / "account" >> SettingsGroup >> RequireLoggedIn) val editBlogs = MenuLoc(Menu.i("Blogs") / "settings" / "blogs" >> SettingsGroup >> RequireLoggedIn) val editFollowing = MenuLoc(Menu.i("Following") / "settings" / "following" >> SettingsGroup >> RequireLoggedIn) val blogVerify = MenuLoc(Menu.i("Blog Verification") / "settings" / "verify" >> RequireLoggedIn) - private val categoriesParamMenu = Menu.param[Blog]("Categories", "Categories", - Blog.findByBlogName, _.blogname.is) / "settings" / "blog" / * / "categorize" >> TemplateBox(() => Templates("settings" :: "categories" :: Nil)) >> RequireLoggedIn + // PARAM MENUS + + /* /{user-name} */ + private val userProfileParamMenu = Menu.param[User]( + "User", "User Profile", + User.findByUsername, _.username.get) / * >> + TemplateBox(() => Templates("user" :: Nil)) + + lazy val userProfileLoc = userProfileParamMenu.toLoc + + /* /{user-name}/preview */ + private val userPreviewParamMenu = Menu.param[User]( + "User Preview", "User Preview", + User.findByUsername, _.username.get) / * / "preview" >> + TemplateBox(() => Templates("preview" :: Nil)) + + lazy val userPreviewLoc = userPreviewParamMenu.toLoc + + /* /{user-name}/following */ + private val userFollowingParamMenu = Menu.param[User]( + "User Following", "User Following", + User.findByUsername, _.username.get) / * / "following" >> + TemplateBox(() => Templates("following" :: Nil)) + + lazy val userFollowingLoc = userFollowingParamMenu.toLoc + + /* /blog/{blog-name} */ + private val blogProfileParamMenu = Menu.param[Blog]( + "Blog Profile", "Blog Profile", + Blog.findByBlogName, _.blogname.get) / "blog" + + lazy val blogProfileLoc = blogProfileParamMenu.toLoc + + /* /blog/{blog-name}/{blog-writer-name} */ + private val blogWriterProfileParamMenu = Menu.params[BlogWriterUser]( + "Blog Writer", "Blog Writer", + BlogWriterUser.parse, BlogWriterUser.encode) / "blog" / * / * >> + TemplateBox(() => Templates("blogwriter" :: Nil)) + + lazy val blogWriterProfileLoc = blogWriterProfileParamMenu.toLoc + + /* /settings/blog/{blog-name} */ + private val blogSettingsParamMenu = Menu.param[Blog]( + "Blog Settings", "Blog Settings", + Blog.findByBlogName, _.blogname.get) / "settings" / "blog" / * >> + TemplateBox(() => Templates("settings" :: "blog" :: Nil)) >> RequireLoggedIn + + lazy val blogSettingsLoc = blogSettingsParamMenu.toLoc + + /* /settings/blog/{blog-name}/categorize */ + private val categoriesParamMenu = Menu.param[Blog]( + "Categories", "Categories", + Blog.findByBlogName, _.blogname.get) / "settings" / "blog" / * / "categorize" >> + TemplateBox(() => Templates("settings" :: "categories" :: Nil)) >> RequireLoggedIn + lazy val categoriesLoc = categoriesParamMenu.toLoc - val passwordRecovery = MenuLoc(Menu.i("Password Recovery") / "help" / "recovery" >> RequireNotLoggedIn) - val bloggerHelp = MenuLoc(Menu.i("Help Verify Blogger") / "help" / "verify" / "blogger") - val tumblrHelp = MenuLoc(Menu.i("Help Verify Tumblr") / "help" / "verify" / "tumblr") private def menus = List( + blogSettingsParamMenu, userPreviewParamMenu, userFollowingParamMenu, blogWriterProfileParamMenu, @@ -105,7 +135,7 @@ object Site extends Locs { blogProfileParamMenu, categoriesParamMenu, home.menu, - purpose.menu, + myBlog.menu, login.menu, loginToken.menu, inviteToken.menu, @@ -117,6 +147,8 @@ object Site extends Locs { signUp5.menu, signUp6.menu, reader.menu, + searchCategories.menu, + searchWriters.menu, editAccount.menu, password.menu, editBlogs.menu, @@ -128,20 +160,22 @@ object Site extends Locs { wordpressSignIn.menu, wordpressCallback.menu, error.menu, - notFound.menu, - Menu.i("Throw") / "throw" >> EarlyResponse(() => throw new Exception("This is only a test.")) + notFound.menu ) /* * Return a SiteMap needed for Lift */ - def siteMap: SiteMap = SiteMap(menus:_*) - - val invalidUsernames = siteMap.menus.map{ menu => - val path = menu.loc.calcDefaultHref - if(path.startsWith("/")) path.split("/")(1) - else "" + def siteMap: SiteMap = SiteMap(menus: _*) + + /* Grabs all top-level paths so user-names won't be able to take them. */ + val invalidUsernames = siteMap.menus.map { + menu => + val path = menu.loc.calcDefaultHref + if (path.startsWith("/")) path.split("/")(1) + else "" } + def isAvailableMenu(n: String) = invalidUsernames.forall(_ != n) def buildInviteTokenMenu = Menu(Loc( @@ -150,7 +184,9 @@ object Site extends Locs { )) protected def inviteTokenLocParams = - EarlyResponse(() => User.meta.handleInviteToken) :: Nil + EarlyResponse(() => User.meta.handleInviteToken()) :: Nil + + def isMarketingPage(path: String): Boolean = + List(home.url, notFound.url, "/").exists(path matches) - def isMarketingPage(path: String): Boolean = List(home.url, purpose.url, notFound.url, "/").exists(path matches _) } diff --git a/src/main/scala/com/joereader/config/SmtpMailer.scala b/src/main/scala/com/joereader/config/SmtpMailer.scala @@ -45,7 +45,7 @@ object SmtpMailer extends Loggable { logger.info("Smtp password length: %s".format(password.length)) Mailer.authenticator = Full(new Authenticator() { override def getPasswordAuthentication = new - PasswordAuthentication(username, password) + PasswordAuthentication(username, password) }) logger.info("SmtpMailer inited") case _ => logger.error("Username/password not supplied for Mailer.") @@ -64,7 +64,7 @@ object SmtpMailer extends Loggable { |%s |==================================== |%s - """.format(buf.toString, m.getContent.toString).stripMargin + """.format(buf.toString(), m.getContent.toString).stripMargin out } diff --git a/src/main/scala/com/joereader/lib/EmailMsgs.scala b/src/main/scala/com/joereader/lib/EmailMsgs.scala @@ -1,9 +0,0 @@ -package com.joereader.lib - - - -object EmailMsgs { - - - -} diff --git a/src/main/scala/com/joereader/lib/Event.scala b/src/main/scala/com/joereader/lib/Event.scala @@ -1,46 +0,0 @@ -package com.joereader.lib - -import akka.event.ActorEventBus -import akka.event.LookupClassification -import akka.actor.ActorSystem -import akka.actor.Props -import akka.actor.Actor -import java.util.Date -import java.util.UUID - - -class Message(val id:String,val timestamp: Long) - -case class PostMessage( override val id:String=UUID.randomUUID().toString(), - override val timestamp: Long=new Date().getTime(), - text:String)extends Message(id, timestamp) - -case class MessageEvent(val channel:String, val message:Message) - -class AppActorEventBus extends ActorEventBus with LookupClassification{ - type Event = MessageEvent - type Classifier=String - protected def mapSize(): Int={ - 10 - } - - protected def classify(event: Event): Classifier={ - event.channel - } - - protected def publish(event: Event, subscriber: Subscriber): Unit={ - subscriber ! event - } -} - -object TryAkka { - val system = ActorSystem("MySystem") - val appActorEventBus=new AppActorEventBus - val NEW_POST_CHANNEL="/posts/new" - val subscriber = system.actorOf(Props(new Actor { - def receive = { - case d: MessageEvent => println(d) - } - })) - -} -\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/Helper.scala b/src/main/scala/com/joereader/lib/Helper.scala @@ -1,83 +1,33 @@ package com.joereader.lib -import java.util.regex.Pattern - - object Helper { implicit class PatternImplicitStringHelper(str: String) { val Email = """(^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$)""".r + /* Checks if string is an email. */ def isEmail: Boolean = str match { case Email(_) => true case _ => false } + /* Converts all single quotes to double quotes. */ def singleQuoteToDouble: String = { str.toList.map(_.toString).map(ch => - if(ch == "'") "\"" else ch).mkString - } - - } - - case class URLFormatter (in: String) { - val url = new java.net.URL(validate(in)) - - val ext = "htm" :: "html" :: "php" :: "php3" :: "jsp" :: "asp" :: "phtml" :: - "shtm" :: "shtml" :: "cgi" :: "cfm" :: "cfml" :: Nil - - def urlWithoutQueryParams(p: String*): String = { - val sep = in.split("\\?") - if(sep.size < 2) this.toString - else { - val params = sep.last.split("&") - this.toString + "?" + - params.map{ param => - val key = param.split("=").head - if(p.exists(_ == key)) "" - else param - }.filterNot(_ == "").mkString("&") - } - } - - def removeFileExtension(p: String) = { - val arr = p.split("""\.""") - val last = arr.lastOption.getOrElse("") - if(ext.exists(_ == last)) arr.slice(0,arr.length-1).mkString(".") else p + if (ch == "'") "\"" else ch).mkString } - /** - * If host address starts with "www." remove it. - */ - def removeWWW(h: String) = { - if(h.slice(0,4) == "www.") h.slice(4,h.length) else h - } - - def escape(s: String): String = { + /* Escapes characters in the string to make it "regex safe" */ + def escape: String = { val meta_regex = ".[]\\^$|?*+(){}" - s.toList. + str.toList. map(_.toString). - map(ch => if(meta_regex.exists(c=> ch.exists(_==c))) "\\"+ch else ch). + map(ch => + if (meta_regex.exists(c => + ch.exists(_ == c))) "\\" + ch + else ch). mkString } - - def pattern: Pattern = { - val s = removeWWW(url.getHost) + removeFileExtension(getPath) - val escS = escape(s) - Pattern.compile("""(https?://)?(www\.)?(^"""+escS+"""$)(\.("""+ext.mkString("|")+"""))?""", Pattern.CASE_INSENSITIVE) - } - - def getPath = if(url.getPath=="/") "" else url.getPath - - def hostAndPath = url.getHost + getPath - - /** - * Prepends http:// if it doesn't have it. - */ - def validate(str: String): String = - if(str.slice(0,8).matches("https?://.*")) str else "http://"+str - - override def toString = url.getProtocol + "://" + url.getHost + - (if (url.getPort == -1) "" else ":" + url.getPort) + url.getPath } + } diff --git a/src/main/scala/com/joereader/lib/ImageUpload.scala b/src/main/scala/com/joereader/lib/ImageUpload.scala @@ -1,126 +1,140 @@ package com.joereader.lib -import net.liftweb.http.rest._ -import net.liftweb.common._ -import net.liftweb.http._ -import net.liftweb.util._ -import com.joereader.lib.aws._ -import com.joereader.config.S3Config._ -import com.joereader.model._ -import concurrent.ExecutionContext.Implicits.global -import com.joereader.snippet.SnipHelpers.UpdateImg -import com.joereader.snippet.SnipHelpers -import net.liftweb.http.js.JsCmds.Run -import net.liftweb.http.js.JE.{Str, Call} +import net.liftweb._ +import http._, rest._, js.JsCmds.Run +import common._ +import util._ + +import com.joereader._ +import lib.aws._ +import config.S3Config._ +import model._ +import snippet.SnipHelpers.{imgBgId, imgProfileId, UpdateImage} + +import scala.concurrent._, ExecutionContext.Implicits.global +import scala.concurrent.duration._ object ImageUpload extends RestHelper with Logger { val acceptedImages = "image/jpeg" :: "image/png" :: "image/svg+xml" :: Nil - def userUrl(imgType: String = "") = - "/api/user/upload/image"+includeImgType(imgType) + val s3 = new S3(s3_access_key.get, s3_secret_key.get, s3_bucket.get) + + // string params to indicate what kind of image + val background = "bg" + val profile = "pic" - def blogUrl(blog: Blog, imgType: String = "") = - "/api/blog/"+blog.id.is+"/upload/image"+includeImgType(imgType) + def userUrl = + "/api/image/upload/user" + includeImgType(profile) + + def blogUrl(blog: Blog) = + "/api/image/upload/blog/" + blog.id.get + includeImgType(background) def includeImgType(imgType: String) = - if(imgType.nonEmpty) "?type="+imgType else "" + if (imgType.nonEmpty) "?type=" + imgType else "" // 2 MB max - def imgTooLarge(req: Req) = req.uploadedFiles.exists(f => f.length > 2097000) + def imgTooLarge(req: Req) = + req.uploadedFiles.exists(f => f.length > 2097000) // 5 MB max - def bgTooLarge(req: Req) = req.uploadedFiles.exists(f => f.length > 5243000) - - def isValidImg(req: Req) = req.uploadedFiles.exists(file => acceptedImages.filter(file.mimeType == _).isEmpty) - - serve { - - case "api" :: "user" :: "upload" :: "image" :: Nil Post req => - - val imgType = S.param("type") getOrElse "" - - if(User.currentUser.isEmpty) - ResponseWithReason(BadResponse(), "Must be logged in.") - - else if(isValidImg(req)) - ResponseWithReason(BadResponse(), "Only JPEG, PNG and SVG are allowed.") - - else if(imgType == "bg" && bgTooLarge(req)) - ResponseWithReason(BadResponse(), "Image is too big. Must be 1MB or smaller.") + def bgTooLarge(req: Req) = + req.uploadedFiles.exists(f => f.length > 5243000) + + def inValidImg(req: Req): Boolean = req.uploadedFiles.exists( + file => acceptedImages.find(file.mimeType == _).isEmpty) + + def errorCheck(req: Req, imgType: String) = { + if (inValidImg(req)) + (false, "Only JPEG, PNG and SVG are allowed.") + else if (imgType == background && bgTooLarge(req)) + (false, "Image is too big. Must be 5MB or smaller.") + else if (imgType == profile && imgTooLarge(req)) + (false, "Image is too big. Must be 2MB or smaller.") + else if (imgType.isEmpty) + (false, "Image type is missing") + else + (true, "") + } - else if(imgType != "bg" && imgTooLarge(req)) - ResponseWithReason(BadResponse(), "Image is too big. Must be 2MB or smaller.") + serve("api" / "image" / "upload" prefix { - else { - for(user <- User.currentUser; file <- req.uploadedFiles) { + case "user" :: Nil Post req => + for { + user <- User.currentUser ?~ "Must be logged in" ~> 400 + imgType <- S.param("type") ?~ s"Missing image type ($background or $profile)" + file <- Box(req.uploadedFiles) ?~ "File not found" + } yield { + val (valid, error) = errorCheck(req, imgType) + if (valid) { val fn = StringHelpers.randomString(32) - val s3 = new S3(s3_access_key.get, s3_secret_key.get, s3_bucket.get) - val s3f = s3.createFile(fn, file.file, file.mimeType) - - s3f onSuccess { - case _ => - if(imgType == "bg") { - if(user.bgImg.is != "") {val s3f = s3.deleteFile(user.bgImg.is)} - user.bgImg(fn).update - } - else { - if(user.img.is != "") {val s3f = s3.deleteFile(user.img.is)} - user.img(fn).update - } - info("Successfully saved: "+file.fileName+" for "+user.name.is) + val res = s3.createFile(fn, file.file, file.mimeType) map { + n => + + val img = s3.fileUrl(fn) + val id: String = + if (imgType == background) { + if (user.bgImg.get != "") + s3.deleteFile(user.bgImg.get) + user.bgImg(fn).update + imgBgId + } + else { + if (user.img.get != "") + s3.deleteFile(user.img.get) + user.img(fn).update + imgProfileId + } + + info("Successfully saved: " + file.fileName + " for " + user.name.get) + JavaScriptResponse(Run( + s"""{"callback":"${UpdateImage(id, img).toJsCmd}"}""")) } + + Await.result[LiftResponse](res, 1 minute) } - OkResponse() + else ResponseWithReason(BadResponse(), error) } + case "blog" :: id :: Nil Post req => - case "api" :: "blog" :: id :: "upload" :: "image" :: Nil Post req => - - val blog = Blog.findByStringId(id) - val user = User.currentUser - val imgType = S.param("type") getOrElse "" - - if(blog.isEmpty) - ResponseWithReason(BadResponse(), "Blog id does not exist.") - - else if(user.isEmpty) - ResponseWithReason(BadResponse(), "Must be logged in.") - - else if(user.exists(u => blog.exists(_.nonOwner(u)))) - ResponseWithReason(BadResponse(), "You do not have permission to control the requested blog.") - - else if (req.uploadedFiles.exists(file => acceptedImages.filter(file.mimeType == _).isEmpty)) - ResponseWithReason(BadResponse(), "Only JPEG, PNG and SVG are allowed.") - - else if(imgType == "bg" && req.uploadedFiles.exists(f => f.length > 5243000)) // 5 MB - bg img - ResponseWithReason(BadResponse(), "Image is too big. Must be 1MB or smaller.") - - else if(imgType != "bg" && req.uploadedFiles.exists(f => f.length > 2097000)) // 2 MB - logo - ResponseWithReason(BadResponse(), "Image is too big. Must be 2MB or smaller.") - - else { - for(blog <- blog; file <- req.uploadedFiles) { - + for { + blog <- Blog.findByStringId(id) ?~ "Blog does not exist" ~> 400 + user <- User.currentUser ?~ "Must be logged in" + imgType <- S.param("type") ?~ s"Missing image type ($background or $profile)" + file <- Box(req.uploadedFiles) ?~ "File not found" + } yield { + val (valid, error) = errorCheck(req, imgType) + if (valid) { val fn = StringHelpers.randomString(32) - val s3 = new S3(s3_access_key.get, s3_secret_key.get, s3_bucket.get) - val s3f = s3.createFile(fn, file.file, file.mimeType) - - s3f onSuccess { - case _ => - if(imgType == "bg") { - if(blog.bgImg.is != "") {val s3f = s3.deleteFile(blog.bgImg.is)} - blog.bgImg(fn).update - } - else { - if(blog.img.is != "") {val s3f = s3.deleteFile(blog.img.is)} - blog.img(fn).update - } - info("Successfully saved: "+file.fileName+" for "+blog.name.is) + val res = s3.createFile(fn, file.file, file.mimeType) map { + n => + + val img = s3.fileUrl(fn) + val id: String = + if (imgType == background) { + if (blog.bgImg.get != "") + s3.deleteFile(blog.bgImg.get) + blog.bgImg(fn).update + imgBgId + } + else { + if (blog.img.get != "") + s3.deleteFile(blog.img.get) + blog.img(fn).update + imgProfileId + } + + info("Successfully saved: " + file.fileName + " for " + user.name.get) + JavaScriptResponse(Run( + s"""{"callback":"${UpdateImage(id, img).toJsCmd}"}""")) } + + info("Successfully saved: " + file.fileName + " for " + blog.name.get) + Await.result[LiftResponse](res, 1 minute) } - OkResponse() + else ResponseWithReason(BadResponse(), error) } - } + }) } diff --git a/src/main/scala/com/joereader/lib/URLFormatter.scala b/src/main/scala/com/joereader/lib/URLFormatter.scala @@ -0,0 +1,62 @@ +package com.joereader.lib + +import java.util.regex.Pattern +import Helper._ + +/** + * Extremely useful for formatting url your way. + * @param in URL in string format + */ +case class URLFormatter(in: String) { + val url = new java.net.URL(validate(in)) + + val ext = "htm" :: "html" :: "php" :: "php3" :: "jsp" :: "asp" :: "phtml" :: + "shtm" :: "shtml" :: "cgi" :: "cfm" :: "cfml" :: Nil + + def urlWithoutQueryParams(p: String*): String = { + val sep = in.split("\\?") + if (sep.size < 2) this.toString + else { + val params = sep.last.split("&") + this.toString + "?" + + params.map { + param => + val key = param.split("=").head + if (p.exists(_ == key)) "" + else param + }.filterNot(_ == "").mkString("&") + } + } + + def pattern: Pattern = { + + def removeFileExtension(p: String) = { + val arr = p.split( """\.""") + val last = arr.lastOption.getOrElse("") + if (ext.exists(_ == last)) + arr.slice(0, arr.length - 1).mkString(".") + else p + } + + def removeWWW(h: String) = + if (h.slice(0, 4) == "www.") h.slice(4, h.length) else h + + val s = removeWWW(url.getHost) + removeFileExtension(path) + Pattern.compile( """(https?://)?(www\.)?(^""" + s.escape + + """$)(\.(""" + ext.mkString("|") + """))?""", Pattern.CASE_INSENSITIVE) + } + + def path = if (url.getPath == "/") "" else url.getPath + + def hostAndPath = url.getHost + path + + /** + * Prepends http:// if it doesn't have it so it can + * validate with java.net.URL. + */ + def validate(str: String): String = + if (str.slice(0, 8).matches("https?://.*")) str else "http://" + str + + override def toString = url.getProtocol + "://" + url.getHost + + (if (url.getPort == -1) "" else ":" + url.getPort) + url.getPath +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/VideoInfo.scala b/src/main/scala/com/joereader/lib/VideoInfo.scala @@ -2,16 +2,18 @@ package com.joereader.lib import dispatch._ import xml._ + import concurrent.Future import concurrent.ExecutionContext.Implicits.global /** * Video information of certain video service providers. - * Supports Youtube, Vimeo, DailyMotion. - * Can only get video duration. + * Supports Youtube, Vimeo. + * Can only get video duration, but easily extensible. */ abstract class VideoInfo { protected def req(id: String): Req + def videoDuration(id: String): Future[Option[Int]] } @@ -33,28 +35,33 @@ abstract class VideoInfoXML extends VideoInfo { object YoutubeVideoInfo extends VideoInfoXML { - protected def req(id: String) = url("https://gdata.youtube.com/feeds/api/videos/"+id) <<? Map("v" -> "2") + protected def req(id: String) = + url("https://gdata.youtube.com/feeds/api/videos/" + id) <<? Map("v" -> "2") - def videoDuration(id: String) = extract[Int](id, { xml: Elem => - node2Int(xml \\ "duration" \ "@seconds") + def videoDuration(id: String) = extract[Int](id, { + xml: Elem => + node2Int(xml \\ "duration" \ "@seconds") }) } object VimeoVideoInfo extends VideoInfoXML { - protected def req(id: String) = url("http://vimeo.com/api/v2/video/"+id+".xml") + protected def req(id: String) = + url("http://vimeo.com/api/v2/video/" + id + ".xml") - def videoDuration(id: String) = extract[Int](id, { xml: Elem => - node2Int(xml \\ "duration") + def videoDuration(id: String) = extract[Int](id, { + xml: Elem => + node2Int(xml \\ "duration") }) } - object VideoService extends Enumeration { type VideoService = Value val Youtube = Value(YoutubeVideoInfo) val Vimeo = Value(VimeoVideoInfo) - class VideoServiceVal(val info : VideoInfo) extends Val(nextId) - protected final def Value(info : VideoInfo): VideoServiceVal = new VideoServiceVal(info) + class VideoServiceVal(val info: VideoInfo) extends Val(nextId) + + protected final def Value(info: VideoInfo): VideoServiceVal = + new VideoServiceVal(info) } diff --git a/src/main/scala/com/joereader/lib/Wordpress.scala b/src/main/scala/com/joereader/lib/Wordpress.scala @@ -1,28 +1,40 @@ package com.joereader.lib -import net.liftweb.util.Props import dispatch._ -import net.liftweb.http._ -import net.liftweb.json.JsonParser + +import net.liftweb._ +import util._ +import http._ +import json._ +import sitemap._, Loc._ +import common._ + import concurrent.ExecutionContext.Implicits.global -import net.liftweb.sitemap.{Loc, Menu} -import net.liftweb.sitemap.Loc.EarlyResponse -import net.liftweb.common.{Full, Box} -import com.joereader.lib.Helper.URLFormatter -import scala.collection.immutable.SortedMap -// Used between signin and callback time (please reset after done using it) +/* + * A session var had to be included so we can keep state between signin and + * callback time. Reset after done using it! + */ object WordpressInfo extends SessionVar(new Wordpress) +/** + * Wordpress Oauth + */ class Wordpress { var accessToken = "" var clientCallback = "" - var username = "" // wp username is author in rss feed - var blogJsonUrl = "" // link to json of the blog site username writes for - var blogUrl = "" // url entered by user (in verification) - def signIn : Box[LiftResponse] = { + // wp username - the author's name in rss feed + var username = "" + + // link to json of the blog site + var blogJsonUrl = "" + + // use this var to store the blog url entered by user + var blogUrl = "" + + def signIn: Box[LiftResponse] = { val req = :/(Wordpress.host).secure / "oauth2" / "authorize" <<? Map( "client_id" -> Wordpress.key, "redirect_uri" -> Wordpress.callback, @@ -44,9 +56,10 @@ class Wordpress { val res = Http(req OK as.String).option - res().map { jsonStr => - val json = JsonParser.parse(jsonStr) - accessToken = (json \ "access_token").values.toString + res().map { + jsonStr => + val json = JsonParser.parse(jsonStr) + accessToken = (json \ "access_token").values.toString } Full(RedirectWithState(clientCallback, RedirectState(() => { @@ -55,31 +68,38 @@ class Wordpress { } def validateToken: Boolean = { - if(accessToken.isEmpty) false + if (accessToken.isEmpty) false else { - val req = :/(Wordpress.host).secure / "rest" / "v1" / "me" <:< Map("Authorization" -> ("Bearer "+accessToken)) - try{ + val req = :/(Wordpress.host).secure / "rest" / "v1" / "me" <:< + Map("Authorization" -> ("Bearer " + accessToken)) + try { val json = Http(req OK as.String).option - if(json().isDefined) { - json().map{jsonStr => - val json = JsonParser.parse(jsonStr) - username = (json \ "username").values.toString - blogJsonUrl = (json \ "meta" \ "links" \ "site").values.toString.replaceAll("""\\""","") + if (json().isDefined) { + json().map { + jsonStr => + val json = JsonParser.parse(jsonStr) + username = (json \ "username").values.toString + blogJsonUrl = (json \ "meta" \ "links" \ "site"). + values.toString.replaceAll( """\\""", "") } true } else false } - catch { case _: Throwable => false } + catch { + case _: Throwable => false + } } } def isBlogUrl(u: String): Boolean = { - val req = url(blogJsonUrl) <:< Map("Authorization" -> ("Bearer "+accessToken)) + val req = url(blogJsonUrl) <:< + Map("Authorization" -> ("Bearer " + accessToken)) val json = Http(req OK as.String).option - json().exists { jsonStr => - val json = JsonParser.parse(jsonStr) - val u1 = (json \ "URL").values.toString - URLFormatter(u1).toString == URLFormatter(u).toString + json().exists { + jsonStr => + val json = JsonParser.parse(jsonStr) + val u1 = (json \ "URL").values.toString + URLFormatter(u1).toString == URLFormatter(u).toString } } @@ -89,9 +109,11 @@ object Wordpress { val baseUrl = Props.get("wordpress.baseurl") openOr S.hostName val key = Props.get("wordpress.key") openOr "" val secret = Props.get("wordpress.secret") openOr "" - val callback = baseUrl+"auth/wordpress/callback" + val callback = baseUrl + "auth/wordpress/callback" val host = "public-api.wordpress.com" + // below functions are handled by Lifts SiteMap + def buildWordpressCallbackMenu = Menu(Loc( "Wordpress Callback", "auth" :: "wordpress" :: "callback" :: Nil, "wordpress.callback", wordpressCallbackLocParams @@ -99,7 +121,7 @@ object Wordpress { def wordpressCallbackLocParams = EarlyResponse(() => { - WordpressInfo.is.callback + WordpressInfo.is.callback() }) :: Nil def buildWordpressSignInMenu = Menu(Loc( diff --git a/src/main/scala/com/joereader/lib/aws/AmazonS3.scala b/src/main/scala/com/joereader/lib/aws/AmazonS3.scala @@ -8,9 +8,10 @@ import dispatch._ protected object AmazonS3 { + import javax.crypto - import java.util.{Date,Locale,SimpleTimeZone} + import java.util.{Date, Locale, SimpleTimeZone} import java.text.SimpleDateFormat val UTF_8 = "UTF-8" @@ -32,12 +33,12 @@ protected object AmazonS3 { Base64.encode(r.digest) } - def md5(stream:java.io.InputStream) = { + def md5(stream: java.io.InputStream) = { import java.security.MessageDigest val buffer = new Array[Byte](1024) val r = MessageDigest.getInstance("MD5") - var numRead:Int = 0 + var numRead: Int = 0 do { numRead = stream.read(buffer) if (numRead > 0) { @@ -50,12 +51,12 @@ protected object AmazonS3 { } def sign(method: String, path: String, secretKey: String, date: Date, - contentType: Option[String], contentMd5: Option[String], amzHeaders: Map[String,Set[String]]): String = { + contentType: Option[String], contentMd5: Option[String], amzHeaders: Map[String, Set[String]]): String = { sign(method, path, secretKey, Left(date), contentType, contentMd5, amzHeaders) } def sign(method: String, path: String, secretKey: String, dateOrExpires: Either[Date, Long], - contentType: Option[String], contentMd5: Option[String], amzHeaders: Map[String,Set[String]]) = { + contentType: Option[String], contentMd5: Option[String], amzHeaders: Map[String, Set[String]]) = { val SHA1 = "HmacSHA1" val message = canonicalString(method, path, dateOrExpires, contentType, contentMd5, amzHeaders) val sig = { @@ -67,7 +68,7 @@ protected object AmazonS3 { sig } - def signedUri(accessKey: String, secretKey: String, method:String, path: String, amzHeaders: Map[String, Set[String]], + def signedUri(accessKey: String, secretKey: String, method: String, path: String, amzHeaders: Map[String, Set[String]], expires: Long = defaultExpiryTime, contentType: Option[String] = None, contentMd5: Option[String] = None) = { val signed = encode(sign(method, path, secretKey, Right(expires), contentType, contentMd5, amzHeaders), "UTF-8") @@ -81,7 +82,9 @@ protected object AmazonS3 { */ def canonicalString(method: String, path: String, dateOrExpires: Either[Date, Long], contentType: Option[String], contentMd5: Option[String], amzHeaders: Map[String, Set[String]]) = { - val amzString = amzHeaders.toList.sortWith(_._1.toLowerCase < _._1.toLowerCase).map{ case (k,v) => "%s:%s".format(k.toLowerCase, v.map(trim _).mkString(",")) } + val amzString = amzHeaders.toList.sortWith(_._1.toLowerCase < _._1.toLowerCase).map { + case (k, v) => "%s:%s".format(k.toLowerCase, v.map(trim).mkString(",")) + } val dateExpiresString = dateOrExpires match { case Left(date) => rfc822DateParser.format(date) case Right(expires) => expires.toString @@ -92,14 +95,16 @@ protected object AmazonS3 { def bytes(s: String) = s.getBytes(UTF_8) implicit def Request2S3RequestSigner(r: RequestBuilder) = new S3RequestSigner(r) + implicit def Request2S3RequestSigner(r: String) = new S3RequestSigner(new RequestBuilder().setUrl(r)) class S3RequestSigner(r: RequestBuilder) { + import scala.collection.JavaConverters._ protected def path = RawUri(r.build.getUrl).path.getOrElse("") - def <@ (accessKey: String, secretKey: String) = { + def <@(accessKey: String, secretKey: String) = { val req = r.build val contentStream = req.getStreamData val contentMd5 = if (req.getContentLength <= 0) None else Some(md5(contentStream)) @@ -110,7 +115,9 @@ protected object AmazonS3 { val headers = req.getHeaders val contentType = headers.keySet.asScala.find { _.toLowerCase == "content-type" - }.map {headers.get(_).asScala.head } + }.map { + headers.get(_).asScala.head + } val d = new Date r.addHeader("Authorization", "AWS %s:%s".format(accessKey, sign(req.getMethod, path, secretKey, d, contentType, contentMd5, amazonHeaders))) @@ -137,6 +144,7 @@ protected object AmazonS3 { .map(name => name -> headers.get(name).asScala.toSet).toMap } } + } object Bucket extends (String => RequestBuilder) { diff --git a/src/main/scala/com/joereader/lib/aws/S3.scala b/src/main/scala/com/joereader/lib/aws/S3.scala @@ -1,33 +1,22 @@ package com.joereader.lib.aws import dispatch._ -import AmazonS3._ import concurrent.ExecutionContext.Implicits.global -import com.ning.http.client.Response -import java.io.FileOutputStream - +import AmazonS3._ +/* Amazon S3 API */ class S3(access_key: String, secret_key: String, bucket: String) { - def createFile(fn: String, bytes: Array[Byte], contentType: String): Future[Response] = { - val file = new java.io.File("/tmp/"+fn) - inputToFile(bytes, file) - createFile(fn, file, contentType) - } - - def createFile(fn: String, file: java.io.File, contentType: String) = - Http.configure(_ setCompressionEnabled true)(Bucket(bucket) / fn <<< file <:< + /* Creates a file that can be viewed by the public. */ + def createFile(fn: String, data: Array[Byte], contentType: String) = + Http.configure(_ setCompressionEnabled true)(Bucket(bucket).PUT.setBody(data) / fn <:< Map("content-type" -> contentType, "x-amz-acl" -> "public-read") <@(access_key, secret_key)) def deleteFile(fn: String) = - Http.configure(_ setCompressionEnabled true)(Bucket(bucket).DELETE / fn <@(access_key, secret_key)) + Http.configure(_ setCompressionEnabled true)(Bucket(bucket).DELETE / + fn <@(access_key, secret_key)) - def fileUrl(fn: String) = "http://"+Root+"/"+bucket+"/"+fn + def fileUrl(fn: String) = "http://" + Root + "/" + bucket + "/" + fn - private def inputToFile(bytes: Array[Byte], f: java.io.File) { - val out = new FileOutputStream(f) - try { out.write(bytes) } - finally { out.close() } - } } diff --git a/src/main/scala/com/joereader/lib/rss/Feed.scala b/src/main/scala/com/joereader/lib/rss/Feed.scala @@ -0,0 +1,101 @@ +package com.joereader.lib.rss + +import com.sun.syndication.feed.synd._ +import scala.collection.JavaConversions._ +import scala.collection.SortedSet + +/* An RSS Feed. */ +case class Feed(name: String, + description: String, + imageUrl: Option[String], + writers: List[FeedAuthor], + entries: List[FeedEntry], + links: List[String]) + +object Feed { + + def build(feed: Option[SyndFeed], links: List[String]) = { + + val entries = FeedEntry.build(feed.map(_.getEntries. + asInstanceOf[java.util.List[SyndEntry]].toList)) + + Feed( + feed.map(_.getTitle).getOrElse(""), + feed.map(_.getDescription).getOrElse(""), + feed.map(f => if (f.getImage != null) f.getImage.getUrl else ""), + entries.map(_.author).distinct, + entries, + links + ) + } + + /** + * Merges multiple feeds into one. If any feed contains one entry with same + * title and date from an entry of another feed, the second feed will be + * ignored. + * @param feeds the feeds to merge + * @return one merged feed + */ + def merge(feeds: List[Feed]): Feed = + if (feeds.nonEmpty) { + + def name(feeds: List[Feed]): String = { + if (feeds.isEmpty) "" + else if (!feeds.head.name.isEmpty) feeds.head.name + else name(feeds.tail) + } + + def description(feeds: List[Feed]): String = { + if (feeds.isEmpty) "" + else if (!feeds.head.description.isEmpty) feeds.head.description + else description(feeds.tail) + } + + def imageUrl(feeds: List[Feed]): Option[String] = { + if (feeds.isEmpty) None + else if (feeds.head.imageUrl.isDefined) feeds.head.imageUrl + else imageUrl(feeds.tail) + } + + var _writers = feeds.head.writers + var _entries: List[FeedEntry] = Nil + var _links: List[String] = Nil + + for (f <- 0 until feeds.size) { + var repeat = false + for (entry1 <- feeds(f).entries) + for (entry2 <- _entries) + if (entry1.title == entry2.title && + entry1.date.compareTo(entry2.date) == 0) repeat = true + + if (!repeat) { + _writers = _writers ::: feeds(f).writers + _entries = _entries ::: feeds(f).entries + _links = _links ::: feeds(f).links + } + } + + // remove any repeated values + _links = SortedSet(_links: _*).toList + _writers = SortedSet(_writers: _*)(FeedAuthorOrdering).toList + + Feed( + name(feeds), + description(feeds), + imageUrl(feeds), + _writers, + _entries, + _links) + + } + else + Feed("", "", None, Nil, Nil, Nil) +} + +case class FeedImage(src: String) + +case class FeedAuthor(name: String, imgUrl: Option[String]) + +private object FeedAuthorOrdering extends Ordering[FeedAuthor] { + def compare(a: FeedAuthor, b: FeedAuthor) = a.name compare b.name +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/rss/FeedEntry.scala b/src/main/scala/com/joereader/lib/rss/FeedEntry.scala @@ -0,0 +1,199 @@ +package com.joereader.lib.rss + +import com.sun.syndication._ +import feed.synd._ +import feed.module.mediarss._, types._ + +import org.jsoup._ +import nodes._ +import select._ +import parser._ +import safety._ + +import net.liftweb.util.PCDataXmlParser + +import scala.xml.NodeSeq +import scala.collection.JavaConversions._ +import java.util.Date +import java.text.SimpleDateFormat + +import com.joereader.lib._ + +/* An RSS Feed Entry/Item. */ +case class FeedEntry( + id: String, + title: String, + link: String, + date: Date, + author: FeedAuthor, + images: List[FeedImage], + content: NodeSeq) { + + val df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") + def nonAuthorImages = images.filterNot(i => author.imgUrl.exists(_ == i.src)) +} + +object FeedEntry { + + /* + * We cheat when possible and remove the following query params from image + * sources. + */ + val invalidImgParams = List("w", "width", "h", "height", "crop", "size", "s") + + def build(entry: SyndEntry) = { + val builtContent = content(entry) + val builtMediaImages = mediaImages(entry, builtContent) + + FeedEntry( + guid(entry), + entry.getTitle, + entry.getLink, + entry.getPublishedDate, + author(entry, builtMediaImages), + builtMediaImages, + builtContent) + } + + def build(entries: List[SyndEntry]): List[FeedEntry] = entries.map(build) + + def build(entries: Option[List[SyndEntry]]): List[FeedEntry] = + entries.map(build).getOrElse(Nil) + + /* Create FeedAuthor */ + def author(entry: SyndEntry, images: List[FeedImage]): FeedAuthor = { + import gdata._ + + // grab image src located in Author GData namespace (used by blogger.com) + val gdataImage = entry.getAuthors.asInstanceOf[java.util.List[SyndPerson]]. + headOption.map { + person => + val mod = person.getModule(GDataModule.URI).asInstanceOf[GDataModule] + if (mod != null) mod.getThumbnail else "" + } + + val gravatarImage: Option[String] = + images.find(_.src.contains("gravatar.com")).map(_.src) + + val img = if(gravatarImage.isDefined) gravatarImage else gdataImage + + FeedAuthor(entry.getAuthor, img) + } + + /* Find All images in entry. */ + def mediaImages(entry: SyndEntry, content: NodeSeq): List[FeedImage] = ({ + val mediaMod: MediaEntryModule = entry.getModule(MediaModule.URI). + asInstanceOf[MediaEntryModule] + + if (mediaMod != null) + (for (mediaContent <- mediaMod.getMediaContents) yield { + val url = mediaContent.getReference.asInstanceOf[UrlReference].getUrl + val src = + if (mediaContent.getMedium == "image") + URLFormatter(url.toString). + urlWithoutQueryParams(invalidImgParams: _*) + else + "" + FeedImage(src) + }).toList + else Nil + } ::: { + (content \\ "img").map { + img => + val width = (img \ "@width").text + val height = (img \ "@height").text + val src = (img \ "@src").text + + try { + if ((width == "" || width.toInt > 25) && + (height == "" || height.toInt > 25)) + FeedImage(src) + else + FeedImage("") + } catch { + case _: Throwable => FeedImage("") + } + + }.toList + + }). + filterNot(_.src == ""). + filterNot(i => + i.src.contains("feeds.feedburner.com") || + i.src.contains("feedsportal.com") || + i.src.contains("subscribe") + ). // feedburner's bottom image links + groupBy(_.src). + map(src => FeedImage(src._1)). + toList + + /* Grabs and formats the entry's content. */ + def content(entry: SyndEntry): NodeSeq = { + val content = entry.getContents.asInstanceOf[java.util.List[SyndContent]]. + headOption.map(_.getValue).getOrElse(entry.getDescription.getValue) + + val whitelist = Whitelist.relaxed. + addTags("iframe"). + addAttributes("iframe", "src", "frameborder", "allowfullscreen"). + addEnforcedAttribute("a", "rel", "nofollow"). + addEnforcedAttribute("a", "target", "_blank") + + val unsanitized = Jsoup.parse("<div>" + content + "</div>").body + val imgUnsanitized = formatImages(unsanitized) + val vidUnsanitized = formatVideos(imgUnsanitized) + val semisanitized = Jsoup.clean(vidUnsanitized.toString, whitelist) + val sanitized = Jsoup.parse(semisanitized).body.children.addClass("content") + PCDataXmlParser(sanitized.toString) openOr NodeSeq.Empty + } + + def formatImages(content: Element): Element = { + val imgs: Elements = content.select("img") + + for (img <- imgs) { + try { + val link = URLFormatter(img.attr("src")). + urlWithoutQueryParams(invalidImgParams: _*) + + img.attr("src", link) + + val w = "width" + val h = "height" + if (img.attr(w) != "" && img.attr(w).toInt > 25) img.attr(w, "") + if (img.attr(h) != "" && img.attr(h).toInt > 25) img.attr(h, "") + } catch { + case _: Throwable => + } + } + content + } + + def formatVideos(content: Element): Element = { + + def emptyElem = new Element(Tag.valueOf("span"), "") + val iframes: Elements = content.select("iframe") + + // only allow embedded youtube, vimeo videos + val validSrc = "http://www.youtube.com/embed/" :: "http://player.vimeo.com/video/" :: Nil + + for (iframe <- iframes) { + try { + val src = iframe.attr("src") + if (validSrc.exists(s => src.startsWith(s))) iframe.html("") + else iframe.replaceWith(emptyElem) + } catch { + case _: Throwable => + } + } + content + } + + /** + * GUID. If not provided, we create our own with title and published date. + */ + def guid(entry: SyndEntry): String = { + if (entry.getUri == "" || entry.getUri == null) + entry.getTitle + entry.getPublishedDate + else + entry.getUri + } +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/rss/MetaVerification.scala b/src/main/scala/com/joereader/lib/rss/MetaVerification.scala @@ -0,0 +1,13 @@ +package com.joereader.lib.rss + +import dispatch._ + +/* Verifies if a webpage contains a specific meta tag. */ +case class MetaVerification(metaName: String, metaContent: String, link: String) { + + def verified: Boolean = + requestLink(link)().exists(response => + headElements(parseHtml(response)) exists + (e => e.attr("name").toLowerCase.trim == metaName.toLowerCase.trim && + e.attr("content").toLowerCase.trim == metaContent.toLowerCase.trim)) +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/rss/Rss.scala b/src/main/scala/com/joereader/lib/rss/Rss.scala @@ -1,280 +0,0 @@ -package com.joereader.lib.rss - -import dispatch._, Defaults._ -import org.jsoup._, nodes._ -import scala.collection.JavaConversions._ -import java.io.ByteArrayInputStream -import com.sun.syndication.io.{XmlReader, SyndFeedInput} -import com.sun.syndication.feed.synd.{SyndContent, SyndEntry, SyndFeed} -import scala.collection.SortedSet - -import java.util.Date -import java.text.SimpleDateFormat -import org.jsoup.safety.Whitelist -import net.liftweb.util.PCDataXmlParser -import org.jsoup.select.Elements -import org.jsoup.parser.Tag -import scala.Some -import org.jsoup.nodes.Document -import com.joereader.lib.Helper.URLFormatter -import scala.xml.NodeSeq - -/** - * - */ -object Rss { - - case class RSSHtmlResponse(loc: String, content: String, redirectTo: String) - - implicit class RssImplicitString(str: String) { - - def entries: List[FeedEntry] = { - val response = requestLink(str)() - Feed.build(extractFeed(response), List(str)).entries - } - - def response: RSSHtmlResponse = { - val req = RSSHtmlResponse(str, "", str) - val res = requestLink(Some(req)) - res.getOrElse(req) - } - - /** - * Given a url link, it tries to find all rss links in html head. - * @return list of rss links and the html content. - */ - def rssLinks: List[String] = { - findRssLinks(str) - } - } - - implicit class RssImplicitListOfString(strs: List[String]) { - /** - * Given a url link to rss feed, it returns a syndicated feed. - * @return a syndicated feed - */ - def feed: Feed = { - val feeds = strs.map { link => - val response = requestLink(link)() - Feed.build(extractFeed(response), List(link)) - }.toList - Feed.merge(feeds) - } - } - - /** - * Returns a result of the page content (as a string) requested by a link. - * @param link the web link to the page. - * @return either a failure with a message or successful content as a string - */ - def requestLink(link: String) : Future[Option[String]] = { - val request = url(URLFormatter(link).toString) - Http.configure(_.setFollowRedirects(true).setCompressionEnabled(true))(request OK as.String).option - } - - /** - * Returns a result of the page content (as a string) requested by a link. - * Note: Redirects can be handled by dispatch with setFollowRedirects as true, - * but we want to retrieve the url of the content. Limit is 5 redirects. - */ - def requestLink(optreq: Option[RSSHtmlResponse], redirectCount: Int = 0) : Option[RSSHtmlResponse] = { - - if(optreq.exists(_.redirectTo == null) || redirectCount > 2 ) return optreq - - optreq.flatMap { req => - val request = url(URLFormatter(req.redirectTo).toString) - val response = - Http.configure(_.setCompressionEnabled(true))(request > { r=> - if(r.isRedirected) - RSSHtmlResponse(req.redirectTo, r.getResponseBody, r.getHeader("Location")) - else RSSHtmlResponse(req.redirectTo, r.getResponseBody, null) - }).option - requestLink(response(), redirectCount+1) - } - } - - /** - * Finds link(s) to RSS feeds in HTML elements. - * @param html html in plain text. - */ - def findRssLinks(html: String): List[String] = { - - val document = parseHtml(html) - - /* All the types of rss mimes (including atom.) */ - val mimes = - "application/rss+xml" :: - "application/atom+xml" :: - "application/xml" :: - "text/xml" :: Nil - - /* Retrieves a list of elements that match type={mimes} */ - def filterByMimeType(elems: List[Element]): List[Element] = - elems.filter(e => mimes.filter(_ == e.attr("type")).nonEmpty) - - /* Remove elements that are likely rss comment feeds. */ - def filterNotRssComments(elems: List[Element]): List[Element] = - elems.filterNot(e => e.toString.contains("comment")) - - /* Retrieves the web link from each elements href value. */ - def getLinkHrefs(elems: List[Element]): List[String] = - elems.map(_.attr("href").trim) - - getLinkHrefs( - filterNotRssComments( - filterByMimeType(headElements(document)))) - } - - def findRssLinks(html: Option[String]): List[String] = - html map findRssLinks _ getOrElse Nil - - /** - * Takes an xml (as string) and builds a syndicated feed. - * @param xml xml as string. - * @return a syndicated feed if xml if correctly parsed. - */ - def extractFeed(xml: String): Option[SyndFeed] = try { - val bytes = new ByteArrayInputStream(xml.getBytes("UTF-8")) - val b = new XmlReader(bytes) - val a = new SyndFeedInput() - val c = a.build(b) - val h = Some(c) - h - } catch {case _: Throwable => None} - - def extractFeed(link: Option[String]): Option[SyndFeed] = - link.map(extractFeed _).flatten - - def parseHtml(html: String): Document = Jsoup.parse(html) - def headElements(doc: Document): List[Element] = - doc.head.children.toList - - case class MetaVerification(metaName: String, metaContent: String, link: String) { - def verified: Boolean = - requestLink(link)().exists(response => - headElements(parseHtml(response)) exists - (e => e.attr("name").toLowerCase.trim == metaName.toLowerCase.trim && - e.attr("content").toLowerCase.trim == metaContent.toLowerCase.trim)) - } - - /* Interface of a SyndFeed. */ - object Feed { - def build(feed: Option[SyndFeed], links: List[String]) = - Feed( - feed.map(_.getTitle).getOrElse(""), - feed.map(_.getDescription).getOrElse(""), - feed.map { _.getEntries.asInstanceOf[java.util.List[SyndEntry]]. - map(_.getAuthor).toList}.getOrElse(Nil), - FeedEntry.build(feed.map(_.getEntries.asInstanceOf[java.util.List[SyndEntry]].toList), feed.map(_.getFeedType).getOrElse("")), - links - ) - - /** - * Merges multiple feeds into one. If any feed contains one entry with same - * title from an entry of another feed, the second feed will be removed. - * @param feeds the feeds to merge - * @return one merged feed - */ - def merge(feeds: List[Feed]): Feed = if(feeds.nonEmpty) { - var _name = feeds.head.name - var _description = feeds.head.description - var _writers = feeds.head.writers - var _entries: List[FeedEntry] = Nil - var _links: List[String] = Nil - - if(_name.isEmpty) feeds.tail.map(f => _name = f.name) - if(_description.isEmpty) feeds.tail.map(f => _description = f.description) - - for(f <- 0 until feeds.size) { - var repeat = false - for(entry1 <- feeds(f).entries) { - for(entry2 <- _entries) { - if(entry1.title == entry2.title) repeat = true - } - } - if(!repeat) { - _writers = _writers ::: feeds(f).writers - _entries = _entries ::: feeds(f).entries - _links = _links ::: feeds(f).links - } - } - - // remove any repeated values - _links = SortedSet(_links: _*).toList - _writers = SortedSet(_writers: _*).toList - - Feed(_name, _description, _writers, _entries, _links) - } else Feed("","",Nil, Nil, Nil) - - } - - case class Feed(name: String, - description: String, - writers: List[String], - entries: List[FeedEntry], - links: List[String]) - - object FeedEntry { - def build(entry: SyndEntry, feedType: String) = FeedEntry( - guid(entry), entry.getTitle, entry.getLink, entry.getPublishedDate, entry.getAuthor, content(entry, feedType)) - - - def build(entries: List[SyndEntry], feedType: String): List[FeedEntry] = entries.map(build(_, feedType)) - def build(entries: Option[List[SyndEntry]], feedType: String): List[FeedEntry] = entries.map(build(_, feedType)).getOrElse(Nil) - - def content(entry: SyndEntry, feedType: String): NodeSeq = { - val content = entry.getContents.asInstanceOf[java.util.List[SyndContent]].headOption.map(_.getValue). - getOrElse(entry.getDescription.getValue) - - val whitelist = Whitelist.relaxed. - addTags("iframe"). - addAttributes("iframe", "src", "frameborder", "allowfullscreen"). - addEnforcedAttribute("a", "rel", "nofollow") - - val unsanitized = Jsoup.parse("<div>"+content+"</div>").body - val imgUnsanitized = formatImages(unsanitized) - val vidUnsanitized = formatVideos(imgUnsanitized) - val semisanitized = Jsoup.clean(vidUnsanitized.toString, whitelist) - val sanitized = Jsoup.parse(semisanitized).body.children.addClass("content") - PCDataXmlParser(sanitized.toString) openOr NodeSeq.Empty - } - - def formatImages(content: Element): Element = { - val imgs: Elements = content.select("img") - for(img <- imgs) { - try { - val link = URLFormatter(img.attr("src")).urlWithoutQueryParams("w","width","h","height","crop","size","s") - img.attr("src", link) - if(!img.attr("width").isEmpty && img.attr("width").toInt > 25) img.attr("width", "") - if(!img.attr("height").isEmpty && img.attr("height").toInt > 25) img.attr("height", "") - } catch { case _: Throwable => } - } - content - } - - def formatVideos(content: Element): Element = { - - def emptyElem = new Element(Tag.valueOf("span"),"") - - val iframes: Elements = content.select("iframe") - val validSrc = "http://www.youtube.com/embed/" :: Nil - - for(iframe <- iframes) { - try { - val src = iframe.attr("src") - if(validSrc.exists(s => src.startsWith(s))) iframe.html("") // empty frame but keep attr - else iframe.replaceWith(emptyElem) - } catch { case _: Throwable => } - } - content - } - - def guid(entry: SyndEntry): String = { - if(entry.getUri == "" || entry.getUri == null) entry.getTitle + entry.getPublishedDate - else entry.getUri - } - } - case class FeedEntry(id: String, title: String, link: String, date: Date, author: String, content: NodeSeq) { - val df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") - } -} -\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/rss/gdata/GDataModule.java b/src/main/scala/com/joereader/lib/rss/gdata/GDataModule.java @@ -0,0 +1,19 @@ +package com.joereader.lib.rss.gdata; + +/** + * https://github.com/lradziwonowicz/rome-gdata-module-skeleton/blob/master/src/feeds/module/gdata/GDataModule.java + */ +import com.sun.syndication.feed.module.Module; + +/** + * @author Lukasz Radziwonowicz + * + */ +public interface GDataModule extends Module { + public static final String URI = "http://schemas.google.com/g/2005"; + + public String getThumbnail(); + + public void setThumbnail(String thumbnailUrl); + +} diff --git a/src/main/scala/com/joereader/lib/rss/gdata/GDataModuleImpl.java b/src/main/scala/com/joereader/lib/rss/gdata/GDataModuleImpl.java @@ -0,0 +1,61 @@ +package com.joereader.lib.rss.gdata; + +/** + * https://github.com/lradziwonowicz/rome-gdata-module-skeleton/blob/master/src/feeds/module/gdata/GDataModuleImpl.java + */ +import com.sun.syndication.feed.module.ModuleImpl; + +/** + * @author Lukasz Radziwonowicz + * + */ +public class GDataModuleImpl extends ModuleImpl implements GDataModule { + + private String thumbnail; + + public GDataModuleImpl() { + super(GDataModule.class, GDataModule.URI); + } + + /* + * (non-Javadoc) + * + * @see com.sun.syndication.feed.CopyFrom#getInterface() + */ + @Override + public Class getInterface() { + return GDataModule.class; + } + + /* + * (non-Javadoc) + * + * @see com.sun.syndication.feed.CopyFrom#copyFrom(java.lang.Object) + */ + @Override + public void copyFrom(Object obj) { + GDataModule module = (GDataModule) obj; + this.setThumbnail(module.getThumbnail()); + } + + /* + * (non-Javadoc) + * + * @see feeds.module.gdata.GDataModule#getThumbnail() + */ + @Override + public String getThumbnail() { + return thumbnail; + } + + /* + * (non-Javadoc) + * + * @see feeds.module.gdata.GDataModule#setThumbnail() + */ + @Override + public void setThumbnail(String thumbnailUrl) { + this.thumbnail = thumbnailUrl; + } + +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/rss/gdata/io/GDataModuleGenerator.java b/src/main/scala/com/joereader/lib/rss/gdata/io/GDataModuleGenerator.java @@ -0,0 +1,62 @@ +package com.joereader.lib.rss.gdata.io; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import com.joereader.lib.rss.gdata.GDataModule; +import org.jdom.Element; +import org.jdom.Namespace; + +import com.sun.syndication.feed.module.Module; +import com.sun.syndication.io.ModuleGenerator; + + +/** + * @author Lukasz Radziwonowicz + * + */ +public class GDataModuleGenerator implements ModuleGenerator { + + // boilerplate code + private static final Namespace NAMESPACE = Namespace.getNamespace("gd", GDataModule.URI); + private static final Set NAMESPACES; + static { + Set<Namespace> namespaces = new HashSet<Namespace>(); + namespaces.add(NAMESPACE); + NAMESPACES = Collections.unmodifiableSet(namespaces); + } + + /* + * (non-Javadoc) + * + * @see com.sun.syndication.io.ModuleGenerator#getNamespaceUri() + */ + @Override + public String getNamespaceUri() { + return GDataModule.URI; + } + + /* + * (non-Javadoc) + * + * @see com.sun.syndication.io.ModuleGenerator#getNamespaces() + */ + @Override + public Set getNamespaces() { + return NAMESPACES; + } + + public GDataModuleGenerator() { + } + + public void generate(Module module, Element element) { +// MyModule myModule = (MyModule) module; +// if (myModule.getTag() != null) { +// Element myElement = new Element("tag", NAMESPACE); +// myElement.setText(myModule.getTag()); +// element.addContent(myElement); +// } + } + +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/rss/gdata/io/GDataModuleParser.java b/src/main/scala/com/joereader/lib/rss/gdata/io/GDataModuleParser.java @@ -0,0 +1,50 @@ +package com.joereader.lib.rss.gdata.io; + +import com.joereader.lib.rss.gdata.GDataModule; +import com.joereader.lib.rss.gdata.GDataModuleImpl; +import org.jdom.Element; +import org.jdom.Namespace; + +import com.sun.syndication.feed.module.Module; +import com.sun.syndication.io.ModuleParser; + +/** + * @author Lukasz Radziwonowicz + * + */ +public class GDataModuleParser implements ModuleParser { + +// public GDataModuleParser(){ +// super(); +// } + + /* + * (non-Javadoc) + * + * @see com.sun.syndication.io.ModuleParser#getNamespaceUri() + */ + @Override + public String getNamespaceUri() { + return GDataModule.URI; + } + + /* + * (non-Javadoc) + * + * @see com.sun.syndication.io.ModuleParser#parse(org.jdom.Element) + */ + @Override + public Module parse(Element element) { + Namespace gdNS = Namespace.getNamespace("gd", GDataModule.URI); + GDataModule gDataModule = new GDataModuleImpl(); + Element image = element.getChild("image", gdNS); +// if (element.getNamespace().equals(gdNS)) { + if (image != null) { + if (image.getAttributeValue("rel").equals(GDataModule.URI + "#thumbnail")) { + gDataModule.setThumbnail(image.getAttributeValue("src")); + } + } +// } + return gDataModule; + } +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/rss/package.scala b/src/main/scala/com/joereader/lib/rss/package.scala @@ -0,0 +1,159 @@ +package com.joereader.lib + +import dispatch._, Defaults._ +import org.jsoup._, nodes._ +import com.sun.syndication._ +import io._ +import feed.synd._ + +import java.io._ +import scala.Some +import scala.collection.JavaConversions._ + +case class RSSHtmlResponse(loc: String, content: String, redirectTo: String) + +/** + * + */ +package object rss { + + implicit class RssImplicitString(str: String) { + + /** + * Grabs all feed entries given a url to a rss feed. + */ + def entries: List[FeedEntry] = { + val response = requestLink(str)() + Feed.build(extractFeed(response), List(str)).entries + } + + /** + * Returns html given a url to a web page + */ + def response: RSSHtmlResponse = { + val req = RSSHtmlResponse(str, "", str) + val optReq: Option[RSSHtmlResponse] = Some(req) + val res = requestLink(optReq) + res.getOrElse(req) + } + + /** + * Given a url link, it tries to find all rss links in html head. + * @return list of rss links and the html content. + */ + def rssLinks: List[String] = { + findRssLinks(str) + } + } + + implicit class RssImplicitListOfString(strs: List[String]) { + /** + * Given a url link to rss feed, it returns a syndicated feed. + * @return a syndicated feed + */ + def feed: Feed = { + val feeds = strs.map { + link => + val response = requestLink(link)() + Feed.build(extractFeed(response), List(link)) + }.toList + Feed.merge(feeds) + } + } + + /** + * Returns a result of the page content (as a string) requested by a link. + * @param link the web link to the page. + * @return either a failure with a message or successful content as a string + */ + def requestLink(link: String): Future[Option[String]] = { + val request = url(URLFormatter(link).toString) + + Http.configure(_. + setFollowRedirects(true). + setCompressionEnabled(true))(request OK as.String).option + } + + /** + * Returns a result of the page content (as a string) requested by a link. + * Note: Redirects can be handled by dispatch with setFollowRedirects as true, + * but we want to retrieve the redirected url. Limit is 3 redirects. + */ + def requestLink(optreq: Option[RSSHtmlResponse], redirectCount: Int = 0): Option[RSSHtmlResponse] = { + + if (optreq.exists(_.redirectTo == null) || redirectCount > 2) return optreq + + optreq.flatMap { + req => + val request = url(URLFormatter(req.redirectTo).toString) + + val response = + Http.configure(_.setCompressionEnabled(true))(request > { + r => + if (r.isRedirected) + RSSHtmlResponse(req.redirectTo, r.getResponseBody, r.getHeader("Location")) + else + RSSHtmlResponse(req.redirectTo, r.getResponseBody, null) + }).option + + requestLink(response(), redirectCount + 1) + } + } + + /** + * Finds link(s) to RSS feeds in HTML elements. + * @param html html in plain text. + */ + def findRssLinks(html: String): List[String] = { + + val document = parseHtml(html) + + /* All the types of rss mimes (including atom.) */ + val mimes = + "application/rss+xml" :: + "application/atom+xml" :: + "application/xml" :: + "text/xml" :: Nil + + /* Retrieves a list of elements that match type={mimes} */ + def filterByMimeType(elems: List[Element]): List[Element] = + elems.filter(e => mimes.filter(_ == e.attr("type")).nonEmpty) + + /* Remove elements that are likely rss comment feeds. */ + def filterNotRssComments(elems: List[Element]): List[Element] = + elems.filterNot(e => e.toString.contains("comment")) + + /* Retrieves the web link from each elements href value. */ + def getLinkHrefs(elems: List[Element]): List[String] = + elems.map(_.attr("href").trim) + + getLinkHrefs( + filterNotRssComments( + filterByMimeType(headElements(document)))) + } + + def findRssLinks(html: Option[String]): List[String] = + html map findRssLinks getOrElse Nil + + /** + * Takes an xml (as string) and builds a syndicated feed. + * @param xml xml as string. + * @return a syndicated feed if xml if correctly parsed. + */ + def extractFeed(xml: String): Option[SyndFeed] = try { + val bytes = new ByteArrayInputStream(xml.getBytes("UTF-8")) + val reader = new XmlReader(bytes) + val feed = new SyndFeedInput().build(reader) + Some(feed) + } catch { + case _: Throwable => None + } + + def extractFeed(link: Option[String]): Option[SyndFeed] = + link.map(extractFeed).flatten + + def parseHtml(html: String): Document = Jsoup.parse(html) + + def headElements(doc: Document): List[Element] = + doc.head.children.toList +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/model/BetaUser.scala b/src/main/scala/com/joereader/model/BetaUser.scala @@ -2,12 +2,8 @@ package com.joereader.model import net.liftweb._ import common._ -import record.field._ import mongodb.record._ import mongodb.record.field._ -import mongodb.BsonDSL._ - -import org.bson.types._ /** @@ -16,9 +12,6 @@ import org.bson.types._ class BetaUser private () extends MongoRecord[BetaUser] with StringPk[BetaUser] { def meta = BetaUser - - /* Users that couldn't validate their blog because of Wordpress. */ - object writesWordPress extends BooleanField(this, false) } object BetaUser extends BetaUser with MongoMetaRecord[BetaUser] { diff --git a/src/main/scala/com/joereader/model/Blog.scala b/src/main/scala/com/joereader/model/Blog.scala @@ -3,128 +3,138 @@ package com.joereader.model import net.liftweb._ import common._ import record.field._ +import mongodb._ import mongodb.record._ import mongodb.record.field._ import mongodb.BsonDSL._ -import com.joereader.lib.Helper._ +import com.joereader.lib._ import org.bson.types._ -import net.liftweb.mongodb.PatternSerializer -class Blog private () extends MongoRecord[Blog] with ObjectIdPk[Blog] { +class Blog private() extends MongoRecord[Blog] with ObjectIdPk[Blog] { def meta = Blog object name extends StringField(this, 64) + object description extends TextareaField(this, 500) + object urlRss extends MongoListField[Blog, String](this) + object img extends StringField(this, 50) + + object bgImg extends StringField(this, 50) + + /* html web page url of this blog */ object urlHtml extends StringField(this, 500) { def format(s: ValueType) = URLFormatter(s).hostAndPath + override def setFilter = format _ :: super.setFilter } - object img extends StringField(this, 50) - object bgImg extends StringField(this, 50) - - // blogname is the blog's site url - object blogname extends StringField(this,64) { + /* blogname is the blog's site url */ + object blogname extends StringField(this, 64) { override def setFilter = toLower _ :: trim _ :: super.setFilter } - // owner is the writer that has control to edit blog + /* owner is the writer that has control to edit blog */ object owner extends ObjectIdRefField(this, User) { - override def defaultValue = new ObjectId("0"*24) + override def defaultValue = new ObjectId("0" * 24) } - def isOwner(u: User) = owner.is == u.id.is - def nonOwner(u: User) = !isOwner(u) - def hasOwner: Boolean = owner.is != owner.defaultValue - // other writers - object writers extends BsonRecordListField(this, BlogWriter) + def isOwner(bu: Box[User]): Boolean = bu.exists(isOwner) - def modifyWriter(bw: BlogWriter) = { - val exists = writers.get.find(_.user.is == bw.user.is) - val clean = exists.map(writers.get diff List(_)) - clean.map(c => writers(bw :: c)) - this - } + def isOwner(u: User): Boolean = owner.get == u.id.get + + def nonOwner(u: User): Boolean = !isOwner(u) + + def hasOwner: Boolean = owner.get != owner.defaultValue + + /* other blog writers */ + object writers extends BsonRecordListField(this, BlogWriter) def addWriter(bw: BlogWriter) = - if(writers.get.exists(_.name.is == bw.name.is)) this + if (writers.get.exists(_.name.get == bw.name.get)) this else writers(bw :: writers.get) - // if writer exists, only update user id. + /* + * If writer exists, only update user id and image. + * (everything except name [because it doesn't change] and followers) + */ def addWriterSafely(bw: BlogWriter) = { - val existing = writer(bw.name.is).map(_.user(bw.user.is)) - if(!existing.isDefined) addWriter(bw) + val existing = writer(bw.name.get). + map(_.user(bw.user.get).img(bw.img.get)) + + if (existing.isEmpty) addWriter(bw) else this } - def removeWriter(bw: BlogWriter) = writers(writers.get.filter(_.name.get != bw.name.get)) - def removeWriter(n: String) = writers(writers.get.filter(_.name.is != n)) + def removeWriter(bw: BlogWriter) = + writers(writers.get.filterNot(_.name.get == bw.name.get)) + + def removeWriter(n: String) = + writers(writers.get.filterNot(_.name.get == n)) + + /* List of names of all writers. */ + def writersNames: List[String] = writers.get.map(_.name.get) + + /* Blog writers that have registered with the website. */ + def registeredWriters: List[BlogWriter] = + writers.get.filterNot(u => u.user.get == u.user.defaultValue) - def writersNames: List[String] = writers.is.map(_.name.is) + /* Blog writers that haven't registered with the website. */ + def unregisteredWriters: List[BlogWriter] = + writers.get diff registeredWriters - def registeredWriters: List[BlogWriter] = writers.is.filter(u => u.user.is != u.user.defaultValue) - def unregisteredWriters: List[BlogWriter] = writers.is diff registeredWriters + def registeredWritersNames = registeredWriters.map(_.name.get) - def registeredWritersNames = registeredWriters.map(_.name.is) - def unregisteredWritersNames = unregisteredWriters.map(_.name.is) + def unregisteredWritersNames = unregisteredWriters.map(_.name.get) + /* Finds blog writer by name. */ def writer(name: String): Box[BlogWriter] = - writers.is.find(_.name.is == name) match { + writers.get.find(_.name.get == name) match { case Some(x) => Full(x) case None => Empty } + /* Finds blog writer by user id. */ def writer(user: User): Box[BlogWriter] = - writers.is.find(_.user.is == user.id.is) match { + writers.get.find(_.user.get == user.id.get) match { case Some(x) => Full(x) case None => Empty } - def writerExists(name: String): Boolean = writers.is.exists(_.name.is == name) + /* Checks if writer under the name of _ exists. */ + def writerExists(name: String): Boolean = + writers.get.exists(_.name.get == name) } object Blog extends Blog with MongoMetaRecord[Blog] { override def collectionName = "blog.blogs" + override def formats = super.formats + new PatternSerializer ensureIndex(name.name -> 1, unique = false) ensureIndex(blogname.name -> 1, unique = true) ensureIndex(urlHtml.name -> 1, unique = true) + def defaultId = new ObjectId("0" * 24) + def findByStringId(id: String): Box[Blog] = - if (ObjectId.isValid(id) && id != "0"*24) find(new ObjectId(id)) else Empty + if (ObjectId.isValid(id) && id != defaultId.toString) + find(new ObjectId(id)) + else + Empty def findByName(in: String): Box[Blog] = find(name.name, in) + def findByBlogName(in: String): Box[Blog] = find(blogname.name, in) + def findByUrl(in: String): Box[Blog] = { val pattern = URLFormatter(in).pattern - find("urlHtml"-> ("$regex" -> pattern.pattern) ~ ("$flags" -> pattern.flags)) + find("urlHtml" -> + ("$regex" -> pattern.pattern) ~ ("$flags" -> pattern.flags)) } -} - -class BlogWriter private () extends BsonRecord[BlogWriter] { - def meta = BlogWriter - - object name extends StringField(this, 100) // name from rss feed - object email extends StringField(this, 100) // if email is present, assume he was invited - object user extends ObjectIdRefField(this, User) { // if user is present, assume he joined - override def defaultValue = new ObjectId("0"*24) - } - - def isUser(u: User) = user.is == u.id.is - def nonUser(u: User) = !isUser(u) - def hasUser: Boolean = user.is != user.defaultValue - def removeUser() {user(user.defaultValue)} - - object followers extends ObjectIdRefListField(this, User) - def addFollower(user: User) = followers(user.id.get :: followers.get) - def removeFollower(user: User) = followers(followers.get.filterNot(_ == user.id.get)) -} -object BlogWriter extends BlogWriter with BsonMetaRecord[BlogWriter] - +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/model/BlogWriter.scala b/src/main/scala/com/joereader/model/BlogWriter.scala @@ -0,0 +1,65 @@ +package com.joereader.model + +import net.liftweb._ +import record.field._ +import mongodb.record._, field._ + +import org.bson.types.ObjectId + +/** + * BlogWriter is a writer of a blog. Users literally follow this instead of + * another user because not all blog writers are registered. + */ +class BlogWriter private() extends BsonRecord[BlogWriter] { + def meta = BlogWriter + + // name from rss feed + object name extends StringField(this, 255) + + // if email is present, assume he was invited + object email extends StringField(this, 255) + + // hex value of primary color + object color extends StringField(this, 255) { + override def defaultValue = "#5bc1e9" + override def get = if(super.get.isEmpty) defaultValue else super.get + } + + // the user representing this blog writer + object user extends ObjectIdRefField(this, User) { + override def defaultValue = new ObjectId("0" * 24) + } + + object followers extends ObjectIdRefListField(this, User) + + object categories extends MongoListField[BlogWriter, String](this) + + // url to image found in rss feed of blog writer + object img extends StringField(this, 255) + + def isUser(u: User) = user.get == u.id.get + + def nonUser(u: User) = !isUser(u) + + def hasUser: Boolean = user.get != user.defaultValue + + def removeUser() { + user(user.defaultValue) + } + + def addFollower(user: User) = followers(user.id.get :: followers.get) + + def removeFollower(user: User) = + followers(followers.get.filterNot(_ == user.id.get)) + + def addCategory(category: String) = + if (categories.get.exists(_ == category)) this + else categories(category :: categories.get) + + def removeCategory(category: String) = + categories(categories.get.filter(_ != category)) + + def categoryExists(s: String) = categories.get.exists(_ == s) +} + +object BlogWriter extends BlogWriter with BsonMetaRecord[BlogWriter] diff --git a/src/main/scala/com/joereader/model/BlogWriterUser.scala b/src/main/scala/com/joereader/model/BlogWriterUser.scala @@ -0,0 +1,129 @@ +package com.joereader.model + +import com.joereader._ +import config._, S3Config._ + +import net.liftmodules.extras.Gravatar +import net.liftweb.common._ + +/** + * Things get a tad ugly. A user is allowed to follow another user's blog, but + * for flexibility, we allow user's to follow BlogWriter's that haven't + * registered. So what type are we following? User or BlogWriter? + * BlogWriterUser solves that problem. + */ +case class BlogWriterUser( + user: Option[User], + blog: Option[Blog] = None, + blogWriter: Option[BlogWriter] = None) { + + def gravatarSize = 300 + private def defaultColor: String = BlogWriter.createRecord.color.defaultValue + + /* + * Creates a link that points to the user's page + * if present else the blog writer's page. + */ + val link: String = + user.map(Site.userProfileLoc.calcHref) getOrElse + (for{ + blogWriter <- blogWriter + blog <- blog + } yield Site.blogWriterProfileLoc.calcHref(this)).getOrElse("/") + + /* + * We try everything possible to display the writer's image. + * Below is the order of importance. + * 1. user's image + * 2. user's email gravatar + * 3. blogWriters's image + * 4. blogWriter's email gravatar + * 5. mrnoman + */ + + private def userImage: Option[String] = { + val img = user.map(_.img.get) getOrElse "" + if (img.isEmpty) None else Some(s3.fileUrl(img)) + } + + private def userEmailImage: Option[String] = { + val email = user.map(_.email.get) getOrElse "" + if (email.isEmpty) None else Some(Gravatar.imageUrl(email, gravatarSize)) + } + + private def blogWriterImage: Option[String] = { + val img = blogWriter.map(_.img.get) getOrElse "" + if (img.isEmpty) None else Some(img) + } + + private def blogWriterEmailImage: Option[String] = { + val email = blogWriter.map(_.email.get) getOrElse "" + if (email.isEmpty) None else Some(Gravatar.imageUrl(email, gravatarSize)) + } + + def image: String = List(userImage, userEmailImage, blogWriterImage, + blogWriterEmailImage, Some(s3.fileUrl("mr_noman"))).filter(_.isDefined).head.get + + + def name: String = user.map(_.name.get). + getOrElse(blogWriter.map(_.name.get).getOrElse("")) + + def dashName = name.split(" ").mkString("-") + + def categories = + blogWriter.map(_.categories.get.mkString(", ")).getOrElse("") + + def color = blogWriter.map(_.color.get).getOrElse(defaultColor) + + def url = blog.map(_.urlHtml.get).getOrElse("") + + val id: String = user.map(_.id.get.toString).getOrElse( + blogWriter.map(bw => blog.map(BlogWriterUser.create(bw, _))).flatten. + getOrElse("")).split(" ").mkString("-") + +} + +object BlogWriterUser { + + import collection.breakOut + + lazy val separator: String = "~" + + /* Creates string format given the type */ + def create(writer: BlogWriter, blog: Blog): String = + blog.id.get + separator + writer.name.is + + /* Creates string format given the type */ + def create(user: User): String = user.id.get + separator + "sa" + + /* + * Removes repeats by grouping user id if user is available, else by + * grouping the string format of blog-writer to blog + */ + def uniqueOnly(l: Seq[BlogWriterUser]): List[BlogWriterUser] = + l.groupBy { + bwu => + val userId = bwu.user.map(_.id.get) + val writer = + for (bw <- bwu.blogWriter; blog <- bwu.blog) + yield create(bw, blog) + + userId.getOrElse(writer getOrElse "") + }.map { + _._2.head + }(breakOut) + + // parse and encode functions are used by SiteMap + def parse(path: List[String]): Box[BlogWriterUser] = { + val blog = Blog.findByBlogName(path(0)) + val blogWriter = blog.map(_.writer(path(1).replace('+', ' '))).openOr(Empty) + + for (b <- blog; bw <- blogWriter) + yield BlogWriterUser(None, blog, blogWriter) + } + + def encode(bwu: BlogWriterUser): List[String] = List( + bwu.blog.map(_.blogname.get), + bwu.blogWriter.map(_.name.get) + ).flatten +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/model/Category.scala b/src/main/scala/com/joereader/model/Category.scala @@ -6,12 +6,8 @@ import record.field._ import mongodb.record._ import net.liftweb.mongodb.record.field._ -import org.bson.types._ - - /** - * Collection of urls for blogs and users. Url name must be unique. - * ie. if url=johnsmith then joereader.com/johnsmith + * Categories with a list of users that write for that category. */ class Category private () extends MongoRecord[Category] { @@ -25,20 +21,68 @@ class Category private () extends MongoRecord[Category] { } // Users that fall under this category - object users extends ObjectIdRefListField(this, User) + object writers extends MongoListField[Category, String](this) { + + import BlogWriterUser._ + + /* Given an index, gets a BlogWriterUser */ + def writer(index: Int): Box[BlogWriterUser] = { + val id = get(index).split(separator).head + val who = get(index).split(separator).tail.mkString + + (for { + blog <- Blog.findByStringId(id) + blogWriter <- blog.writer(who) + } yield { + val user = blogWriter.user.obj + + // remove blogWriter if it's not attached to a user + if (user.isEmpty) { + removeWriter(blog, blogWriter).update + Empty + } + else user.map(user => + BlogWriterUser(Some(user), Some(blog), Some(blogWriter))) + + }) openOr Empty + } - def addUser(u: User) = - if(users.get.exists(_ == u.id.is)) this - else users(u.id.is :: users.get) + def randomWriters(n: Int): List[BlogWriterUser] = + uniqueOnly { + if (get.size > 0) { + val random = + Seq.fill(n)(scala.util.Random.nextInt(get.size)).distinct + random.map(writer).flatten + } + else Nil + } - def removeUser(u: User) = { - users(users.get.filter(_ != u.id.is)) - if(users.is.isEmpty) delete_! + } + + def addWriter(blog: Blog, blogWriter: BlogWriter) = { + val addMe = BlogWriterUser.create(blogWriter, blog) + if(writers.get.exists(_ == addMe)) this + else writers(addMe :: writers.get) + } + + def removeWriter(blog: Blog, blogWriter: BlogWriter) = { + val removeMe = BlogWriterUser.create(blogWriter, blog) + writers(writers.get.filter(_ != removeMe)) + if(writers.is.isEmpty) delete_! this } + + override def update: Category = super.update } object Category extends Category with MongoMetaRecord[Category] { override def collectionName = "category.categories" override def find(in: String): Box[Category] = find(id.name, in) + + def random(n: Int): Seq[Category] = { + val all = Category.findAll + val random = + Seq.fill(n)(scala.util.Random.nextInt(all.size)).distinct + random.map(all) + } } \ No newline at end of file diff --git a/src/main/scala/com/joereader/model/InviteToken.scala b/src/main/scala/com/joereader/model/InviteToken.scala @@ -35,15 +35,13 @@ object InviteToken extends InviteToken with MongoMetaRecord[InviteToken] { lazy val inviteTokenUrl = "/invite-token" lazy val inviteTokenExpires: ReadablePeriod = Days.days(30) - def url(inst: InviteToken): String = "%s%s?token=%s".format(S.hostAndPath, inviteTokenUrl, inst.id.toString()) + def url(inst: InviteToken): String = "%s%s?token=%s". + format(S.hostAndPath, inviteTokenUrl, inst.id.toString()) - def create(email: String, name: String, blogId: ObjectId): InviteToken = { + def create(email: String, name: String, blogId: ObjectId): InviteToken = createRecord.email(email).name(name).blogId(blogId).save - } - def deleteAllByEmail(e: String) { - delete(email.name, e) - } + def deleteAllByEmail(e: String) { delete(email.name, e) } def findByStringId(in: String): Box[InviteToken] = if (ObjectId.isValid(in)) find(new ObjectId(in)) diff --git a/src/main/scala/com/joereader/model/User.scala b/src/main/scala/com/joereader/model/User.scala @@ -1,141 +1,197 @@ package com.joereader package model -import org.bson.types.ObjectId +import org.bson.types._ import net.liftweb._ +import util._ import common._ import http.{StringField => _, BooleanField => _, _} -import mongodb.record.field._ +import mongodb.record._, field._ import record.field._ import net.liftmodules.mongoauth._, model._ -import net.liftweb.mongodb.record.{BsonMetaRecord, BsonRecord} + +import com.joereader._ +import config._ +import snippet._ + import net.liftweb.common.Full -import com.joereader.config.Site -import net.liftweb.util.{StringHelpers, Helpers} -import com.joereader.snippet.{VerifiedVar, BlogIdVar} -class User private () extends ProtoAuthUser[User] with ObjectIdPk[User] { +class User private() extends ProtoAuthUser[User] with ObjectIdPk[User] { def meta = User def userIdAsString: String = id.toString() object name extends StringField(this, 64) - def firstName = name.is.split(" ").headOption getOrElse "" + + def firstName = name.get.split(" ").headOption getOrElse "" object about extends TextareaField(this, 500) + object img extends StringField(this, 50) + object bgImg extends StringField(this, 50) + object introVid extends StringField(this, 20) object otherVid extends MongoListField[User, String](this) def addVideo(v: String) = - if(otherVid.get.exists(_ == v)) this else otherVid(v :: otherVid.get) + if (otherVid.get.exists(_ == v)) this + else otherVid(v :: otherVid.get) - def removeVideo(v: String) = otherVid(otherVid.get.filter(_ != v)) + def removeVideo(v: String) = + otherVid(otherVid.get.filter(_ != v)) - object blogs extends BsonRecordListField(this, UserBlog) + object blogs extends ObjectIdRefListField(this, Blog) - def modifyBlog(ub: UserBlog) = { - val exists = blogs.get.find(_.blog.is == ub.blog.is) - val clean = exists.map(blogs.get diff List(_)) - clean.map(c => blogs(ub :: c)) - this - } + def isWriter: Boolean = !blogs.get.isEmpty - def addBlog(ub: UserBlog) = - if(blogs.get.exists(_.blog.is == ub.blog.is)) this - else blogs(ub :: blogs.get) + def addBlog(blog: Blog) = + if (blogs.get.exists(_ == blog.id.get)) this + else blogs(blog.id.get :: blogs.get) - def removeBlog(ub: UserBlog) = blogs(blogs.get.filter(_.blog.is != ub.blog.is)) + def removeBlog(blog: Blog) = + blogs(blogs.get.filterNot(_ == blog.id.get)) - def blog(b: Blog) = - blogs.is.find(_.blog.is == b.id.is) match { - case Some(x) => Full(x) - case None => Empty - } /* Contains a list of blog id to writer name separated by separator. */ - object following extends MongoListField[User,String](this) { - lazy val separator: String = "~" - - def create(writer: BlogWriter, blog: Blog): String = blog.id.is + separator + writer.name.is - def create(user: User): String = user.id.get + separator + "sa" - - def user(n: Int): Box[UserUserBlog] = { - val id = get(n).split(separator).head - val who = get(n).split(separator).tail.mkString - - if(who == "sa") for { - user <- User.findByStringId(id) - userBlog <- user.blogs.get.headOption - } yield UserUserBlog(user, userBlog) // ??? - - else for { - blog <- Blog.findByStringId(id) - blogWriter <- blog.writer(who) - user <- blogWriter.user.obj - userBlog <- user.blog(blog) - } yield UserUserBlog(user, userBlog) + object following extends MongoListField[User, String](this) { + + import BlogWriterUser._ + + /* Given an index, gets a BlogWriterUser */ + def user(index: Int): Box[BlogWriterUser] = { + val id = get(index).split(separator).head + val who = get(index).split(separator).tail.mkString + + if (who == "sa") + for (user <- User.findByStringId(id)) + yield BlogWriterUser(Some(user)) + + else + for { + blog <- Blog.findByStringId(id) + blogWriter <- blog.writer(who) + } yield { + val user = blogWriter.user.obj + BlogWriterUser(user, Some(blog), Some(blogWriter)) + } } - def randomUsers(n: Int): Set[UserUserBlog] = { - val random = Seq.fill(n)(scala.util.Random.nextInt(get.size)).toSet - random.map(user).flatten - } - def allUsers: Set[UserUserBlog] = (0 until get.size).map(user).flatten.toSet + def randomUsers(n: Int): List[BlogWriterUser] = + uniqueOnly { + if (get.size > 0) { + val random = + Seq.fill(n)(scala.util.Random.nextInt(get.size)).distinct + random.map(user).flatten + } + else Nil + } + + def allUsers: List[BlogWriterUser] = + uniqueOnly((0 until get.size).map(user).flatten) + + def exists(bw: BlogWriter, blog: Blog): Boolean = + get.exists(_ == create(bw, blog)) + + def exists(u: User): Boolean = + get.exists(_ == create(u)) } def follow(writer: BlogWriter, blog: Blog) = { - val addMe = following.create(writer,blog) - if(!blog.writerExists(writer.name.is) || following.get.exists(_ == addMe)) this - else following(addMe :: following.get) + val addMe = BlogWriterUser.create(writer, blog) + + if (!blog.writerExists(writer.name.is) || + following.get.exists(_ == addMe)) + this + else + following(addMe :: following.get) } def follow(user: User) = { - val addMe = following.create(user) - if(following.get.exists(_ == addMe)) this + val addMe = BlogWriterUser.create(user) + if (following.get.exists(_ == addMe)) this else following(addMe :: following.get) } - def unFollow(writer: BlogWriter, blog: Blog) = { - following(following.get.filterNot(_ == following.create(writer,blog))) - } + def unFollow(writer: BlogWriter, blog: Blog) = + following(following.get. + filterNot(_ == BlogWriterUser.create(writer, blog))) - def unFollow(user: User) = { - following(following.get.filterNot(_ == following.create(user))) - } + def unFollow(user: User) = + following(following.get. + filterNot(_ == BlogWriterUser.create(user))) - // follow this user to view his shared articles (only if this user is a writer) + /* + * Follow this user to view his shared articles + * (only if this user is a writer) + */ object followers extends ObjectIdRefListField(this, User) - def addFollower(user: User) = followers(user.id.get :: followers.get) - def removeFollower(user: User) = followers(followers.get.filterNot(_ == user.id.get)) + def addFollower(user: User) = + followers(user.id.get :: followers.get) + def removeFollower(user: User) = + followers(followers.get.filterNot(_ == user.id.get)) + + /* + * Get a set of user id's following this user + * (that should be a writer) + */ + def followersIds: List[String] = { + val blogFollowers: List[String] = + (for { + blogs <- blogs.get + blog <- Blog.find(blogs) + writer <- blog.writer(this) + } yield + writer.followers.get.map(_.toString)).flatten + + val sharedArticlesFollowers: List[String] = + followers.get.map(_.toString) + + (blogFollowers ::: sharedArticlesFollowers).distinct + } + + object saved extends MongoListField[User, String](this) + object shared extends MongoListField[User, String](this) } object User extends User with ProtoAuthUserMeta[User] with Loggable { + import mongodb.BsonDSL._ override def collectionName = "user.users" + ensureIndex(email.name -> 1, unique = true) ensureIndex(username.name -> 1, unique = true) ensureIndex(name.name -> 1, unique = false) + def defaultId = new ObjectId("0" * 24) + def findByEmail(in: String): Box[User] = find(email.name, in) + def findByName(in: String): Box[User] = find(name.name, in) - def findByUsername(in: String): Box[User] = find(username.name, in.toLowerCase) + + def findByUsername(in: String): Box[User] = + find(username.name, in.toLowerCase) def findByStringId(id: String): Box[User] = - if (ObjectId.isValid(id) && id != "0"*24) find(new ObjectId(id)) else Empty + if (ObjectId.isValid(id) && id != "0" * 24) + find(new ObjectId(id)) + else + Empty + + override def onLogIn: List[User => Unit] = + List(user => User.loginCredentials.remove()) - override def onLogIn: List[User => Unit] = List(user => User.loginCredentials.remove()) override def onLogOut: List[Box[User] => Unit] = List( x => logger.debug("User.onLogOut called."), - boxedUser => boxedUser.foreach { u => - ExtSession.deleteExtCookie() + boxedUser => boxedUser.foreach { + u => + ExtSession.deleteExtCookie() } ) @@ -155,7 +211,9 @@ object User extends User with ProtoAuthUserMeta[User] with Loggable { val resp = S.param("token").flatMap(LoginToken.findByStringId) match { case Full(at) if at.expires.isExpired => { at.delete_! - RedirectWithState(indexUrl, RedirectState(() => { S.error("Login token has expired") })) + RedirectWithState(indexUrl, RedirectState(() => { + S.error("Login token has expired") + })) } case Full(at) => find(at.userId.is).map(user => { if (user.validate.length == 0) { @@ -167,10 +225,16 @@ object User extends User with ProtoAuthUserMeta[User] with Loggable { else { at.delete_! regUser(user) - RedirectWithState(registerUrl, RedirectState(() => { S.notice("Please complete the registration form") })) + RedirectWithState(registerUrl, RedirectState(() => { + S.notice("Please complete the registration form") + })) } - }).openOr(RedirectWithState(indexUrl, RedirectState(() => { S.error("User not found") }))) - case _ => RedirectWithState(indexUrl, RedirectState(() => { S.warning("Login token not provided") })) + }).openOr(RedirectWithState(indexUrl, RedirectState(() => { + S.error("User not found") + }))) + case _ => RedirectWithState(indexUrl, RedirectState(() => { + S.warning("Login token not provided") + })) } Full(resp) @@ -206,43 +270,61 @@ object User extends User with ProtoAuthUserMeta[User] with Loggable { def handleInviteToken(): Box[LiftResponse] = { var resp: LiftResponse = DoRedirectResponse("/") + S.param("token").flatMap(InviteToken.findByStringId) match { case Full(at) if at.expires.isExpired => at.delete_! - resp = RedirectWithState(indexUrl, RedirectState(() => { S.error("Invite token has expired.") })) + resp = RedirectWithState(indexUrl, RedirectState(() => { + S.error("Invite token has expired.") + })) case Full(at) => + + // Find user if he is registered already else create user val userBoxed = findByEmail(at.email.is) val user: User = userBoxed openOr { createRecord. - email(at.email.is). - name(at.name.is). + email(at.email.get). + name(at.name.get). password(StringHelpers.randomString(20), true). username(StringHelpers.randomString(15)) } - user.verified(true).addBlog(UserBlog.createRecord.blog(at.blogId.is)).save - val blog = Blog.findByStringId(at.blogId.is.toString) - blog.map(_.addWriterSafely(BlogWriter.createRecord.user(user.id.is).name(at.name.is)).save) - if(isLoggedIn) logUserOut() - logUserIn(user, isAuthed = true) - - blog.map { blog => - if(userBoxed.isDefined) - resp = RedirectWithState(Site.categoriesLoc.calcHref(blog), RedirectState(() => { - S.notice("Congratulations! You are verified with "+blog.name.is+"!") - })) - else - resp = RedirectWithState(Site.signUp3.url, RedirectState(() => { - BlogIdVar(at.blogId.is.toString) - VerifiedVar(true) - S.notice("Congratulations! You are verified with "+blog.name.is+"!") - })) + Blog.findByStringId(at.blogId.get.toString).map { + blog => + val blogWriter = + BlogWriter.createRecord.user(user.id.get).name(at.name.get) + + blog.addWriterSafely(blogWriter).save + user.addBlog(blog).verified(true).save + + if (userBoxed.isDefined) + resp = RedirectWithState(Site.categoriesLoc.calcHref(blog), + RedirectState(() => { + S.notice("Congratulations! You are verified with " + + blog.name.get + "!") + })) + else + resp = RedirectWithState(Site.signUp3.url, + RedirectState(() => { + BlogIdVar(at.blogId.get.toString) + VerifiedVar(true) + S.notice("Congratulations! You are verified with " + + blog.name.get + "!") + })) } + + // Remove token at.delete_! + // Log user in + if (isLoggedIn) logUserOut() + logUserIn(user, isAuthed = true) + case _ => - resp = RedirectWithState(indexUrl, RedirectState(() => { S.warning("Invite token is missing.") })) + resp = RedirectWithState(indexUrl, RedirectState(() => { + S.warning("Invite token is missing.") + })) } Full(resp) @@ -264,11 +346,14 @@ object User extends User with ProtoAuthUserMeta[User] with Loggable { | |Yours truly, |%s - """.format(name, user.name.is, blog.name.is, token.url, MongoAuth.systemUsername.vend).stripMargin + """. + format(name, user.name.get, blog.name.get, token.url, + MongoAuth.systemUsername.vend).stripMargin sendMail( From(MongoAuth.systemFancyEmail), - Subject("%s invites you to %s".format(user.name.is, MongoAuth.siteName.vend)), + Subject("%s invites you to %s". + format(user.name.get, MongoAuth.siteName.vend)), To(email), PlainMailBodyType(msgTxt) ) @@ -276,11 +361,14 @@ object User extends User with ProtoAuthUserMeta[User] with Loggable { // used during login process (holds email address) object loginCredentials extends SessionVar[String]("") - object regUser extends SessionVar[User](createRecord.email(loginCredentials.is)) + + object regUser + extends SessionVar[User](createRecord.email(loginCredentials.is)) + } object SystemUser { - private val username = "joe" + private val username = "julio" private val email = "j.cabrra@gmail.com" lazy val user: User = User.find("username", username) openOr { @@ -294,23 +382,20 @@ object SystemUser { } } -class UserBlog private () extends BsonRecord[UserBlog] { - def meta = UserBlog +object TestUser { + private val username = "testuser" + private val email = "test@example.com" - object blog extends ObjectIdRefField(this, Blog) { - override def defaultValue = new ObjectId("0"*24) + lazy val user: User = User.find("username", username) openOr { + User.createRecord + .name("Test Man") + .username(username) + .email(email) + .verified(true) + .password("test1234", isPlain = true) + .save } - object categories extends MongoListField[UserBlog, String](this) - - def addCategory(category: String) = - if(categories.get.exists(_ == category)) this - else categories(category :: categories.get) - - def removeCategory(category: String) = categories(categories.get.filter(_ != category)) - -} -object UserBlog extends UserBlog with BsonMetaRecord[UserBlog] - -case class UserUserBlog(user: User, userBlog: UserBlog) - + def isLoggedIn = User.currentUser. + exists(_.username.get == TestUser.user.username.get) +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/ArticleSnip.scala b/src/main/scala/com/joereader/snippet/ArticleSnip.scala @@ -0,0 +1,43 @@ +package com.joereader.snippet + +import net.liftweb.util._ +import Helpers._ + +import com.joereader._ +import lib.rss._ +import model._ + +/** + * Mix this in to list articles. + */ +trait ArticleSnip { + + def articles(entries: List[(FeedEntry, BlogWriterUser)]): CssSel = + ".article *" #> entries.map { + f => + val (entry, bwu) = f + + ".article-save [style]" #> ("color:"+bwu.color) & + ".article-share [style]" #> ("color:"+bwu.color) & + ".article-inner [style]" #> ("border-right:3px solid "+bwu.color) & + ".article-key [class+]" #> bwu.id & + ".article-user-link [href]" #> bwu.link & + "#article-user-image [src]" #> bwu.image & + "#article-user-name *" #> bwu.name & + ".timeago [datetime]" #> entry.df.format(entry.date) & + ".title a [href]" #> entry.link & + ".title a *" #> entry.title & + "#article-content" #> entry.content + } & + "#reader-writers" #> { + "#writer" #> entries.distinct.groupBy(_._2).map { + f => + val bwu = f._1 + + "#writer [id]" #> bwu.id & + "#writer-image [src]" #> bwu.image & + "#writer-name *" #> bwu.name & + "a [href]" #> bwu.link + } + } +} diff --git a/src/main/scala/com/joereader/snippet/BackgroundSnip.scala b/src/main/scala/com/joereader/snippet/BackgroundSnip.scala @@ -0,0 +1,95 @@ +package com.joereader.snippet + +import net.liftweb._ +import common._ +import http.SHtml._ +import util._, Helpers._ + +import scala.xml._ + +import com.joereader._ +import snippet.SnipHelpers._ +import config._, S3Config._ +import model._ +import lib._ + +/** + * Handles profile backgrounds + */ +trait BackgroundSnip { + + val backgrounds = Seq( + "big-island", + "blue-grid", + "orangey", + "breen-gloss", + "rainbow-landscape", + "water-wave" + ) + + def listBackgrounds(currentDefault: String, bwu: BlogWriterUser) = ajaxRadio[String]( + backgrounds, + backgrounds.find(_ == currentDefault), + { + s => + bwu.user.map(_.bgImg(s).update) + bwu.blog.map(_.bgImg(s).update) + UpdateImage(imgBgId, S3Config.s3.fileUrl(s)) + } + ).backgroundChoices + + def radios = ajaxRadio[Boolean]( + Seq(true, false), + Full(true), + { + bool => + if(bool) Show("backgrounds-list") & Hide("bg-fileupload") + else Show("bg-fileupload") & Hide("backgrounds-list") + } + ) + + def defaultBackground: String = s3.fileUrl(backgrounds.head) + + def imageBgUrl(u: User) = + if (u.bgImg.get.nonEmpty) s3.fileUrl(u.bgImg.get) + else defaultBackground + + def imageBgUrl(b: Blog) = + if (b.bgImg.get.nonEmpty) s3.fileUrl(b.bgImg.get) + else defaultBackground + + def uploadBgImg(user: User): CssSel = { + + val radios = this.radios + + "#bg-fileupload *" #> + insertFileUpload(ImageUpload.background, ImageUpload.userUrl) & + "#backgrounds-list *" #> + listBackgrounds(user.bgImg.get, BlogWriterUser(Some(user))) & + "#backgrounds-q-yes" #> radios(0) & + "#backgrounds-q-no" #> radios(1) + } + + def uploadBgImg(blog: Blog): CssSel = { + + val radios = this.radios + + "#bg-fileupload *" #> + insertFileUpload(ImageUpload.background, ImageUpload.blogUrl(blog)) & + "#backgrounds-list *" #> + listBackgrounds(blog.bgImg.get, BlogWriterUser(None, Some(blog))) & + "#backgrounds-q-yes" #> radios(0) & + "#backgrounds-q-no" #> radios(1) + } + + + implicit class BackgroundsRadioDesign2Implicit(choices: ChoiceHolder[String]) { + def backgroundChoices: NodeSeq = { + for (i <- 0 until choices.items.size) yield { + val bg = backgrounds(i) + val item = choices.items(i) + <label>{item.xhtml}<img src={S3Config.s3.fileUrl(bg)}/></label> + } + } + } +} diff --git a/src/main/scala/com/joereader/snippet/BlogSnipEdit.scala b/src/main/scala/com/joereader/snippet/BlogSnipEdit.scala @@ -0,0 +1,90 @@ +package com.joereader.snippet + +import net.liftweb._ +import common._ +import util._, Helpers._ +import http._, SHtml._, js._, JsCmds._ + +import com.joereader._ +import model._ +import config._ +import lib._ +import snippet.SnipHelpers._ + +import scala.xml._ + +/** + * Snippets for the owner of a blog to edit Blog model information. + * Must extend View trait so these snips can fallback to the view snips + * if the test fails. + */ +trait BlogSnipEdit extends BlogSnipView with BackgroundSnip { + + // only allow blog owner + // must be kept private so it won't be overridden by sub or super classes + private def test = blog.exists(_ isOwner User.currentUser) + + def name(html: NodeSeq) = serve(html) { + blog => + "*" #> ajaxText(blog.name.is, { + s => blog.name(s).update; Noop + }, "placeholder" -> "Name of blog") + }(test, super.name) + + def description(html: NodeSeq) = serve(html) { + blog => + "*" #> ajaxTextarea(blog.description.is, { + s => blog.description(s).update; Noop + }, "placeholder" -> "Enter blog description here") + }(test, super.description) + + def uploadImg(html: NodeSeq) = serve(html) { + blog => + "*" #> insertFileUpload(ImageUpload.profile, ImageUpload.blogUrl(blog)) + }(test, NodeSeq.Empty) + + def uploadBgImg(html: NodeSeq): NodeSeq = serve(html) { + uploadBgImg + }(test, NodeSeq.Empty) + + def blogname(html: NodeSeq) = serve(html) { + blog => + + def check(s: String): JsCmd = { + val b = Blog.findByBlogName(s) + def isYou = b.exists(_.id.is == blog.id.is) + + if (!s.matches("^[a-z0-9-]{2,}$")) + S.error("Blog name can only contain " + + "alphanumeric or dash characters.") + else if (isYou) + Noop + else if (b.isEmpty) { + blog.blogname(s).update + Noop + } + else S.error("Blog name is taken") + } + + "*" #> ajaxText(blog.blogname.is, { + s: String => check(s.toLowerCase) + }) + }(test, super.blogname) + + def categoriesEdit(html: NodeSeq): NodeSeq = serve(html) { + blog => + "*" #> + Templates("templates-hidden" :: "parts" :: "categories" :: Nil).map { + ns => + Site.categoriesLoc.requestValue(Full(blog)) + ns + } + } (User.isLoggedIn, NodeSeq.Empty) + + /* + * Surround profile page with editable-page class so owner can edit + * on the spot + */ + def surround = + "#profile-wrap [class+]" #> (if (test) "editable-page" else "") +} diff --git a/src/main/scala/com/joereader/snippet/BlogSnipView.scala b/src/main/scala/com/joereader/snippet/BlogSnipView.scala @@ -0,0 +1,69 @@ +package com.joereader.snippet + +import net.liftweb._ +import util._, Helpers._ +import common._, Box._ + +import com.joereader._ +import config._ +import lib.rss._ +import snippet.SnipHelpers._ +import model._ + +import scala.xml._ + +/** + * Snippets for the public to view Blog model information. + */ +trait BlogSnipView extends BlogSnip with ArticleSnip with BackgroundSnip { + + // true so anyone can view blog snippets + // must be kept private so it won't be overridden by subclasses + private def test = true + + def name = serve { + blog => Text(blog.name.get) + }(test, NodeSeq.Empty) + + def blogname = serve { + user => Text(user.blogname.get) + }(test, NodeSeq.Empty) + + def url(html: NodeSeq) = serve(html) { + blog => + "a *" #> blog.urlHtml.get & + "a [href]" #> Site.blogProfileLoc.calcHref(blog) + }(test, NodeSeq.Empty) + + def description = serve { + blog => Text(blog.description.get) + }(test, NodeSeq.Empty) + + def img(html: NodeSeq) = serve(html) { + blog => + "* [id]" #> imgProfileId & + "* [src]" #> imageUrl(blog) + }(test, NodeSeq.Empty) + + def bgImg(html: NodeSeq) = serve(html) { + blog => + "* [id]" #> imgBgId & + "* [src]" #> imageBgUrl(blog) + }(test, NodeSeq.Empty) + + def articles(html: NodeSeq): NodeSeq = serve(html) { + blog => + + val entries = blog.urlRss.is.head.entries.filter( + e => blog.writerExists(e.author.name)).map { + entry => + val blogWriter: Box[BlogWriter] = blog.writer(entry.author.name) + val bwu = BlogWriterUser( + blogWriter.flatMap(_.user.obj), Some(blog), blogWriter) + (entry, bwu) + } + + articles(entries) + + }(test, NodeSeq.Empty) +} diff --git a/src/main/scala/com/joereader/snippet/BlogSnips.scala b/src/main/scala/com/joereader/snippet/BlogSnips.scala @@ -4,393 +4,104 @@ import scala.xml._ import net.liftweb._ import common._ -import http._ -import SHtml._ -import js._ -import JE._ -import JsCmds._ -import util._, Helpers._ +import util._ -import net.liftmodules.extras.{Gravatar, SnippetHelper} +import net.liftmodules.extras.SnippetHelper import com.joereader._ import com.joereader.model._ -import snippet.SnipHelpers._ -import lib._, Helper._, rss.Rss._ import config._ -import com.joereader.config.Site.BlogWriterContent -sealed trait BlogSnippet extends SnippetHelper with Loggable with PageHeader { +trait BlogSnip extends SnippetHelper with Loggable { - val headName = blog.map(_.name.get) getOrElse "" protected def blog: Box[Blog] - private def test = true - protected def serve(snip: Blog => NodeSeq)(test: Boolean, fallback: NodeSeq): NodeSeq = + protected def serve(snip: Blog => NodeSeq) + (test: Boolean, fallback: NodeSeq): NodeSeq = (for { b <- blog ?~ "Blog not found" } yield { - if(test) snip(b) else fallback + if (test) snip(b) else fallback }): NodeSeq - protected def serve(html: NodeSeq)(snip: Blog => CssSel)(test: Boolean, fallback: NodeSeq): NodeSeq = + protected def serve(html: NodeSeq) + (snip: Blog => CssSel) + (test: Boolean, fallback: NodeSeq): NodeSeq = (for { b <- blog ?~ "Blog not found" } yield { - if(test) snip(b)(html) else fallback + if (test) snip(b)(html) else fallback }): NodeSeq - - def name = serve(blog => Text(blog.name.is))(test, NodeSeq.Empty) - - def blogname = serve(user => Text(user.blogname.is))(test, NodeSeq.Empty) - - def url = serve(blog => Text(blog.urlHtml.is))(test, NodeSeq.Empty) - - def description = serve(blog => Text(blog.description.is))(test, NodeSeq.Empty) - - def img(html: NodeSeq) = serve(html)(blog => "* [src]" #> imageUrl(blog))(test, NodeSeq.Empty) - - def bgImg(html: NodeSeq) = serve(html)(blog => "* [src]" #> imageBgUrl(blog))(test, NodeSeq.Empty) - - def articles = serve(blog => blog.urlRss.is.head.entries.flatMap(e=> SnipHelpers.blogProfileArticles(e,blog)))(test, NodeSeq.Empty) - - def writers = serve(blog => writersList(blog.writers.get, owner = false))(test, NodeSeq.Empty) - - def writersList(writers: List[BlogWriter], owner: Boolean): NodeSeq = - (for(writer <- writers) yield { - val box = User.findByStringId(writer.user.is.toString) - val user = box.openOr(User.createRecord) - val name = if(box.isDefined) user.name.get else writer.name.get - val dashName = name.split(" ").mkString("-") - var msg = "" - - <div> - <div id={"writer-"+dashName} class="blog-writer"> - <a href={ - blog.map { blog => - if(box.isDefined) Site.userProfileLoc.calcHref(user) - else Site.blogWriterProfileLoc.calcHref(BlogWriterContent(blog, writer)) - }.openOr("/") - }><img id={"img-"+dashName} src={image200Url(user)}/></a> - <div class="name">{name} { - if(owner) <span><a onclick={Show("writer-edit-"+dashName).toJsCmd.toString}>Edit</a></span> - }</div> - </div> - <div id={"writer-edit-"+dashName} class="writer-edit-area hide"> - <div class="inline header"> - <div class="name">{name}</div> - <a onclick={Hide("writer-edit-"+dashName).toJsCmd.toString}>Exit</a> - </div> - { - def inviteForm: NodeSeq = - if(!writer.hasUser && owner) { - msg = "Who is "+name+"? Has he registered already? If so, input the email that the person used to" + - " sign up. If he's not registered, we'll send "+name+" an invite. " - - <input type="text" id={"email-"+dashName} placeholder="email" value={writer.email.get} /> - <button onclick={ajaxCall(JsArray(ValById("email-"+dashName), Str(name)), - invite)._2.toJsCmd.toString+"; return false;"} class="btn btn-primary">Invite</button> - } else NodeSeq.Empty - - def removeButton(): NodeSeq = - if(owner) { - msg = msg + "If you would like to remove this name, click remove button. " - <button onclick={ajaxCall(JsArray(Str(writer.name.get), Str(name)), removeAlert)._2.toJsCmd.toString+"; return false;"} class="btn btn-primary">Remove</button> - } else NodeSeq.Empty - - def detachButton(): NodeSeq = - if(owner && writer.hasUser) { - msg = msg + "To remove the user account connected to this name, click Detach User button." - <button id={"detach-btn-"+dashName} onclick={ajaxCall( - JsArray(Str(user.username.get), Str(writer.name.get), Str(name)), detachUserAlert). - _2.toJsCmd.toString+"; return false;"} class="btn btn-primary">Detach User</button> - } else NodeSeq.Empty - - <div class="input-append">{inviteForm ++ removeButton ++ detachButton}</div> - <div class="help-block"><small>{msg}</small></div> - }</div> - </div> - }.toSeq).+:(<style>{"#blog-writers .overview{width:"+(writers.size*200)+"px}"}</style>).flatten.flatten - - def removeAlert(in: String): JsCmd = { - val name = in.split(",").headOption.getOrElse("") - val displayName = in.split(",").tail.headOption.getOrElse("") - - for(blog <- blog; blogWriter <- blog.writer(name)) { - val followSize = blogWriter.followers.is.size - val msg = s"Are you sure you want to remove $displayName? ${ - if(followSize > 0) followSize +" followers will stop following!" else ""}" - - S.warning( - <div>{msg}</div> - <div> - <button onclick={ajaxCall(JsArray(Str(name), Str(displayName)), remove). - _2.toJsCmd.toString+"; return false;"} class="btn btn-warning">Yes</button> - <button onclick="$(document).trigger('clear-alerts')" class="btn">No</button> - </div> - ) - } - } - - def remove(in: String): JsCmd = { - val name = in.split(",").headOption.getOrElse("") - val displayName = in.split(",").tail.headOption.getOrElse("") - - for(blog <- blog; blogWriter <- blog.writer(name)) yield { - blogWriter.followers.objs.par.map(_.unFollow(blogWriter, blog).update) - blog.removeWriter(blogWriter).update - val dashName = displayName.split(" ").mkString("-") - Remove("writer-"+dashName) & Remove("writer-edit-"+dashName) & Hide("writer-edit-"+dashName) - } - } - - def detachUserAlert(in: String): JsCmd = { - val username = in.split(",").headOption.getOrElse("") - val name = in.split(",").tail.headOption.getOrElse("") - val displayName = in.split(",").lastOption.getOrElse("") - - for(blog <- blog) { - S.warning( - <div>Are you sure you want to detach from {blog.name.get}</div> - <div> - <button onclick={ajaxCall(JsArray(Str(username), Str(name), Str(displayName)), detachUser). - _2.toJsCmd.toString+"; return false;"} class="btn btn-warning">Yes</button> - <button onclick="$(document).trigger('clear-alerts')" class="btn">No</button> - </div> - ) - } - } - - def detachUser(in: String): JsCmd = { - val username = in.split(",").headOption.getOrElse("") - val name = in.split(",").tail.headOption.getOrElse("") - val displayName = in.split(",").lastOption.getOrElse("") - - for(blog <- blog; user <- User.findByUsername(username)) { - val userblog = user.blog(blog) - userblog.map(ub => user.removeBlog(ub).update) - } - - for(blog <- blog; blogWriter <- blog.writer(name)) yield { - blogWriter.removeUser() - blog.save - val dashName = displayName.split(" ").mkString("-") - UpdateImg("writer-"+dashName+" img", Gravatar.imageUrl("blah")) & - Remove("detach-btn-"+dashName) & Hide("writer-edit-"+dashName) - } - } - - def invite(nameAndEmail: String): JsCmd = { - val email = nameAndEmail.split(",").headOption.getOrElse("") - val name = nameAndEmail.split(",").tail.headOption.getOrElse("") - - for(blog <- blog; user <- User.currentUser) { - blog.writer(name).map(_.email(email)) - blog.save - User.sendInviteToken(name, email, user, blog) - } - S.notice(name+" has been invited") - } - } -trait OwnerBlogSnippet extends BlogSnippet { +trait BlogWriterSnip extends SnippetHelper with Loggable { - protected def test = blog.exists(b => User.currentUser.exists(_.id.is == b.owner.is)) - - override def writers = serve { blog => - writersList(blog.writers.get, owner = true) - }(test, super.writers) - - def name(html: NodeSeq) = serve(html) { blog => - "*" #> ajaxText(blog.name.is, {s => blog.name(s).update; Noop}) - } (test, super.name) - - def description(html: NodeSeq) = serve(html) { blog => - "*" #> ajaxTextarea(blog.description.is, {s => blog.description(s).update; Noop}) - } (test, super.description) - - def uploadImg(html: NodeSeq) = serve(html) { blog => - "*" #> insertFileUpload("pic", ImageUpload.blogUrl(blog)) - }(test, NodeSeq.Empty) - - def uploadBgImg(html: NodeSeq) = serve(html) { blog => - "*" #> insertFileUpload("bg", ImageUpload.blogUrl(blog, "bg")) - }(test, NodeSeq.Empty) - - def blogname(html: NodeSeq) = serve(html) { blog => - - def check(s: String): JsCmd = { - val b = Blog.findByBlogName(s) - def isYou = b.exists(_.id.is == blog.id.is) - - if(!s.matches("^[a-z0-9-]{2,}$")) - S.error("Blog name can only contain alphanumeric or dash characters.") - else if(isYou) - Noop - else if(b.isEmpty) { - blog.blogname(s).update - S.notice("New blog name is saved") - } - else S.error("Blog name is taken") - } + protected def blog: Box[Blog] - "*" #> ajaxText(blog.blogname.is, { s: String => check(s.toLowerCase)}) - } (test, super.blogname) + protected def blogWriter: Box[BlogWriter] - def surround = "#profile-wrap [class+]" #> (if(test) "editable-page" else "") -} - -object ProfileLocBlog extends OwnerBlogSnippet { - protected def blog = Site.blogProfileLoc.currentValue -} + protected def serve(snip: (Blog, BlogWriter) => NodeSeq) + (test: Boolean, fallback: NodeSeq): NodeSeq = + (for { + b <- blog ?~ "Blog not found" + bw <- blogWriter ?~ "Blog Writer not found" + } yield { + if (test) snip(b, bw) else fallback + }): NodeSeq -class ProfileLocBlog(b: Box[Blog]) extends OwnerBlogSnippet { - protected def blog = b - override def test = super.test -} + protected def serve(html: NodeSeq) + (snip: (Blog, BlogWriter) => CssSel) + (test: Boolean, fallback: NodeSeq): NodeSeq = + (for { + b <- blog ?~ "Blog not found" + bw <- blogWriter ?~ "Blog Writer not found" + } yield { + if (test) snip(b, bw)(html) else fallback + }): NodeSeq -object ProfileLocBlogReq extends OwnerBlogSnippet { - protected def blog = Blog.findByStringId(BlogIdVar.is) + protected def serve(snip: (Blog, BlogWriter, User) => NodeSeq) + (test: Boolean, fallback: NodeSeq): NodeSeq = + (for { + b <- blog ?~ "Blog not found" + bw <- blogWriter ?~ "Blog Writer not found" + u <- User.currentUser ?~ "User not found" + } yield { + if (test) snip(b, bw, u) else fallback + }): NodeSeq } -object ProfileLocBlogWriter { - - def blogWriterContent: Box[BlogWriterContent] = Site.blogWriterProfileLoc.currentValue - def blog: Box[Blog] = blogWriterContent.map(_.blog) - def blogWriter: Box[BlogWriter] = blogWriterContent.map(_.writer) - - def writerName: String = blogWriter.map(_.name.get).openOr("") - - def articles: NodeSeq = blog.map(blog => blog.urlRss.is.head.entries.filter(_.author == writerName). - flatMap(e=> SnipHelpers.userProfileArticle(e))).openOr(NodeSeq.Empty) - - def followButton: NodeSeq = if(User.isLoggedIn) { - <div><button class="btn btn-primary btn-large"><strong>Follow</strong></button> - <h4>{blogWriter.map(_.followers.get.size).sum} followers</h4></div> - } else NodeSeq.Empty +trait BlogWriterUserSnip extends BlogWriterSnip { - def name: NodeSeq = Text(writerName) - def url: NodeSeq = blog.map(b=> Text(b.urlHtml.get)).openOr(NodeSeq.Empty) + protected def bwu: Box[BlogWriterUser] - def img = { - "* [id]" #> imgProfileId & - "* [width]" #> imgProfileSize & - "* [src]" #> Gravatar.imageUrl(blogWriter.map(_.email.get).openOr("blah"), imgProfileSize) + override protected def blog = bwu.map(_.blog) match { + case Full(Some(t)) => Full(t) + case _ => Empty } - def bgImg = { - "* [id]" #> imgBgId & - "* [src]" #> defaultBackground + override protected def blogWriter = bwu.map(_.blogWriter) match { + case Full(Some(t)) => Full(t) + case _ => Empty } } -case class CategoriesSnippet(b: Box[Blog]) extends OwnerBlogSnippet { - override protected def blog = b - - val user: Box[User] = User.currentUser - - val userblog: Box[UserBlog] = - for { - user <- user - blog <- blog - } yield user.blog(blog) openOr UserBlog.createRecord.blog(blog.id.is) - - var category = "" - - def toHtml = serve { blog => - for { - userblog <- userblog - user <- user - blogWriter <- blog.writer(user) - } yield - <div class="userblog-snip well"> - <div class="row-fluid"> - <div class="span12"> - <a href={Site.blogProfileLoc.calcHref(blog)}> - <span id="blog-img">{img(<img/>)}</span><span class="url">{url}</span> - </a> - </div> - </div> - <div class="row-fluid"> - <div class="span12 up-separate"> - <div>www.readmeans.com/blog/ {blogname(<span></span>)}</div> - <div> - <button id={"detach-btn-"+user.name.get.split(" ").mkString("-")} onclick={ajaxCall( - JsArray(Str(user.username.get), Str(blogWriter.name.get), Str(user.name.get)), detachUserAlert). - _2.toJsCmd.toString+"; return false;"} class="btn btn-primary">Remove Blog</button> - </div> - </div> - </div> - <div class="row-fluid"> - <div class="span12 up-separate"> - <form data-lift="form.ajax?class=input-append"> - {text(category, category = _, "placeholder" -> "Submit New Category", "id" -> "category-input")} - {ajaxSubmit("Add", ()=> addCategory(category), "class" -> "btn btn-primary")} - </form> - <br/> - <div id="chosen-categories" class="well">{ - userblog.categories.is.flatMap(categoryNode).toSeq - }</div> - </div> - </div> - <hr/> - </div> - } (test = true, NodeSeq.Empty) - - def addCategory(cat: String): JsCmd = { - - for(ub <- userblog; u <- user) { - ub.addCategory(cat) - u.modifyBlog(ub).save // change to update (but wont work with embed docs) - val c = Category.find(cat).map(_.addUser(u).update) - if(!c.isDefined) Category.createRecord.id(cat).addUser(u).save - } - - category = "" - Prepend("chosen-categories", categoryNode(cat)) & - SetValById("category-input","") - } - - def removeCategory(cat: String): JsCmd = { - super.remove("") - for(ub <- userblog; u <- user) { - ub.removeCategory(cat) - u.modifyBlog(ub).save - Category.find(cat).map(_.removeUser(u).update) - } - - Remove("chosen-cat-"+cat.split(" ").mkString("-")) - } - - def categoryNode(cat: String): Elem = - <span id={"chosen-cat-"+cat.split(" ").mkString("-")} class="uneditable-input">{cat}<button onclick={ - ajaxCall(Str(cat), removeCategory)._2.toJsCmd.toString.singleQuoteToDouble +"; return false;" - } class="close category">&times;</button></span> +object ProfileLocBlog extends BlogSnipEdit with BlogWritersSnipView { + protected def blog = Site.blogProfileLoc.currentValue } -class CategoriesSnip { - - protected def blog = Site.categoriesLoc.currentValue - val snip = CategoriesSnippet(blog) - - def render = { - "#chosen-categories *" #> snip.userblog.map(_.categories.is.map(snip.categoryNode)) & - "#category-input" #> text(snip.category, snip.category = _) & - "#category-add" #> ajaxSubmit("Add", ()=> snip.addCategory(snip.category)) - } +object BlogSettings extends BlogSnipEdit with BlogWritersSnipView { + protected def blog = Site.blogSettingsLoc.currentValue } -class BlogsSnip extends UserSnippet { - - def user = User.currentUser - - def blogs: NodeSeq = serve { user => - val blogs: List[CategoriesSnippet] = user.blogs.is.map(ub => CategoriesSnippet(ub.blog.find)) - blogs.flatMap(_.toHtml).toSeq - } (test = true, NodeSeq.Empty) - - def addBlog() = "* [href]" #> Site.blogVerify.url +object ProfileLocBlogWriter extends BlogWriterSnipView with FollowSnip { + protected def bwu = Site.blogWriterProfileLoc.currentValue } +/* + * (hack) Used during sign up wizard to handle the blog-name text input + * field which rests outside of blog-profile.html + */ +object ProfileLocBlogReq extends BlogSnipEdit { + protected def blog = Blog.findByStringId(BlogIdVar.is) +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/BlogWriterCategoriesSnip.scala b/src/main/scala/com/joereader/snippet/BlogWriterCategoriesSnip.scala @@ -0,0 +1,79 @@ +package com.joereader.snippet + +import com.joereader._ +import config._ +import model._ +import lib.Helper._ +import snippet.SnipHelpers._ + +import net.liftweb._ +import common._ +import http._ +import js._, JsCmds._, JE._ +import SHtml._ +import util.Helpers._ + +import scala.xml._ + + +/** + * Snippet to add and remove categories. + */ +object BlogWriterCategoriesSnip extends BlogWriterSnip { + + protected def blog = Site.categoriesLoc.currentValue + + override def blogWriter: Box[BlogWriter] = + for { + blog <- blog + user <- User.currentUser + blogWriter <- blog.writer(user) + } yield blogWriter + + private def test: Boolean = blogWriter.exists( + bw => User.currentUser.exists(_.id.get == bw.user.get)) + + def render(html: NodeSeq): NodeSeq = serve(html) { + (blog, blogWriter) => + var category = "" + + "#category-input" #> text(category, category = _) & + "#category-add" #> ajaxSubmit("Add", () => addCategory(category)) & + "#chosen-categories .inner" #> blogWriter.categories.get.map(categoryNode) + }(test, NodeSeq.Empty) + + def categoryNode(cat: String): Elem = + <span id={"chosen-cat-" + cat.split(" ").mkString("-")} class="uneditable-input">{cat}<button onclick={ajaxCall(Str(cat), removeCategory)._2.toJsCmd.toString. + singleQuoteToDouble + "; return false;"} class="close category">&times;</button></span> + + def addCategory(cat: String): JsCmd = { + + for { + blogWriter <- blogWriter + blog <- blog + } { + blogWriter.addCategory(cat) + blog.save + val c = Category.find(cat).map(_.addWriter(blog, blogWriter).update) + if (c.isEmpty) + Category.createRecord.id(cat).addWriter(blog, blogWriter).save + } + + Prepend("chosen-categories", categoryNode(cat)) & + SetValById("category-input", "") + } + + def removeCategory(cat: String): JsCmd = { + + for { + blogWriter <- blogWriter + blog <- blog + } { + blogWriter.removeCategory(cat) + blog.save + Category.find(cat).map(_.removeWriter(blog, blogWriter).update) + } + + Remove("chosen-cat-" + cat.split(" ").mkString("-")) + } +} diff --git a/src/main/scala/com/joereader/snippet/BlogWriterColorSnip.scala b/src/main/scala/com/joereader/snippet/BlogWriterColorSnip.scala @@ -0,0 +1,63 @@ +package com.joereader.snippet + +import com.joereader._ +import config._ +import model._ +import snippet.SnipHelpers._ + +import net.liftweb._ +import common._ +import http._ +import js._ +import SHtml._ +import util.Helpers._ + +import scala.xml._ + +/** + * Snippet to change blog writer's primary color. + */ +object BlogWriterColorSnip extends BlogWriterSnip { + + // in the meantime, color snippet is located beside categories snippet. + protected def blog = Site.categoriesLoc.currentValue + + override def blogWriter: Box[BlogWriter] = + for { + blog <- blog + user <- User.currentUser + blogWriter <- blog.writer(user) + } yield blogWriter + + private def test: Boolean = blogWriter.exists( + bw => User.currentUser.exists(_.id.get == bw.user.get)) + + def render(html: NodeSeq): NodeSeq = serve(html) { + (blog, blogWriter) => + var color = blogWriter.color.get + + "#hexVal" #> text(color, color = _) & + "#hexVal-add" #> ajaxSubmit("Add", () => addColor(color)) & + ".preview [style]" #> ("background-color:"+blogWriter.color.get) + }(test, NodeSeq.Empty) + + def addColor(color: String): JsCmd = { + val hexRegex = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$" + val success = + for { + blog <- blog + blogWriter <- blogWriter + } yield + if(color.matches(hexRegex)) { + blogWriter.color(color) + blog.save + true + } else false + + if(success.openOr(false)) + UpdateCSS("colorpicker-wrapper .preview", "background-color", color) + else + S.error("Invalid hex value. '#' should be included") + } + +} diff --git a/src/main/scala/com/joereader/snippet/BlogWriterSnipView.scala b/src/main/scala/com/joereader/snippet/BlogWriterSnipView.scala @@ -0,0 +1,51 @@ +package com.joereader.snippet + +import net.liftweb._ +import util._, Helpers._ + +import com.joereader._ +import config._ +import lib.rss._ +import snippet.SnipHelpers._ +import scala.xml._ +import com.joereader.model.BlogWriterUser + +/** + * Snippets to view Blog Writer information. + */ +trait BlogWriterSnipView extends BlogWriterUserSnip with ArticleSnip with BackgroundSnip { + + def articles(html: NodeSeq): NodeSeq = serve(html) { + (blog, blogWriter) => + + val entries = blog.urlRss.get.head.entries. + filter(_.author.name == blogWriter.name.get). + map((_, BlogWriterUser(None, Some(blog), Some(blogWriter)))) + + articles(entries) + + }(test = true, NodeSeq.Empty) + + def name: NodeSeq = serve { + (blog, blogWriter) => Text(blogWriter.name.get) + }(test = true, NodeSeq.Empty) + + def url(html: NodeSeq): NodeSeq = serve(html) { + (blog, blogWriter) => + "a *" #> blog.urlHtml.get & + "a [href]" #> Site.blogProfileLoc.calcHref(blog) + }(test = true, NodeSeq.Empty) + + def img(html: NodeSeq): NodeSeq = serve(html) { + (b, bw) => + "* [id]" #> imgProfileId & + "* [class+]" #> "profile-img-size" & + "* [src]" #> bwu.map(_.image) + }(test = true, NodeSeq.Empty) + + def bgImg(html: NodeSeq): NodeSeq = serve(html) { + (b, bw) => + "* [id]" #> imgBgId & + "* [src]" #> defaultBackground + }(test = true, NodeSeq.Empty) +} diff --git a/src/main/scala/com/joereader/snippet/BlogWritersSnipView.scala b/src/main/scala/com/joereader/snippet/BlogWritersSnipView.scala @@ -0,0 +1,201 @@ +package com.joereader.snippet + +import com.joereader._ +import model._ +import snippet.SnipHelpers._ + +import net.liftweb._ +import http._ +import js._, JE._ +import SHtml._ +import util.Helpers._ + +import scala.xml._ + +import net.liftmodules.extras.Gravatar + +/** + * Sigh. + */ +trait BlogWritersSnipView extends BlogSnip { + + def showIfOwner(html: NodeSeq): NodeSeq = + blog.map(b => if(b.isOwner(User.currentUser)) html else NodeSeq.Empty) + + def writers(html: NodeSeq) = serve(html) { + blog => + val owner = blog.isOwner(User.currentUser) + + ".writer *" #> blog.writers.get.map { + blogWriter => + + var msg = "" + + val bwu = BlogWriterUser( + blogWriter.user.obj, + Some(blog), + Some(blogWriter)) + + def inviteForm: NodeSeq = + if (!blogWriter.hasUser && owner) { + msg = "Who is " + bwu.name + "? Has he registered already? If" + + " so, input the email that the person used to sign up. If" + + " he's not registered, we'll send " + bwu.name + " an invite. " + + <input type="text" id={"email-" + bwu.dashName} placeholder="email" value={blogWriter.email.get}/> + <button onclick={ajaxCall(JsArray(ValById("email-" + + bwu.dashName), Str(bwu.name)), invite)._2.toJsCmd.toString + + "; return false;"} class="btn btn-primary">Invite</button> + } + else NodeSeq.Empty + + def removeButton(): NodeSeq = + if (owner) { + msg = msg + + "If you would like to remove " + bwu.name + ", click remove button. " + + <button onclick={ajaxCall(JsArray(Str(blogWriter.name.get), + Str(bwu.name)), removeAlert)._2.toJsCmd.toString + + "; return false;"} class="btn btn-primary">Remove</button> + } + else NodeSeq.Empty + + def detachButton(): NodeSeq = + if (owner && blogWriter.hasUser) { + msg = msg + "To remove the user account connected to " + bwu.name + + ", click Detach User button." + val user = bwu.user.getOrElse(User.createRecord) + + <button id={"detach-btn-" + bwu.dashName} onclick={ajaxCall( + JsArray(Str(user.username.get), Str(blogWriter.name.get), + Str(bwu.name)), detachUserAlert)._2.toJsCmd.toString + + "; return false;"} class="btn btn-primary">Detach User</button> + } + else NodeSeq.Empty + + ".blog-writer [id]" #> ("writer-" + bwu.dashName) & + ".blog-writer" #> { + ".writer-link [href]" #> bwu.link & + "img [id]" #> ("img-" + bwu.dashName) & + "img [src]" #> bwu.image & + ".name .text *" #> bwu.name & + ".name .edit-link a [onclick]" #> + Show("writer-edit-" + bwu.dashName).toJsCmd.toString & + ".input-area *" #> (inviteForm ++ removeButton ++ detachButton) & + ".categories *" #> bwu.categories + } & + ".writer-edit-area [id]" #> ("writer-edit-" + bwu.dashName) & + ".writer-edit-area" #> { + ".name *" #> bwu.name & + ".msg-area small *" #> msg & + ".input-area *" #> (inviteForm ++ removeButton ++ detachButton) & + ".exit-link [onclick]" #> + Hide("writer-edit-" + bwu.dashName).toJsCmd.toString + } + } & + "style *" #> ("#blog-writers .overview{width:" + + (blog.writers.get.size * 200) + "px}") + + }(test = true, NodeSeq.Empty) + + def removeAlert(in: String): JsCmd = { + val name = in.split(",").headOption.getOrElse("") + val displayName = in.split(",").tail.headOption.getOrElse("") + + for { + blog <- blog + blogWriter <- blog.writer(name) + } { + val followSize = blogWriter.followers.get.size + + val msg = s"Are you sure you want to remove $displayName? ${ + if (followSize > 0) followSize + " followers will stop following!" + else "" + }" + + S.warning( + <div> + {msg} + </div> + <div> + <button onclick={ajaxCall(JsArray(Str(name), Str(displayName)), remove). + _2.toJsCmd.toString + "; return false;"} class="btn btn-warning">Yes</button> + <button onclick="$(document).trigger('clear-alerts')" class="btn">No</button> + </div> + ) + } + } + + def remove(in: String): JsCmd = { + val name = in.split(",").headOption.getOrElse("") + val displayName = in.split(",").tail.headOption.getOrElse("") + + for { + blog <- blog + blogWriter <- blog.writer(name) + } yield { + blogWriter.followers.objs.map(_.unFollow(blogWriter, blog).update) + blog.removeWriter(blogWriter).update + val dashName = displayName.split(" ").mkString("-") + Remove("writer-" + dashName) & Remove("writer-edit-" + dashName) & Hide("writer-edit-" + dashName) + } + } + + def detachUserAlert(in: String): JsCmd = { + val username = in.split(",").headOption.getOrElse("") + val name = in.split(",").tail.headOption.getOrElse("") + val displayName = in.split(",").lastOption.getOrElse("") + + for (blog <- blog) { + S.warning( + <div>Are you sure you want to detach from + {blog.name.get} + </div> + <div> + <button onclick={ajaxCall(JsArray(Str(username), Str(name), Str(displayName)), detachUser). + _2.toJsCmd.toString + "; return false;"} class="btn btn-warning">Yes</button> + <button onclick="$(document).trigger('clear-alerts')" class="btn">No</button> + </div> + ) + } + } + + def detachUser(in: String): JsCmd = { + val username = in.split(",").headOption.getOrElse("") + val name = in.split(",").tail.headOption.getOrElse("") + val displayName = in.split(",").lastOption.getOrElse("") + + for { + blog <- blog + blogWriter <- blog.writer(name) + user <- User.findByUsername(username) + } yield { + user.removeBlog(blog).update + blogWriter.removeUser() + + if(blog.isOwner(user)) + blog.owner(blog.owner.defaultValue) + + blog.save + val dashName = displayName.split(" ").mkString("-") + + UpdateImage("writer-" + dashName + " img", Gravatar.imageUrl("blah")) & + Remove("detach-btn-" + dashName) & Hide("writer-edit-" + dashName) + } + } + + def invite(nameAndEmail: String): JsCmd = { + val email = nameAndEmail.split(",").headOption.getOrElse("") + val name = nameAndEmail.split(",").tail.headOption.getOrElse("") + + for { + blog <- blog + user <- User.currentUser + } { + blog.writer(name).map(_.email(email)) + blog.save + User.sendInviteToken(name, email, user, blog) + } + S.notice(name + " has been invited") + } +} diff --git a/src/main/scala/com/joereader/snippet/FollowSnip.scala b/src/main/scala/com/joereader/snippet/FollowSnip.scala @@ -0,0 +1,194 @@ +package com.joereader.snippet + +import net.liftweb._ +import common._ +import http._, js._ +import JsCmds._ +import SHtml._ +import util._, Helpers._ + +import com.joereader._ +import model._ +import config._ + +import scala.xml._ + +/** + * Allow users to follow blog writers and other users. (BlogWriterUser) + */ +trait FollowSnip extends BlogWriterUserSnip { + + val btnPrefix = "follow-btn" + + private def test: Boolean = + (for { + bwu <- bwu + user <- bwu.user + } yield + User.currentUser.exists(_.id.is != user.id.is) && user.isWriter). + getOrElse(User.isLoggedIn) + + def followButton: NodeSeq = + if(bwu.exists(_.user.isDefined)) userFollowButton(bwu) + else blogWriterFollowButton(bwu) + + def followButton(bwu: Box[BlogWriterUser]): NodeSeq = + if(bwu.exists(_.user.isDefined)) userFollowButton(bwu) + else blogWriterFollowButton(bwu) + + def userFollowButton(bwu: Box[BlogWriterUser]): NodeSeq = + if(test) bwu.flatMap(_.user.map { + user => + val btnId = btnPrefix + "-" + user.id.get + + def isFollowing: Boolean = + User.currentUser.exists(_.following.exists(user)) + + def unFollowBtn: NodeSeq = + ajaxButton("Unfollow", () => unFollow, + "class" -> "btn btn-info btn-large", + "id" -> btnId) + + def followBtn = + ajaxButton("Follow", () => follow, + "class" -> "btn btn-primary btn-large", + "id" -> btnId) + + def follow: JsCmd = { + for { + loggedInUser <- User.currentUser + user <- User.find(user.id.get) + } yield { + loggedInUser.follow(user).update + user.addFollower(loggedInUser).update + user.blogs.objs.map { + blog => + blog.writer(user).map(_.addFollower(loggedInUser)) + blog.save + } + } + Replace(btnId, unFollowBtn) + } + + def unFollow: JsCmd = { + for { + loggedInUser <- User.currentUser + user <- User.find(user.id.get) + } yield { + loggedInUser.unFollow(user).update + user.removeFollower(loggedInUser).update + user.blogs.objs.map { + blog => + blog.writer(user).map(_.removeFollower(loggedInUser)) + blog.save + } + } + Replace(btnId, followBtn) + } + + if (isFollowing) unFollowBtn else followBtn + }) else NodeSeq.Empty + + /* + * Note: We search for blog even though we have the record already + * because it might be unsynchronised due to having multiple + * BlogWriterUser's, hence multiple unsynchronised blogs. + * + */ + def blogWriterFollowButton(bwu: Box[BlogWriterUser]): NodeSeq = { + if(test) + for { + bwu <- bwu + blog <- bwu.blog + blogWriter <- bwu.blogWriter + } yield { + val btnId = btnPrefix + "-" + bwu.dashName + + def isFollowing: Boolean = + User.currentUser.exists(_.following.exists(blogWriter, blog)) + + def unFollowBtn: NodeSeq = + ajaxButton("Unfollow", () => unFollow, + "class" -> "btn btn-info btn-large", + "id" -> btnId) + + def followBtn = + ajaxButton("Follow", () => follow, + "class" -> "btn btn-primary btn-large", + "id" -> btnId) + + def follow: JsCmd = { + for { + user <- User.currentUser + blog <- Blog.find(blog.id.get) + blogWriter <- blog.writer(blogWriter.name.get) + } yield { + user.follow(blogWriter, blog).update + blogWriter.addFollower(user) + blog.save + } + Replace(btnId, unFollowBtn) + } + + def unFollow: JsCmd = { + for { + user <- User.currentUser + blog <- Blog.find(blog.id.get) + blogWriter <- blog.writer(blogWriter.name.get) + } yield { + user.unFollow(blogWriter, blog).update + blogWriter.removeFollower(user) + blog.save + } + Replace(btnId, followBtn) + } + + if (isFollowing) unFollowBtn else followBtn + } + else NodeSeq.Empty + } + + def followersAmount: NodeSeq = + if(bwu.exists(_.user.isDefined)) userFollowersAmount + else blogWriterFollowersAmount + + def blogWriterFollowersAmount: NodeSeq = serve { + (blog, blogWriter) => + Text(blogWriter.followers.get.size.toString + " followers") + }(test = true, NodeSeq.Empty) + + def userFollowersAmount: NodeSeq = bwu.flatMap(_.user.map { + user => + + val blogFollowers = + (for { + blog <- user.blogs.objs + blogWriter <- blog.writer(user) + } yield blogWriter.followers.get).flatten + + val followers = (blogFollowers ::: user.followers.get).distinct + + Text(followers.size.toString + " followers") + }) + + /* + * Subclasses must override this user to see the list + * of following for that user. + */ + protected def followingUser: Box[User] = Empty + + def following: CssSel = followingUser.map { + user: User => + ".writer *" #> user.following.allUsers.map { + bwu => + ".writer-link [href]" #> bwu.link & + ".name .writer-link [href]" #> bwu.link & + ".writer-link img [src]" #> bwu.image & + ".name a *" #> bwu.name & + ".categories *" #> bwu.categories & + ".url a *" #> bwu.url & + ".url a [href]" #> bwu.blog.map(Site.blogProfileLoc.calcHref) & + "#follow-btn" #> followButton(Full(bwu)) + } + } +} diff --git a/src/main/scala/com/joereader/snippet/LiftExtras.scala b/src/main/scala/com/joereader/snippet/LiftExtras.scala @@ -1,16 +1,18 @@ package com.joereader.snippet -import net.liftweb.util._, Helpers._ +import net.liftweb._ +import util._, Helpers._ +import http.js._ + +import com.joereader.model._ +import com.joereader.config._, S3Config._ +import com.joereader.lib._ + import net.liftmodules.extras._, snippet._ -import scala.xml.{Elem, NodeSeq} -import com.joereader.model.{Blog, User} -import com.joereader.config.S3Config._ -import net.liftweb.http.js.{JE, JsCmd} -import com.joereader.lib.ImageUpload -import com.joereader.lib.rss.Rss.FeedEntry -import com.joereader.config.{Site, S3Config} +import scala.xml._ object Menus extends BsMenu + object Notices extends BsAlerts /* @@ -26,93 +28,52 @@ object ProductionOnly { else NodeSeq.Empty } -trait PageHeader { - val headName: String - - def title = - <lift:head> - <title lift="Menu.title">{"Read Means: %*% - "+headName}</title> - </lift:head> -} object SnipHelpers { - val imgProfileSize = 380 val imgProfileId = "img-profile" val imgBgId = "img-bg" - def image100Url(u: User) = if(u.img.is.nonEmpty) s3.fileUrl(u.img.is) else Gravatar.imageUrl(u.email.is, 100) - def image200Url(u: User) = if(u.img.is.nonEmpty) s3.fileUrl(u.img.is) else Gravatar.imageUrl(u.email.is, 200) - def imageUrl(u: User, s: Int) = if(u.img.is.nonEmpty) s3.fileUrl(u.img.is) else Gravatar.imageUrl(u.email.is, s) - def imageUrl(b: Blog) = if(b.img.is.nonEmpty) s3.fileUrl(b.img.is) else Gravatar.imageUrl("blah", 500, "G", "wavatar") - def imageBgUrl(u: User) = if(u.bgImg.is.nonEmpty) s3.fileUrl(u.bgImg.is) else defaultBackground - def imageBgUrl(b: Blog) = if(b.bgImg.is.nonEmpty) s3.fileUrl(b.bgImg.is) else defaultBackground - def mrnoman = Gravatar.imageUrl("blah") - def defaultBackground: String = S3Config.s3.fileUrl("bubblegum.jpg") - def insertFileUpload(id: String, path: String): NodeSeq = { - <input id={id+"-fileupload"} type="file" name="files2[]" data-url={path} accept={ImageUpload.acceptedImages.mkString(",")} /> - <div id={id+"-progress"} style="width:20em; border: 1pt solid silver; display: none"> - <div id={id+"-progress-bar"} style="background: green; height: 1em; width:0%"></div> - </div> - } + def imageUrl(b: Blog) = + if (b.img.get.nonEmpty) s3.fileUrl(b.img.is) + else s3.fileUrl("mr_noblog_color") + - def setNoticeContainer = - "#notices-container [id]" #> (if(SignUp.hideSignup) "notices-container-top" else "notices-container") - - - def blogProfileArticles(entry: FeedEntry, blog: Blog): NodeSeq = { - val blogWriter = blog.writer(entry.author) - - val (name, img, profileLink): (String, String,String) = - (for { - blogWriter <- blogWriter - user <- blogWriter.user.obj - } yield - (user.name.get, image100Url(user), Site.userProfileLoc.calcHref(user))). - openOr((entry.author, mrnoman, "/")) - - if(blogWriter.isDefined) - <div class="article"> - <div class="row-fluid header text-center"> - <div class="span12"> - <a href={profileLink}><img src={img}/></a> - <div class="writer-name nolink-decoration"><a href={profileLink}>{name}</a></div> - <time class="timeago" datetime={entry.df.format(entry.date)}></time> - </div> - </div> - <div class="title"><a href={entry.link} target="_blank">{entry.title}</a></div> - { entry.content } + def insertFileUpload(id: String, path: String): NodeSeq = { + <input id={id + "-fileupload"} type="file" name="files2[]" data-url={ + path} accept={ImageUpload.acceptedImages.mkString(",")}/> + <div id={id + "-progress"} style="width:20em; border: 1pt solid silver; display: none; margin: 10px 0"> + <div id={id + "-progress-bar"} style="background: green; height: 1em; width:0%"></div> </div> - else - NodeSeq.Empty } - def userProfileArticle(entry: FeedEntry): NodeSeq = - <div class="article"> - <div class="row-fluid header text-center"> - <div class="span12"> - <time class="timeago" datetime={entry.df.format(entry.date)}></time> - </div> - </div> - <div class="title"><a href={entry.link} target="_blank">{entry.title}</a></div> - { entry.content } - </div> + def setNoticeContainer() = + "#notices-container [id]" #> + (if (SignUp.hideSignup) "notices-container-top" else "notices-container") class JQuery(id: String, func: String, params: String*) extends JsCmd { - val script = """$("#%s").%s('%s')""".format(id, func, params.mkString("','")) + val script = "$('#%s').%s('%s')".format(id, func, params.mkString("','")) + override def toJsCmd = JE.JsRaw(script).cmd.toJsCmd } - case class DisableInput(id: String) extends JQuery(id,"attr", "disabled", "") - case class AddClass(id: String, className: String) extends JQuery(id,"addClass", className) - case class RemoveClass(id: String, className: String) extends JQuery(id,"removeClass", className) - case class ToggleClass(id: String, className: String) extends JQuery(id,"toggleClass", className) - case class ToggleHide(id: String) extends JQuery(id,"toggleClass", "hide") - case class Hide(id: String) extends JQuery(id,"addClass", "hide") - case class Show(id: String) extends JQuery(id,"removeClass", "hide") - case class Prepend(id: String, node: Elem) extends JQuery(id,"prepend", node.toString()) - case class LoadButton(id: String) extends JQuery(id, "button", "loading") - case class Remove(id: String) extends JQuery(id, "remove", "") - case class UpdateImg(id: String, img: String) extends JQuery(id, "attr", "src", img) + case class Hide(id: String) + extends JQuery(id, "addClass", "hide") + + case class Show(id: String) + extends JQuery(id, "removeClass", "hide") + + case class Prepend(id: String, node: Elem) + extends JQuery(id, "prepend", node.toString()) + + case class Remove(id: String) + extends JQuery(id, "remove", "") + + case class UpdateImage(id: String, img: String) + extends JQuery(id, "attr", "src", img) + + case class UpdateCSS(id: String, key: String, value: String) + extends JQuery(id, "css", key, value) + } \ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/MyBlog.scala b/src/main/scala/com/joereader/snippet/MyBlog.scala @@ -0,0 +1,42 @@ +package com.joereader.snippet + +import com.joereader._ +import model._ +import config._ +import lib.rss._ + +import scala.xml._ + +import net.liftweb._ +import util.Helpers._ +import common._ + +/** + * Stuff for Read Means blog. + */ +class MyBlog extends BlogSnip with ArticleSnip { + + override def blog = Blog.findByBlogName("readmeans") + + def link = "* [href]" #> blog.map(Site.blogProfileLoc.calcHref) + + def blogExists(html: NodeSeq) = if(blog.isDefined) html else NodeSeq.Empty + + def entry(html: NodeSeq): NodeSeq = serve(html) { + blog => + + val entry = + blog.urlRss.is.head.entries. + find(e => blog.writerExists(e.author.name)).map { + entry => + + val blogWriter: Box[BlogWriter] = blog.writer(entry.author.name) + val bwu = BlogWriterUser( + blogWriter.flatMap(_.user.obj), Some(blog), blogWriter) + (entry, bwu) + } + + articles(entry.toList) + + }(test = true, NodeSeq.Empty) +} diff --git a/src/main/scala/com/joereader/snippet/PasswordReset.scala b/src/main/scala/com/joereader/snippet/PasswordReset.scala @@ -0,0 +1,48 @@ +package com.joereader.snippet + +import net.liftweb._ +import util.Helpers._ +import common._ +import http._ +import js._ +import SHtml._ + +import com.joereader._ +import model._ +import config._ + +import net.liftmodules.mongoauth.model.LoginToken + +object PasswordReset extends Loggable { + + def render = { + + User.currentUser.map { + user => + val token = + LoginToken.find(LoginToken.userId.name, user.id.is) + + if (token.isEmpty) S.redirectTo(Site.notFound.url) + else token.map(_.delete_!) + } + + var pwd, pwd2 = "" + + def doSubmit(): JsCmd = + if (pwd == pwd2) { + User.currentUser.map { + user => + user.password(pwd) + user.password.hashIt + user.save + } + S.redirectTo(Site.home.url) + } + else + S.error("id_pwd2_err", "Password does not match") + + "#id_pwd" #> password(pwd, pwd = _) & + "#id_pwd2" #> password(pwd2, pwd2 = _) & + "#id_submit" #> ajaxSubmit("Save", doSubmit) + } +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/ReaderSnip.scala b/src/main/scala/com/joereader/snippet/ReaderSnip.scala @@ -0,0 +1,50 @@ +package com.joereader.snippet + +import net.liftweb._ +import common._ +import http._ + +import com.joereader._ +import model._ +import lib.rss._ + +import scala.xml._ + +/** + * View all of your articles. + */ +class ReaderSnip extends UserSnip with ArticleSnip { + protected def user: Box[User] = User.currentUser + + def articles(html: NodeSeq): NodeSeq = serve(html) { + user => + + val entries: List[(FeedEntry, BlogWriterUser)] = + user.following.allUsers.map(bwu => + for { + blog <- bwu.blog + blogWriter <- bwu.blogWriter + } yield + blog.urlRss.get.head.entries. + filter(_.author.name == blogWriter.name.get.toString). + map((_, bwu)) + ).flatten.flatten + + articles(entries) + + }(TestUser.isLoggedIn, NodeSeq.Empty) + + def comingSoon: NodeSeq = + if(TestUser.isLoggedIn) NodeSeq.Empty + else { + <div style="margin: 100px 0" class="text-center"> + <h3>Have another blog?</h3> + <a data-lift="UserBlogsSnip.addBlog" class="btn btn-primary" style="margin-bottom: 50px">Add Blog</a> + <br/><br/> + {Templates("templates-hidden" :: "parts" :: "social" :: Nil).openOr(NodeSeq.Empty)} + <h2>Coming Soon!</h2> + <p>We'll send an email as soon as we're ready.</p> + </div> + + } +} diff --git a/src/main/scala/com/joereader/snippet/Search.scala b/src/main/scala/com/joereader/snippet/Search.scala @@ -0,0 +1,166 @@ +package com.joereader.snippet + + +import com.joereader._ +import lib._, rss._ +import config._ +import model._ + +import scala.xml._ + +import net.liftweb._ +import http._ +import util._, Helpers._ + + +class Search { + /* + val categories = Category.random(25) + val usersByCategory = categories.map(c => (c.id.get, c.writers.randomWriters(9))) + + val minImages = 5 // per category + + val imagesByCategory = usersByCategory.par.map(c => (c._1, { + (for{ + bwu <- c._2.headOption + blogWriter <- bwu.blogWriter + blog <- bwu.blog + rss <- blog.urlRss.get.headOption + } yield { + rss.entries.filter(_.author.name == blogWriter.name.get).flatMap(_.images) + }).getOrElse(Nil) + })).seq + */ + + + val categories: Seq[(String,List[String])] = Seq( + ("technology", "http://www.theverge.com/rss/index.xml" :: Nil), + ("fashion", "http://1010woodland.tumblr.com/rss" :: Nil), + ("travel", "http://www.lonelyplanet.com/blog/feed/atom/" :: Nil), + ("cooking", "http://www.blogger.com/feeds/12411922/posts/default" :: Nil), + ("business", "http://feeds2.feedburner.com/businessinsider" :: Nil), + ("music", "http://rapdose.com/feed" :: Nil), + ("celebrities", "http://hollywoodlife.com/feed/" :: Nil), + ("gaming", "http://feeds.feedburner.com/psblog" :: Nil) + ) + + val feeds = categories.map(f => (f._1, f._2.feed)) + + val imagesByCategory: Seq[(String, List[FeedImage])] = feeds.map{f => + + (f._1, f._2.entries.flatMap(_.nonAuthorImages.headOption)) + } + + def render: NodeSeq = { + if(!TestUser.isLoggedIn) { + return <div class="container text-center"><h1>Coming Soon!</h1></div> + } + + imagesByCategory.flatMap { category => + + val link = Site.searchWriters.url+"?category="+category._1 + + <div> + <div class="span4 offset0"> + <a href={link} class="bg-overlay" style="width:50%"></a> + <div class="category-text"><a href={link}>{category._1}</a></div> + + <div style={"background:url("+category._2(0).src+") no-repeat center center;width:100%;height:300px;background-color: white"}></div> + </div> + <div class="span2 offset0"> + <div class="row-fluid"> + <div class="span12 offset0"><div style={"background:url("+category._2(1).src+") no-repeat center center;width:100%;height:200px;background-color: white"}></div></div> + <div class="span6 offset0"><div style={"background:url("+category._2(2).src+") no-repeat center center;width:100%;height:100px;background-color: white"}></div></div> + <div class="span6 offset0"><div style={"background:url("+category._2(3).src+") no-repeat center center;width:100%;height:100px;background-color: white"}></div></div> + </div> + </div> + </div> + } + + } +} + +class SearchWriters { + + def goBack = { + "a [href]" #> S.referer + } + + def render: NodeSeq = { + + if(!TestUser.isLoggedIn) { + return <div class="container text-center"><h1>Coming Soon!</h1></div> + } + + val category = S.param("category") + + if(category.exists(_ == "technology")) { + + <div> + <h1>Technology</h1> + <div class="row-fluid up-separate"> + <div class="span3" style="background-color: #FFFACD"> + <img src="http://img.gawkerassets.com/img/18kch9l5mp9h8png/avt-large.png" /> + <label>Adam Dachis</label> + <p>clever uses, diy</p> + <button class="btn btn-primary btn-small">Follow</button> + <button class="btn btn-info btn-small">Preview</button> + </div> + <div class="span3"> + <img src="http://img.gawkerassets.com/img/17zf8538pfi54jpg/avt-large.jpg" /> + <label>Walter Glenn</label> + <p>reader poll, discussions of the week</p> + <button class="btn btn-primary btn-small">Follow</button> + <button class="btn btn-info btn-small">Preview</button> + </div> + <div class="span3"> + <img src="http://img.gawkerassets.com/img/18jasvke0nsy9jpg/avt-large.jpg" /> + <label>Eric Ravenscraft</label> + <p>downloads, mind hacks</p> + <button class="btn btn-primary btn-small">Follow</button> + <button class="btn btn-info btn-small">Preview</button> + </div> + <div class="span3"> + <img src="http://img.gawkerassets.com/img/17rp9pzgs0jfypng/avt-large.png" /> + <label>Tessa Miller</label> + <p>crowd hacker, how i work</p> + <button class="btn btn-primary btn-small">Follow</button> + <button class="btn btn-info btn-small">Preview</button> + </div> + </div> + <div class="row-fluid up-separate"> + <div class="span3"> + <img src="http://img.gawkerassets.com/img/183aibc9tgpejjpg/avt-large.jpg" /> + <label>Alan Henry</label> + <p>android, ask lifehacker</p> + <button class="btn btn-primary btn-small">Follow</button> + <button class="btn btn-info btn-small">Preview</button> + </div> + <div class="span3"> + <img src="http://img.gawkerassets.com/img/18j0it9qk2nn8jpg/avt-large.jpg" /> + <label>Thorin Klosowski</label> + <p>productivity, diy</p> + <button class="btn btn-primary btn-small">Follow</button> + <button class="btn btn-info btn-small">Preview</button> + </div> + <div class="span3"> + <img src="http://img.gawkerassets.com/img/18l2h51ciwl1ajpg/avt-large.jpg" /> + <label>Shep McAllister</label> + <p>downloads, mind hacks</p> + <button class="btn btn-primary btn-small">Follow</button> + <button class="btn btn-info btn-small">Preview</button> + </div> + <div class="span3"> + <img src="http://img.gawkerassets.com/img/17z00h8twlstpjpg/avt-large.jpg" /> + <label>Whitson Gordon</label> + <p>linux downloads, lifehacker top 10</p> + <button class="btn btn-primary btn-small">Follow</button> + <button class="btn btn-info btn-small">Preview</button> + </div> + </div> + </div> + } + else + NodeSeq.Empty + } +} diff --git a/src/main/scala/com/joereader/snippet/SignUp.scala b/src/main/scala/com/joereader/snippet/SignUp.scala @@ -15,10 +15,14 @@ import scala.xml.NodeSeq // Requests variables sent from page to page during sign up object EmailVar extends RequestVar("") + object BlogIdVar extends RequestVar("") + object VerifiedVar extends RequestVar(false) -object WordpressToken extends RequestVar("") +/** + * Sign Up Wizard! + */ class SignUp { var email = EmailVar.is @@ -29,43 +33,44 @@ class SignUp { */ def setupEmail = { - def invitedMsg: JsCmd = S.notice("email-join-err", "You've been invited already silly!") - - def joinedMsg: JsCmd = S.notice("email-join-err", "We'll invite you as soon as we're ready!") + def invitedMsg: JsCmd = S.notice( + "email-join-err", + "You've been invited already silly!!") def continue(): JsCmd = { var js: JsCmd = Noop - if(email.isEmail) { - if(BetaUser.find(email).isDefined) - js = joinedMsg & SetValById("email-join", "") - else if(User.findByEmail(email).isDefined) + if (email.isEmail) { + if (User.findByEmail(email).isDefined) js = invitedMsg & SetValById("email-join", "") else { BetaUser.createRecord.id(email).save - js = S.redirectTo(Site.signUp1.url,() => EmailVar(email)) + js = S.redirectTo(Site.signUp1.url, () => EmailVar(email)) } } else js = S.error("email-join-err", "Please enter a valid email.") js } - if(SignUp.hideSignup) + if (SignUp.hideSignup) "*" #> NodeSeq.Empty else - "#signup-style *" #> "@media (min-width: 980px) {body{padding-top: 150px}}" & // @navbarHeight + @emailSignupHeight + fudge in less - "#email-join" #> text(email, email = _) & + "#signup-style *" #> + // @navbarHeight + @emailSignupHeight + fudge in less + "@media (min-width: 980px) {body{padding-top: 150px}}" & + "#email-join" #> text(email, email = _) & "#join-btn" #> ajaxSubmit("Join", continue) } /** - * Second Step: Check if he's a blog writer. If so, continue to next steps - * and allow him to setup and customize his blog. + * Second Step: Check if he's a blog writer. If so, + * continue to next steps and allow him to setup and customize his blog. */ def setupWriter = { assert(email.isEmpty) - "#yes-button" #> ajaxSubmit("Yes", () => S.redirectTo(Site.signUp2.url, () => EmailVar(email))) & - "#no-button" #> ajaxSubmit("No ", () => S.redirectTo("/")) + "#yes-button" #> ajaxSubmit("Yes", () => + S.redirectTo(Site.signUp2.url, () => EmailVar(email))) & + "#no-button" #> ajaxSubmit("No ", () => S.redirectTo("/")) } // Third step is blog verification which is handled by @@ -91,11 +96,13 @@ class SignUp { }) "#blogname" #> blog.map(_.name.is) & - "#categories-area" #> Templates("templates-hidden" :: "parts" :: "categories" :: Nil).map{ ns => - Site.categoriesLoc.requestValue(blog) - ns - } & - "#categories-continue" #> ajaxSubmit("Continue", continue) + "#categories-area" #> + Templates("templates-hidden" :: "parts" :: "categories" :: Nil).map { + ns => + Site.categoriesLoc.requestValue(blog) + ns + } & + "#categories-continue" #> ajaxSubmit("Continue", continue) } /** @@ -109,16 +116,20 @@ class SignUp { assert(!verified || user.isEmpty || blog.isEmpty) - if(!new ProfileLocBlog(blog).test) + // skip to next page if this user is not owner + if (blog.exists {blog => User.currentUser.exists(blog.nonOwner)}) S.redirectTo(Site.signUp5.url, () => VerifiedVar(verified)) - def continue(): JsCmd = S.redirectTo(Site.signUp5.url, () => VerifiedVar(verified)) + def continue(): JsCmd = + S.redirectTo(Site.signUp5.url, () => VerifiedVar(verified)) - "#blog-area" #> Templates("templates-hidden" :: "parts" :: "blog-profile" :: Nil).map{ ns => - Site.blogProfileLoc.requestValue(blog) - ns - } & - "#blog-continue" #> ajaxSubmit("Continue", continue) + "#blog-area" #> + Templates("templates-hidden" :: "parts" :: "blog-profile" :: Nil).map { + ns => + Site.blogProfileLoc.requestValue(blog) + ns + } & + "#blog-continue" #> ajaxSubmit("Continue", continue) } /** @@ -130,13 +141,16 @@ class SignUp { assert(!verified || user.isEmpty) - def continue(): JsCmd = S.redirectTo(Site.signUp6.url, () => VerifiedVar(verified)) + def continue(): JsCmd = + S.redirectTo(Site.signUp6.url, () => VerifiedVar(verified)) - "#user-area" #> Templates("templates-hidden" :: "parts" :: "user-profile" :: Nil).map{ ns => - Site.userProfileLoc.requestValue(user) - ns - } & - "#user-continue" #> ajaxSubmit("Continue", continue) + "#user-area" #> + Templates("templates-hidden" :: "parts" :: "user-profile" :: Nil).map { + ns => + Site.userProfileLoc.requestValue(user) + ns + } & + "#user-continue" #> ajaxSubmit("Continue", continue) } /** @@ -147,12 +161,14 @@ class SignUp { val verified = VerifiedVar.is assert(!verified || user.isEmpty) - def continue(): JsCmd = S.redirectTo("/") + def continue(): JsCmd = S.redirectTo(Site.reader.url) "#user-continue" #> ajaxSubmit("Continue", continue) } - def assert(bool: Boolean) { if(bool) S.redirectTo(Site.notFound.url) } + def assert(bool: Boolean) { + if (bool) S.redirectTo(Site.notFound.url) + } } diff --git a/src/main/scala/com/joereader/snippet/SitemapContent.scala b/src/main/scala/com/joereader/snippet/SitemapContent.scala @@ -0,0 +1,39 @@ +package com.joereader.snippet + +import org.joda.time._ + +import net.liftweb._ +import util._, Helpers._ +import http._ +import rest._ + +import com.joereader.config._ + +object Sitemap extends RestHelper { + serve { + case Req("sitemap" :: Nil, _, GetRequest) => + XmlResponse( + S.render(<lift:embed what="sitemap" />, + S.request.get.request).head) + } +} + +class SitemapContent { + + case class Post(url: String, date: DateTime) + + lazy val entries = Site.siteMap.locForGroup("sitemap"). + map(_.calcDefaultHref).map(path => Post(path, new DateTime)) + + val siteLastUpdated = new DateTime + + def base: CssSel = + "loc *" #> "http://%s/".format(S.hostName) & + "lastmod *" #> siteLastUpdated.toString("yyyy-MM-dd'T'HH:mm:ss.SSSZZ") + + def list: CssSel = + "url *" #> entries.map(post => + "loc *" #> "http://%s%s".format(S.hostName, post.url) & + "lastmod *" #> post.date.toString("yyyy-MM-dd'T'HH:mm:ss.SSSZZ")) + +} diff --git a/src/main/scala/com/joereader/snippet/UserBlogsSnip.scala b/src/main/scala/com/joereader/snippet/UserBlogsSnip.scala @@ -0,0 +1,30 @@ +package com.joereader.snippet + +import com.joereader._ +import model._ +import config._ +import snippet.SnipHelpers._ + +import net.liftweb.util.Helpers._ +import scala.xml._ + +/** + * Used in settings to list all the blogs the current user writes for. + */ +object UserBlogsSnip extends UserSnip { + + def user = User.currentUser + + def blogs(html: NodeSeq): NodeSeq = serve(html) { + user => + ".blog *" #> user.blogs.objs.map { + blog => + ".blog-profile-link [href]" #> Site.blogProfileLoc.calcHref(blog) & + ".blog-settings-link [href]" #> Site.blogSettingsLoc.calcHref(blog) & + ".blog-image [src]" #> imageUrl(blog) & + "#blog-name" #> blog.name.get + } + }(test = true, NodeSeq.Empty) + + def addBlog() = "* [href]" #> Site.blogVerify.url +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/UserLogin.scala b/src/main/scala/com/joereader/snippet/UserLogin.scala @@ -0,0 +1,66 @@ +package com.joereader.snippet + +import net.liftweb._ +import util.Helpers._ +import common._ +import http._ +import js._ +import SHtml._ +import JsCmds._ + +import com.joereader._ +import model._ +import config._ + +import net.liftmodules.mongoauth.model.ExtSession +import net.liftmodules.mongoauth.LoginRedirect + + +object UserLogin extends Loggable { + + def render = { + + var pwd = "" + + def doSubmit(): JsCmd = { + S.param("email").map(e => { + val email = e.toLowerCase.trim + // save the email and remember entered in the session var + User.loginCredentials(email) + + if (email.length > 0 && pwd.length > 0) { + User.findByEmail(email) match { + case Full(user) if user.password.isMatch(pwd) => + User.logUserIn(user, isAuthed = true) + ExtSession.deleteExtCookie() + RedirectTo(LoginRedirect.openOr(Site.home.url)) + case _ => + S.error("id_password_err", "Invalid credentials") + Noop + } + } + else if (email.length <= 0 && pwd.length > 0) { + S.error("id_email_err", "Please enter an email") + Noop + } + else if (pwd.length <= 0 && email.length > 0) { + S.error("id_password_err", "Please enter a password") + Noop + } + else { + S.error("id_email_err", "Please enter an email") + S.error("id_password_err", "Please enter a password") + Noop + } + }) openOr { + S.error("id_email_err", "Please enter an email address") + Noop + } + } + + "#id_email [value]" #> User.loginCredentials.is & + "#id_password" #> password(pwd, pwd = _) & + "#forgot_password [href]" #> Site.passwordRecovery.url & + "#id_submit" #> hidden(doSubmit) + } +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/UserPassword.scala b/src/main/scala/com/joereader/snippet/UserPassword.scala @@ -0,0 +1,56 @@ +package com.joereader.snippet + +import com.joereader.model._ + +/* Mixin when handling passwords. */ +trait UserPassword { + + // Checks if password is stealthy enough + def validPassword(pw: String): (Boolean, String) = { + if (pw.length < 1) + (false, "You forgot to enter a password") + else if (pw.length < 8) + (false, "Password must be at least 8 figures long") + else + (true, "") + } + + def resettablePassword( + oldPw: String, + newPw: String, + confirmPw: String) = { + if (!correctPassword(oldPw)) + (false, "Your old password is incorrect") + else if (newPw != confirmPw) + (false, "Your passwords do not match") + else + (true, "") + } + + def savePassword( + oldPw: String, + newPw: String, + confirmPw: String): (Boolean, String) = { + val (valid, error) = + resettablePassword(oldPw, newPw, confirmPw) + if (valid) + savePassword(newPw) + else + (false, error) + } + + def savePassword(pw: String): (Boolean, String) = + User.currentUser.map { + user => + val (valid, error) = validPassword(pw) + if (valid) { + user.password(pw) + user.password.hashIt + user.update + (true, "") + } else (false, error) + } openOr(false, "???") + + def correctPassword(pw: String) = + User.currentUser.exists(_.password.isMatch(pw)) +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/UserReaderSnipEdit.scala b/src/main/scala/com/joereader/snippet/UserReaderSnipEdit.scala @@ -0,0 +1,99 @@ +package com.joereader.snippet + +import com.joereader._ +import model._ +import lib._, Helper._ +import config._ +import SnipHelpers._ + +import scala.xml._ + +import net.liftweb._ +import util.Helpers._ +import http._ +import js._ +import JsCmds._ +import SHtml._ + +/** + * Snippets for currently logged in user (could be writer or reader) + * to edit their information. + * We subclass the View trait to fallback if access control test fails. + */ +trait UserReaderSnipEdit extends UserReaderSnipView with UserPassword { + + private def test = user.exists(u => + User.currentUser.exists(_.id.is == u.id.is)) + + override def email(html: NodeSeq) = serve(html) { + user => + def checkEmail(email: String): JsCmd = { + val u = User.findByEmail(email) + def isYou = u.exists(_.id.is == user.id.is) + + if (!email.isEmail) + S.error("Not a valid email") + else if (isYou) + Noop + else if (u.isDefined) + S.error("That email is registered already") + else { + user.email(email).verified(false).update + S.notice("New email is saved") + } + } + "*" #> ajaxText(user.email.is, checkEmail) + }(test, super.email(html)) + + def password(html: NodeSeq) = serve(html) { + user => + "*" #> ajaxText("", { + s => + val (pass, msg) = savePassword(s) + if (pass) S.notice("Your new password has been saved") + else S.error("id_password_err", msg) + }, "type" -> "password", "placeholder" -> "password") + }(test, super.name) + + def setPassword(html: NodeSeq) = serve(html) { + user => + var oldpwd, pwd, pwd2 = "" + + def process(): JsCmd = { + val (pass, msg) = savePassword(oldpwd, pwd, pwd2) + if (pass) + SetValById("old-pwd", "") & + SetValById("new-pwd", "") & + SetValById("new-pwd2", "") + else S.error(msg) + } + + "#old-pwd" #> SHtml.password(oldpwd, oldpwd = _) & + "#new-pwd" #> SHtml.password(pwd, pwd = _) & + "#new-pwd2" #> SHtml.password(pwd2, pwd2 = _) & + "#submit-pwd" #> ajaxSubmit("Set Password", process) + }(test, NodeSeq.Empty) + + def name(html: NodeSeq) = serve(html) { + user => + "*" #> ajaxText(user.name.is, { + s => user.name(s).update; Noop + }, "placeholder" -> "Your Name") + }(test, super.name) + + def uploadImg(html: NodeSeq) = serve(html) { + user => + "*" #> insertFileUpload(ImageUpload.profile, ImageUpload.userUrl) + }(test, NodeSeq.Empty) + + /* + * This wraps the profile with editable-page class so user can + * edit information from their profile page. + */ + def surround = + "#profile-wrap [class+]" #> (if (test) "editable-page" else "") + + def previewPage(html: NodeSeq) = serve(html)(user => + "* [href]" #> Site.userPreviewLoc.calcHref(user) + )(!test && user.exists(_.isWriter), html) +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/UserReaderSnipView.scala b/src/main/scala/com/joereader/snippet/UserReaderSnipView.scala @@ -0,0 +1,31 @@ +package com.joereader.snippet + +import net.liftweb.util.Helpers._ +import scala.xml._ +import com.joereader.model._ +import SnipHelpers._ + +/** + * Snippets to view information about a writer or reader. + */ +trait UserReaderSnipView extends UserSnip { + + def email(html: NodeSeq) = serve(html)(user => + "*" #> user.email.is)(test = true, NodeSeq.Empty) + + def name = serve(user => + Text(user.name.is))(test = true, NodeSeq.Empty) + + def img(html: NodeSeq) = serve(html) { + user => + val bwu = BlogWriterUser(Some(user)) + "* [id]" #> imgProfileId & + "* [class+]" #> "profile-img-size" & + "* [src]" #> bwu.image + }(test = true, NodeSeq.Empty) + + def introVidUrl(html: NodeSeq) = serve(html)(user => + "* [src]" #> ("http://www.youtube.com/embed/" + + user.introVid.is + "?autoplay=1&iv_load_policy=3") + )(test = true, NodeSeq.Empty) +} diff --git a/src/main/scala/com/joereader/snippet/UserRecovery.scala b/src/main/scala/com/joereader/snippet/UserRecovery.scala @@ -0,0 +1,45 @@ +package com.joereader.snippet + +import net.liftweb._ +import util.Helpers._ +import common._ +import http._ +import js._ +import SHtml._ +import JsCmds._ + +import com.joereader._ +import model._ + + +object UserRecovery extends Loggable { + + def render = { + + def doSubmit(): JsCmd = + S.param("email").map(e => { + val email = e.toLowerCase.trim + User.loginCredentials(email) + + User.findByEmail(email) match { + case Full(user) => + User.sendLoginToken(user) + User.loginCredentials.remove() + S.notice("id_email_err", "An email has been " + + "sent to you with instructions to access your account") + Noop + case _ => + S.error("id_email_err", + "The email you entered cannot be found") + Noop + } + + }) openOr { + S.error("id_email_err", "Please enter an email address") + Noop + } + + "#id_email [value]" #> User.loginCredentials.is & + "#id_submit" #> hidden(doSubmit) + } +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/UserSnips.scala b/src/main/scala/com/joereader/snippet/UserSnips.scala @@ -1,545 +1,89 @@ -package com.joereader -package snippet - +package com.joereader.snippet import scala.xml._ import net.liftweb._ import common._ import http._ -import SHtml._ -import js._ -import JsCmds._ -import util._, Helpers._ +import util._ import net.liftmodules._ -import mongoauth._ -import extras.{Gravatar, SnippetHelper} -import net.liftmodules.mongoauth.model.{LoginToken, ExtSession} +import extras.SnippetHelper import com.joereader._ -import com.joereader.model._ -import com.joereader.snippet.SnipHelpers._ +import model._ import config._ -import lib._, Helper._, rss.Rss._ -import net.liftweb.common.Full -import net.liftweb.http.js.JsCmds.SetValById -import net.liftweb.common.Full -import com.joereader.config.Site.BlogWriterContent -import org.bson.types.ObjectId -import net.liftweb.http.js.JsCmds.SetValById -import net.liftweb.common.Full -import net.liftweb.http.js.JE.JsFalse -trait UserSnippet extends SnippetHelper with Loggable{ + +trait UserSnip extends SnippetHelper with Loggable { protected def user: Box[User] - protected def serve(snip: User => NodeSeq)(test: Boolean, fallback: NodeSeq): NodeSeq = + protected def serve(snip: User => NodeSeq) + (test: Boolean, fallback: NodeSeq): NodeSeq = (for { u <- user ?~ "User not found" } yield { - if(test) snip(u) else fallback + if (test) snip(u) else fallback }): NodeSeq - protected def serve(html: NodeSeq)(snip: User => CssSel)(test: Boolean, fallback: NodeSeq): NodeSeq = + protected def serve(html: NodeSeq) + (snip: User => CssSel) + (test: Boolean, fallback: NodeSeq): NodeSeq = (for { u <- user ?~ "User not found" } yield { - if(test) snip(u)(html) else fallback + if (test) snip(u)(html) else fallback }): NodeSeq } -trait ReaderUserSnippet extends UserSnippet { - private def testReaderUserSnippet = true - - def email(html: NodeSeq) = serve(html)(user => "*" #> user.email.is)(testReaderUserSnippet, NodeSeq.Empty) - def name = serve(user => Text(user.name.is))(testReaderUserSnippet, NodeSeq.Empty) - - def img(html: NodeSeq) = serve(html){user => - "* [id]" #> imgProfileId & - "* [width]" #> imgProfileSize & - "* [src]" #> imageUrl(user, imgProfileSize) - }(testReaderUserSnippet, NodeSeq.Empty) - - def introVidUrl(html: NodeSeq) = serve(html)(user => - "* [src]" #> ("http://www.youtube.com/embed/"+user.introVid.is+"?autoplay=1&iv_load_policy=3") - )(testReaderUserSnippet, NodeSeq.Empty) - - def previewPage(html: NodeSeq) = serve(html)(user => - "* [href]" #> Site.userPreviewLoc.calcHref(user) - )(testReaderUserSnippet, NodeSeq.Empty) -} - -trait WriterUserSnippet extends UserSnippet { - - private def testWriterUserSnippet = user.exists(!_.blogs.is.isEmpty) - - def username = serve(user => Text(user.username.is))(testWriterUserSnippet, NodeSeq.Empty) - def about = serve(user => Text(user.about.is))(testWriterUserSnippet, NodeSeq.Empty) - - def bgImg(html: NodeSeq) = serve(html){ user => - "* [id]" #> imgBgId & - "* [src]" #> imageBgUrl(user) - }(testWriterUserSnippet, NodeSeq.Empty) - - def followButton(html: NodeSeq) = serve(html) { user => - val btnId = "follow-btn" - - def isFollowing: Boolean = User.currentUser.exists( loggedInUser => - user.followers.get.exists(_ == loggedInUser.id.get)) - - def follow: JsCmd = { - for { - loggedInUser <- User.currentUser - blogs <- user.blogs.get.par - blog <- blogs.blog.obj - blogWriter <- blog.writer(user) - } { - loggedInUser.follow(blogWriter, blog) - blogWriter.addFollower(loggedInUser) - blog.save - } - User.currentUser.map(_.follow(user).update) - AddClass(btnId, "btn-info") & RemoveClass(btnId, "btn-primary") & SetValById(btnId, "Unfollow") - } - - def unFollow: JsCmd = { - for { - loggedInUser <- User.currentUser - blogs <- user.blogs.get.par - blog <- blogs.blog.obj - blogWriter <- blog.writer(user) - } { - loggedInUser.unFollow(blogWriter, blog) - blogWriter.removeFollower(loggedInUser) - blog.save - } - User.currentUser.map(_.unFollow(user).update) - RemoveClass(btnId, "btn-info") & AddClass(btnId, "btn-primary") & SetValById(btnId, "Follow") - } - - "*" #> { - if(isFollowing) - ajaxButton("Unfollow", () => unFollow, "class" -> "btn btn-info btn-large", "id" -> btnId) - else - ajaxButton("Follow", () => follow, "class" -> "btn btn-primary btn-large", "id" -> btnId) - } - } (User.isLoggedIn && !testWriterUserSnippet, NodeSeq.Empty) - - def followingList(html: NodeSeq) = serve(html) { user => - val following = user.following.randomUsers(6) - - "*" #> following.map( ub => - <a href={Site.userProfileLoc.calcHref(ub.user)}> - <img src={image100Url(ub.user)}/></a>) - } (User.isLoggedIn && !testWriterUserSnippet, NodeSeq.Empty) - - def followersAmount(html: NodeSeq) = serve(html) { user => - val followers: Int = - (for { - blogs <- user.blogs.get - blog <- blogs.blog.obj - writer <- blog.writer(user) - } yield writer.followers.get.size).sum - - "*" #> (followers + " followers") - } (User.isLoggedIn && !testWriterUserSnippet, NodeSeq.Empty) - - def followingAmount(html: NodeSeq) = serve(html) { user => - val following: Int = user.following.get.size - "*" #> <a href={Site.userFollowingLoc.calcHref(user)}>Following {following}</a> - } (User.isLoggedIn && !testWriterUserSnippet, NodeSeq.Empty) - - def articles = serve { user => - user.blogs.is.map { - ub => - val blog = ub.blog.obj.openOr(Blog.createRecord) - blog.writers.is.filter(_.user.is == user.id.is).flatMap { - bw => - blog.urlRss.is.head.entries.filter(_.author == bw.name.is). - flatMap(e=> SnipHelpers.userProfileArticle(e)) - } - }.flatten - }(testWriterUserSnippet, NodeSeq.Empty) -} - -trait CurrentReaderUser extends ReaderUserSnippet with UserPassword { - - protected def test = user.exists(u => true) - - override def email(html: NodeSeq) = serve(html) { user => - def checkEmail(email: String): JsCmd = { - val u = User.findByEmail(email) - def isYou = u.exists(_.id.is == user.id.is) - - if(!email.isEmail) - S.error("Not a valid email") - else if(isYou) - Noop - else if(u.isDefined) - S.error("That email is registered already") - else { - user.email(email).verified(false).update - S.notice("New email is saved") - } - } - "*" #> ajaxText(user.email.is, checkEmail) - } (test, super.email(html)) - - def password(html: NodeSeq) = serve(html) { user => - "*" #> ajaxText("", {s => - val (pass,msg) = savePassword(s) - if(pass) Noop else S.error("id_password_err", msg) - }, "type" -> "password", "placeholder" -> "password") - } (test, super.name) - - def setPassword(html: NodeSeq) = serve(html) { user => - var oldpwd, pwd, pwd2 = "" - - def process(): JsCmd = { - val (pass, msg) = savePassword(oldpwd, pwd, pwd2) - if(pass) SetValById("old-pwd","") & SetValById("new-pwd","") & SetValById("new-pwd2","") - else S.error(msg) - } - - "#old-pwd" #> SHtml.password(oldpwd, oldpwd = _) & - "#new-pwd" #> SHtml.password(pwd, pwd = _) & - "#new-pwd2" #> SHtml.password(pwd2, pwd2 = _) & - "#submit-pwd" #> ajaxSubmit("Set Password", process) - } (test, NodeSeq.Empty) - - def name(html: NodeSeq) = serve(html) { user => - "*" #> ajaxText(user.name.is, {s => user.name(s).update; Noop}) - } (test, super.name) - - def uploadImg(html: NodeSeq) = serve(html) { user => - "*" #> insertFileUpload("pic", ImageUpload.userUrl()) - }(test, NodeSeq.Empty) -} - -trait CurrentWriterUser extends WriterUserSnippet { - - protected def test = user.exists(!_.blogs.is.isEmpty) - - def about(html: NodeSeq) = serve(html) { user => - "*" #> ajaxTextarea(user.about.is, {s => user.about(s).update; Noop}) - } (test, super.about) - - def uploadBgImg(html: NodeSeq) = serve(html) { user => - "*" #> insertFileUpload("bg", ImageUpload.userUrl("bg")) - }(test, NodeSeq.Empty) - - def username(html: NodeSeq) = serve(html) { user => - - def check(s: String): JsCmd = { - val u = User.findByUsername(s) - def isYou = u.exists(_.id.is == user.id.is) - - if(!s.matches("^[a-z0-9-]{2,}$")) - S.error("User name can only contain alphanumeric or dash characters") - else if(isYou) - Noop - else if(u.isEmpty && Site.isAvailableMenu(s)) { - user.username(s).update - S.notice("New username is saved") - } - else S.error("Username is taken") - } - - "*" #> ajaxText(user.username.is, { s: String => check(s.toLowerCase)}) - } (test, super.username) - - def introVid(html: NodeSeq) = serve(html) { user => - - def check(id: String): JsCmd = { - import VideoService._, dispatch._ - if(id.isEmpty) Noop - val time: Int = Youtube.info.videoDuration(id)().getOrElse(-1) - if(time < 0) S.error("Video could not be found") - else if(time <= 30) { user.introVid(id).update; Noop } - else S.error("Video duration must be 30 seconds or less") - } - "*" #> ajaxText(user.introVid.is, {s => check(s); Noop}) - }(test, NodeSeq.Empty) - - def otherVid(html: NodeSeq) = serve(html) { user => - - var vids: List[String] = user.otherVid.is - - def addVideo(in: String): JsCmd = { - val res = if(in.isEmpty) Nil else in.split(",").map(_.trim).toList - val newVids: List[String] = res diff vids - val sameVids: List[String] = res diff newVids - - user.otherVid(sameVids).update - vids = sameVids - - var failed: List[String] = Nil - newVids.par.map{ id => - if(validVideo(id)) { - user.addVideo(id).update - vids = id :: vids - } - else failed = id :: failed - } - - if(!failed.isEmpty) - S.error("Video ids ~ "+failed.mkString(", ")+" ~ could not be found") - else Noop - } - - def validVideo(id: String): Boolean = { - import VideoService._, dispatch._ - val time = Youtube.info.videoDuration(id)() - if(time.isDefined) true else false - } - - "*" #> ajaxText(vids.mkString(", "), addVideo) - }(test, NodeSeq.Empty) -} - -object CurrentReader extends CurrentReaderUser { +object CurrentReader extends UserReaderSnipEdit { protected def user = User.currentUser } -object CurrentWriter extends CurrentWriterUser { + +object CurrentWriter extends UserWriterSnipEdit { protected def user = User.currentUser } -object ProfileLocReader extends CurrentReaderUser { - protected def user = Site.userProfileLoc.currentValue - override def test = user.exists(u => User.currentUser.exists(_.id.is == u.id.is)) && super.test - - def surround = "#profile-wrap [class+]" #> (if(test) "editable-page" else "") +object ProfileLocReader extends UserReaderSnipEdit with FollowSnip { + override protected def user = Site.userProfileLoc.currentValue + protected def bwu = user.map(u => BlogWriterUser(user)) } -object ProfileLocWriter extends CurrentWriterUser { +object ProfileLocWriter extends UserWriterSnipEdit { protected def user = Site.userProfileLoc.currentValue - override def test = user.exists(u => User.currentUser.exists(_.id.is == u.id.is)) && super.test - - def uploadButton(html: NodeSeq) = serve(html) { user => - "*" #> button("Upload Media", () => {}, "href" -> "#settingsModal", "role" -> "button", - "class" -> "btn btn-primary btn-large btn-bold", "data-toggle" -> "modal") - } (test, NodeSeq.Empty) } -object PreviewLocReader extends ReaderUserSnippet { - protected def user = Site.userPreviewLoc.currentValue +object PreviewLocReader extends UserReaderSnipView with FollowSnip { + override protected def user = Site.userPreviewLoc.currentValue + protected def bwu = user.map(u => BlogWriterUser(user)) } -object PreviewLocWriter extends WriterUserSnippet { +object PreviewLocWriter extends UserWriterSnipView { protected def user = Site.userPreviewLoc.currentValue -} - -object UserFollowing extends UserSnippet { - def user = Site.userFollowingLoc.currentValue - - def following = serve { user => - user.following.allUsers.toSeq.flatMap { ub => - <div class="row-fluid writer"> - <div class="span4"> - <img src={image100Url(ub.user)}/> - </div> - <div class="span8"> - <div class="name">{ub.user.name.get}</div> - <div class="categories">{ub.userBlog.categories.get.mkString(", ")}</div> - </div> - </div> - } - } (test = true, NodeSeq.Empty) -} - -class MyFollowing extends WriterUserSnippet { - - var iUser: Box[User] = Full(User.createRecord) - override def user: Box[User] = iUser - - def following: NodeSeq = User.currentUser.map { user => - user.following.allUsers.toSeq.flatMap { ub => - iUser = Full(ub.user) - <div class="row-fluid writer"> - <div class="span4"> - <img src={image100Url(ub.user)}/> - </div> - <div class="span6"> - <div class="name">{ub.user.name.get}</div> - <div class="categories">{ub.userBlog.categories.get.mkString(", ")}</div> - </div> - <div class="span2"> - {followButton(<span></span>)} - </div> - </div> - } - } getOrElse NodeSeq.Empty -} -object UserLogin extends Loggable { - - def render = { - - var pwd = "" - - def doSubmit(): JsCmd = { - S.param("email").map(e => { - val email = e.toLowerCase.trim - // save the email and remember entered in the session var - User.loginCredentials(email) - - if (email.length > 0 && pwd.length > 0) { - User.findByEmail(email) match { - case Full(user) if user.password.isMatch(pwd) => - User.logUserIn(user, isAuthed = true) - ExtSession.deleteExtCookie() - RedirectTo(LoginRedirect.openOr(Site.home.url)) - case _ => - S.error("id_password_err", "Invalid credentials") - Noop - } - } - else if (email.length <= 0 && pwd.length > 0) { - S.error("id_email_err", "Please enter an email") - Noop - } - else if (pwd.length <= 0 && email.length > 0) { - S.error("id_password_err", "Please enter a password") - Noop - } - else { - S.error("id_email_err", "Please enter an email") - S.error("id_password_err", "Please enter a password") - Noop - } - }) openOr { - S.error("id_email_err", "Please enter an email address") - Noop - } - } - - "#id_email [value]" #> User.loginCredentials.is & - "#id_password" #> password(pwd, pwd = _) & - "#forgot_password [href]" #> Site.passwordRecovery.url & - "#id_submit" #> hidden(doSubmit) - } -} - -object UserRecovery extends Loggable { - - def render = { - - def doSubmit(): JsCmd = - S.param("email").map(e => { - val email = e.toLowerCase.trim - User.loginCredentials(email) - - User.findByEmail(email) match { - case Full(user) => - User.sendLoginToken(user) - User.loginCredentials.remove() - S.notice("id_email_err", "An email has been sent to you with instructions to access your account") - Noop - case _ => - S.error("id_email_err", "The email you entered cannot be found") - Noop - } - - }) openOr { - S.error("id_email_err", "Please enter an email address") - Noop - } - - "#id_email [value]" #> User.loginCredentials.is & - "#id_submit" #> hidden(doSubmit) - } + def redirect(html: NodeSeq) = + if (user.exists(_.isWriter)) html + else S.redirectTo(Site.notFound.url) } -object PasswordReset extends Loggable { - - def render = { - - User.currentUser.map { user => - val token = LoginToken.find(LoginToken.userId.name, user.id.is) - if(token.isEmpty) S.redirectTo(Site.notFound.url) - else token.map(_.delete_!) - } +/* + * Go to a user's (must be a writer) following page + * to view who they follows. + */ +object UserFollowing extends FollowSnip { - var pwd, pwd2 = "" + override protected def followingUser = + Site.userFollowingLoc.currentValue + protected def bwu = Empty - def doSubmit(): JsCmd = - if(pwd == pwd2) { - User.currentUser.map { user => - user.password(pwd) - user.password.hashIt - user.save - } - S.redirectTo(Site.home.url) - } - else - S.error("id_pwd2_err", "Password does not match") - - "#id_pwd" #> password(pwd, pwd = _) & - "#id_pwd2" #> password(pwd2, pwd2 = _) & - "#id_submit" #> ajaxSubmit("Save", doSubmit) - } + def redirect(html: NodeSeq) = + if (followingUser.exists(_.isWriter)) html + else S.redirectTo(Site.notFound.url) } -trait UserPassword { - // Checks if password is stealthy enough - def validPassword(pw: String): (Boolean, String) = { - if(pw.length < 1) (false, "You forgot to enter a password") - else if(pw.length < 8) (false, "Password must be at least 8 figures long") - else (true,"") - } - - def resettablePassword(oldPw: String, newPw: String, confirmPw: String): (Boolean, String) = { - if(!correctPassword(oldPw)) - (false, "Your old password is incorrect") - else if(newPw != confirmPw) - (false, "Your passwords do not match") - else (true,"") - } - - def savePassword(oldPw: String, newPw: String, confirmPw: String): (Boolean, String) = { - val reset = resettablePassword(oldPw, newPw, confirmPw) - if(reset._1) savePassword(newPw) else (false, reset._2) - } - - def savePassword(pw: String): (Boolean, String) = User.currentUser.map { user => - val valid = validPassword(pw) - if(valid._1) { - user.password(pw) - user.password.hashIt - user.update - (true,"") - } else (false, valid._2) - } openOr(false, "???") - - def correctPassword(pw: String) = User.currentUser.exists(_.password.isMatch(pw)) -} - -object UserTopbar { - def render = { - User.currentUser match { - case Full(user) => - <ul class="nav pull-right" id="user"> - <li class="dropdown" data-dropdown="dropdown"> - <a href="#" class="dropdown-toggle" data-toggle="dropdown"> - <img src={image100Url(user)} width="20" height="20" style="margin-right:10px"></img> - <span>{user.name.get}</span> - <b class="caret"></b> - </a> - <ul class="dropdown-menu"> - <li><a href={Site.userProfileLoc.calcHref(user)}>Profile</a></li> - <li><lift:Menu.item name="Account" donthide="true" linktoself="true">Settings</lift:Menu.item></li> - <li class="divider"></li> - <li><lift:Menu.item name="Logout" donthide="true">Log Out</lift:Menu.item></li> - </ul> - </li> - </ul> - case _ if S.request.flatMap(_.location).map(_.name).filterNot(it => List("Login", "Register").contains(it)).isDefined => - <ul class="nav pull-right"> - <li><a href="/login">Sign In</a></li> - </ul> - case _ => NodeSeq.Empty - } - } -} +/* What the currently loggedin user follows. */ +object MyFollowing extends FollowSnip { + override protected def followingUser = User.currentUser + protected def bwu = Empty +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/UserTopbar.scala b/src/main/scala/com/joereader/snippet/UserTopbar.scala @@ -0,0 +1,73 @@ +package com.joereader.snippet + +import com.joereader._ +import model._ +import config._ + +import net.liftweb._ +import common._ +import http._ + +import net.liftmodules.extras.Gravatar +import scala.xml._ + +/* Someone needs an extreme makeover. */ +object UserTopbar { + def render = { + User.currentUser match { + case Full(user) => + <ul class="nav pull-right" id="user"> + <li class="dropdown" data-dropdown="dropdown"> + <a href="#" class="dropdown-toggle" data-toggle="dropdown"> + <img src={ + val bwu = BlogWriterUser(Some(user)) + bwu.image + } width="20" height="20" style="margin-right:10px"></img> + <span> + {user.name.get} + </span> + <b class="caret"></b> + </a> + <ul class="dropdown-menu"> + <li> + <a href={Site.reader.url}>Reader</a> + </li> + <li> + <a href={Site.searchCategories.url}>Search Writers</a> + </li> + <li> + <a href={Site.editAccount.url}>Settings</a> + </li> + { + if(user.isWriter) { + <li> + <a href={Site.userProfileLoc.calcHref(user)}>Profile</a> + </li> + <li class="divider"></li> + } + } + { + user.blogs.objs.map { blog => + <li> + <a href={Site.blogProfileLoc.calcHref(blog)}>{blog.name.get}</a> + </li> + } + } + <li class="divider"></li> + <li> + <a href={Site.logout.url}>Log Out</a> + </li> + </ul> + </li> + </ul> + case _ if S.request.flatMap(_.location).map(_.name). + filterNot(it => List("Login", "Register").contains(it)).isDefined => + <ul class="nav pull-right"> + <li> + <a href="/login">Sign In</a> + </li> + </ul> + case _ => NodeSeq.Empty + } + } +} diff --git a/src/main/scala/com/joereader/snippet/UserWriterSnipEdit.scala b/src/main/scala/com/joereader/snippet/UserWriterSnipEdit.scala @@ -0,0 +1,134 @@ +package com.joereader.snippet + +import com.joereader._ +import model._ +import config._ +import lib._ +import SnipHelpers._ + +import scala.xml._ + +import net.liftweb._ +import util.Helpers._ +import http._ +import js._ +import JsCmds._ +import SHtml._ +import common._ + +/** + * Snippets for currently logged in user (writer only) + * to edit their information. + * We subclass the View trait to fallback if access control test fails. + */ +trait UserWriterSnipEdit extends UserWriterSnipView with BackgroundSnip { + + private def test = user.exists(u => + User.currentUser.exists(_.id.is == u.id.is) && u.isWriter) + + def about(html: NodeSeq) = serve(html) { + user => + "*" #> ajaxTextarea(user.about.is, { + s => user.about(s).update; Noop + }, "placeholder" -> "About Me goes here") + }(test, super.about) + + def uploadBgImg(html: NodeSeq): NodeSeq = serve(html) { + uploadBgImg + }(test, NodeSeq.Empty) + + def username(html: NodeSeq) = serve(html) { + user => + + def check(s: String): JsCmd = { + val u = User.findByUsername(s) + def isYou = u.exists(_.id.is == user.id.is) + + if (!s.matches("^[a-z0-9-]{2,}$")) + S.error("User name can only contain " + + "alphanumeric or dash characters") + else if (isYou) + Noop + else if (u.isEmpty && Site.isAvailableMenu(s)) { + user.username(s).update + Noop + } + else S.error("Username is taken") + } + + "*" #> ajaxText(user.username.is, { + s: String => check(s.toLowerCase) + }) + }(test, super.username) + + def introVid(html: NodeSeq) = serve(html) { + user => + + def check(id: String): JsCmd = { + import VideoService._, dispatch._ + if (id.isEmpty) Noop + else { + val time: Int = Youtube.info.videoDuration(id)().getOrElse(-1) + if (time < 0) S.error("Video could not be found") + else if (time <= 30) { + user.introVid(id).update + Noop + } + else S.error("Video duration must be 30 seconds or less") + } + } + "*" #> ajaxText(user.introVid.is, { + s => check(s); Noop + }) + }(test, NodeSeq.Empty) + + def otherVid(html: NodeSeq) = serve(html) { + user => + + var vids: List[String] = user.otherVid.is + + def addVideo(in: String): JsCmd = { + val res = + if (in.isEmpty) Nil + else in.split(",").map(_.trim).toList + + val newVids: List[String] = res diff vids + val sameVids: List[String] = res diff newVids + + user.otherVid(sameVids).update + vids = sameVids + + var failed: List[String] = Nil + newVids.map { + id => + if (validVideo(id)) { + user.addVideo(id).update + vids = id :: vids + } + else failed = id :: failed + } + + if (!failed.isEmpty) + S.error("Video ids ~ " + failed.mkString(", ") + + " ~ could not be found") + else Noop + } + + def validVideo(id: String): Boolean = { + import VideoService._, dispatch._ + val time = Youtube.info.videoDuration(id)() + if (time.isDefined) true else false + } + + "*" #> ajaxText(vids.mkString(", "), addVideo) + }(test, NodeSeq.Empty) + + def uploadButton(html: NodeSeq) = serve(html) { + user => + "*" #> button("Upload Media", () => {}, + "href" -> "#settingsModal", + "role" -> "button", + "class" -> "btn btn-primary btn-large btn-bold up-separate", + "data-toggle" -> "modal") + }(test, NodeSeq.Empty) +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/snippet/UserWriterSnipView.scala b/src/main/scala/com/joereader/snippet/UserWriterSnipView.scala @@ -0,0 +1,77 @@ +package com.joereader.snippet + +import net.liftweb.util.Helpers._ +import scala.xml._ + +import com.joereader._ +import config._ +import lib.rss._ +import model._ +import SnipHelpers._ + +/** + * Snippets to view information about a writer only. + */ +trait UserWriterSnipView extends UserSnip with ArticleSnip with BackgroundSnip { + + private def test = user.exists(_ isWriter) + + def username = serve(user => + Text(user.username.is))(test, NodeSeq.Empty) + + def about = serve(user => + Text(user.about.is))(test, NodeSeq.Empty) + + def bgImg(html: NodeSeq) = serve(html) { + user => + + "* [id]" #> imgBgId & + "* [src]" #> imageBgUrl(user) + }(test, NodeSeq.Empty) + + def followingList(html: NodeSeq) = serve(html) { + user => + val following = user.following.randomUsers(6) + + "*" #> following.map(bwu => + <a href={bwu.link}> + <img src={bwu.image}/> + </a>) + }(test, NodeSeq.Empty) + + def followingAmount(html: NodeSeq) = serve(html) { + user => + val following: Int = user.following.get.size + if (following > 0) + "*" #> + <a href={Site.userFollowingLoc.calcHref(user)}> + Following {following} + </a> + else + "*" #> NodeSeq.Empty + }(test, NodeSeq.Empty) + + def articles(html: NodeSeq): NodeSeq = serve(html) { + user => + + val entries: List[(FeedEntry, BlogWriterUser)] = + (for { + blogs <- user.blogs.get + blog <- Blog.find(blogs) + blogWriter <- blog.writer(user) + } yield blog.urlRss.get.head.entries. + filter(_.author.name == blogWriter.name.get). + map((_, BlogWriterUser(Some(user), Some(blog), Some(blogWriter))))). + flatten. + sortWith((x,y) => x._1.date.getTime > y._1.date.getTime ) + + articles(entries) + + }(test, NodeSeq.Empty) + + def showIfWriter(html: NodeSeq) = + if (test) html else NodeSeq.Empty + + def showIfWriterAndLoggedIn(html: NodeSeq) = + if (test && User.isLoggedIn) html else NodeSeq.Empty +} diff --git a/src/main/scala/com/joereader/snippet/Verification.scala b/src/main/scala/com/joereader/snippet/Verification.scala @@ -9,14 +9,16 @@ import js._, JsCmds._ import common._ import com.joereader._ -import lib.rss.Rss._ -import snippet.SnipHelpers._ +import lib._, rss._ import config._ import model._ + import scala.xml._ -import com.joereader.lib.{Wordpress, WordpressInfo} -// this is used in case of wordpress callbacks +/* + * A session var had to be included so we can keep state between Wordpress + * signin and callback. Reset after done using it! + */ object CurrentVerification extends SessionVar(VerificationSettings()) case class VerificationSettings() { @@ -24,7 +26,6 @@ case class VerificationSettings() { var blog: Blog = Blog.createRecord var searched, verified = false var urlHtml = "" - } /** @@ -35,11 +36,15 @@ class Verification extends VerificationDesigns { var s = VerificationSettings() val writerName = ValueCell("") + val owner = ValueCell(false) val metaName = "readmeans" + val metaContent = (writerName lift owner)( - (w,o) => s.user.id.is+":"+w+{if(o)":owner" else ""}) + (w, o) => s.user.id.is + ":" + w + { + if (o) ":owner" else "" + }) val verification = metaContent.lift(metaContent => s"""<meta name="$metaName" content="$metaContent"/>""") @@ -48,17 +53,18 @@ class Verification extends VerificationDesigns { def process(): JsCmd = { // check if wordpress auth is set - if(WordpressInfo.is.validateToken) { - if(WordpressInfo.is.isBlogUrl(s.urlHtml)) { - if(s.blog.writersNames.exists(_ == WordpressInfo.is.username)) { + if (WordpressInfo.is.validateToken) { + if (WordpressInfo.is.isBlogUrl(s.urlHtml)) { + if (s.blog.writersNames.exists(_ == WordpressInfo.is.username)) { writerName.set(WordpressInfo.is.username) s.verified = true } } } else - s.verified = MetaVerification(metaName, metaContent.get, s.urlHtml).verified + s.verified = + MetaVerification(metaName, metaContent.get, s.urlHtml).verified - if(s.verified) verifiedProcess() + if (s.verified) verifiedProcess() else S.error("Verification is unsuccessful.") } @@ -66,11 +72,11 @@ class Verification extends VerificationDesigns { // save blog stuff val bw = BlogWriter.createRecord.name(writerName.is).user(s.user.id.is) - if(owner) s.blog.owner(s.user.id.is) + if (owner) s.blog.owner(s.user.id.is) s.blog.addWriterSafely(bw) - if(s.blog.blogname.is.isEmpty) - s.blog.blogname(StringHelpers.randomString(15)) + if (s.blog.blogname.is.isEmpty) + s.blog.blogname(StringHelpers.randomString(15).toLowerCase) s.blog.save @@ -83,26 +89,29 @@ class Verification extends VerificationDesigns { def verifyBlog(outer: IdMemoizeTransform) = { // if idmemoize updated, get urlhtml and find feed - if(s.urlHtml.nonEmpty) { + if (s.urlHtml.nonEmpty) { val existing = Blog.findByUrl(s.urlHtml) - if(existing.isEmpty) { - val res = s.urlHtml.response - val links = res.content.rssLinks - val feed: Feed = links.feed - val writers = feed.writers.map(BlogWriter.createRecord.name(_)) + val res = s.urlHtml.response + val links = res.content.rssLinks + val feed: Feed = links.feed + val writers = feed.writers.map { + w => BlogWriter.createRecord.name(w.name).img(w.imgUrl) + } + if (existing.isEmpty) { s.blog = Blog.createRecord .urlHtml(res.loc) .name(feed.name) .description(feed.description) .writers(writers) .urlRss(feed.links) - } else + } else { existing.map(s.blog = _) - + writers.map(writer => s.blog.addWriterSafely(writer)) + } s.searched = true - s.urlHtml = s.blog.urlHtml.is // nicely formatted + s.urlHtml = s.blog.urlHtml.get // nicely formatted WordpressInfo.is.blogUrl = s.urlHtml CurrentVerification(s) } else @@ -119,30 +128,32 @@ class Verification extends VerificationDesigns { "#blogger-link [href]" #> Site.bloggerHelp.url & "#tumblr-link [href]" #> Site.tumblrHelp.url & "#blog-owner-q [class]" #> hideOwnerQuestion & - "#verification-info-input" #> WiringUI.asText(verification) & + "#verification-info-input" #> WiringUI.asText(verification) & "#verify-blog" #> ajaxSubmit("Verify", process) } def writersRadios = ajaxRadio[String]( s.blog.unregisteredWritersNames, - Full(s.blog.unregisteredWritersNames headOr ""), - { s => onWritersRadiosChange(s) & onWritersRadiosChangeSignUp(s) } - ).unregisteredWritersChoicesToPictureForm + Full(s.blog.unregisteredWritersNames headOr ""), { + s => onWritersRadiosChange(s) & onWritersRadiosChangeSignUp(s) + } + ).unregisteredWritersChoicesToPictureForm(s.blog) val radios = ajaxRadio[Boolean]( Seq(true, false), - Full(false), - { bool => - owner.set(bool) - Noop + Full(false), { + bool => + owner.set(bool) + Noop } ) def registeredUsers = - s.blog.registeredWriters.registeredWritersChoicesToPictureForm + s.blog.registeredWriters. + registeredWritersChoicesToPictureForm(s.blog) def completedMsg = - if(s.blog.unregisteredWriters.isEmpty && s.searched) + if (s.blog.unregisteredWriters.isEmpty && s.searched) Text("Looks like all writers have joined") else NodeSeq.Empty @@ -160,11 +171,13 @@ class Verification extends VerificationDesigns { // returns the radio group of writers def listWriters(): NodeSeq = - if(s.blog.writersNames.isEmpty && s.searched) + if (s.blog.writersNames.isEmpty && s.searched) Text("Writers not found. Double check your url.") else { writerName.set(s.blog.unregisteredWritersNames headOr "") - s.user.name(writerName.is) + if (!EmailVar.is.isEmpty) { + s.user.name(writerName.is) + } writersRadios ++ registeredUsers ++ completedMsg } @@ -173,75 +186,98 @@ class Verification extends VerificationDesigns { // We differentiate by checking if EmailVar is set (which only happens during signup.) // including setUser function - def onVerifiedSignUp(): JsCmd = if(!EmailVar.is.isEmpty) { - BetaUser.find(EmailVar.is).map(_.delete_!) - - s.user - .addBlog(UserBlog.createRecord.blog(s.blog.id.is)) - .email(EmailVar.is) - .password(Helpers.randomString(20)) - .username(StringHelpers.randomString(15)) - s.user.password.hashIt - s.user.save - User.logUserIn(s.user, isAuthed = true) - - RedirectTo(Site.signUp3.url,() => { - BlogIdVar(s.blog.id.is.toString) - VerifiedVar(s.verified) - }) - } else Noop - - def onWritersRadiosChangeSignUp(str: String): JsCmd = if(!EmailVar.is.isEmpty) { - writerName.set(str) - s.user.name(str) - Noop - } else Noop + def onVerifiedSignUp(): JsCmd = + if (!EmailVar.is.isEmpty) { + BetaUser.find(EmailVar.is).map(_.delete_!) + + s.user + .addBlog(s.blog) + .email(EmailVar.is) + .password(Helpers.randomString(20)) + .username(StringHelpers.randomString(15).toLowerCase) + s.user.password.hashIt + s.user.save + User.logUserIn(s.user, isAuthed = true) + + RedirectTo(Site.signUp3.url, () => { + BlogIdVar(s.blog.id.is.toString) + VerifiedVar(s.verified) + }) + } else Noop + + def onWritersRadiosChangeSignUp(str: String): JsCmd = + if (!EmailVar.is.isEmpty) { + writerName.set(str) + s.user.name(str) + Noop + } else Noop - protected def onVerified(): JsCmd = if(EmailVar.is.isEmpty) { - s.user.addBlog(UserBlog.createRecord.blog(s.blog.id.is)).update + protected def onVerified(): JsCmd = + if (EmailVar.is.isEmpty) { + s.user.addBlog(s.blog).update - if(owner) S.redirectTo(Site.editBlogs.url) - else S.notice("Congratulations! You have verified your blog!") - } else Noop + if (owner) S.redirectTo(Site.editBlogs.url) + else S.notice("Congratulations! You have verified your blog!") + } else Noop - protected def onWritersRadiosChange(str: String): JsCmd = if(EmailVar.is.isEmpty) { - writerName.set(str) - Noop - } else Noop + protected def onWritersRadiosChange(str: String): JsCmd = + if (EmailVar.is.isEmpty) { + writerName.set(str) + Noop + } else Noop } object Verification { def setUser(): User = { - if(EmailVar.is.isEmpty) User.currentUser.openOr(User.createRecord) + if (EmailVar.is.isEmpty) User.currentUser.openOr(User.createRecord) else User.createRecord } } sealed trait VerificationDesigns { + implicit class InputRadioDesignImplicit(choices: ChoiceHolder[String]) { - def unregisteredWritersChoicesToPictureForm : NodeSeq = { + def unregisteredWritersChoicesToPictureForm(blog: Blog): NodeSeq = { - for(i <- 0 until choices.items.size) yield { - val choice = choices.items(i) - val user = User.createRecord + val blogWriters = blog.unregisteredWriters - <label>{choice.xhtml}<img src={image100Url(user)} class="writer"/><div>{choice.key.toString}</div></label> - } + if (blogWriters.size != choices.items.size) NodeSeq.Empty + else + for (i <- 0 until choices.items.size) yield { + val item = choices.items(i) + val bwu = BlogWriterUser(None, Some(blog), Some(blogWriters(i))) + + <label> + {item.xhtml}<div> + {item.key.toString} + </div> + <img src={bwu.image} class="writer"/> + </label> + } } } implicit class InputRadioDesign2Implicit(writers: List[BlogWriter]) { - def registeredWritersChoicesToPictureForm : NodeSeq = { + def registeredWritersChoicesToPictureForm(blog: Blog): NodeSeq = { - for(i <- 0 until writers.size) yield { - val writer = writers(i) - val user = User.findByStringId(writer.user.is.toString).openOr(User.createRecord) + for (i <- 0 until writers.size) yield { + val blogWriter = writers(i) + val user = User.findByStringId(blogWriter.user.is.toString) - <label><a href={Site.userProfileLoc.calcHref(user)} target="_blank"> - <img src={image100Url(user)} class="writer"/></a><div>{writer.name.is}</div></label> + val bwu = BlogWriterUser(user, Some(blog), Some(blogWriter)) + + <label> + <div> + {bwu.name} + </div> + <a href={bwu.link} target="_blank"> + <img src={bwu.image} class="writer"/> + </a> + </label> } } } + } diff --git a/src/main/webapp/blogwriter.html b/src/main/webapp/blogwriter.html @@ -6,18 +6,19 @@ <div id="profile-wrap"> <div id="bg"> - <img data-lift="ProfileLocBlogWriter.bgImg"> + <img data-lift="ProfileLocBlogWriter.bgImg" /> </div> <div class="boxed-articles"> <div id="profile"> - <div id="profile-inner"> - <div class="row-fluid"> - <div id="profile-img" class="span12 text-center"> - <img data-lift="ProfileLocBlogWriter.img"> - </div> + <div class="row-fluid"> + <div id="profile-img" class="span12 text-center"> + <img data-lift="ProfileLocBlogWriter.img"> </div> + </div> + + <div id="profile-inner"> <div class="row-fluid"> <div id="user-name" class="span12 text-center up-separate"> @@ -26,14 +27,19 @@ </div> <div class="row-fluid"> - <div id="blog-url" class="span12 text-center"> - <span data-lift="ProfileLocBlogWriter.url"></span> + <div data-lift="ProfileLocBlogWriter.url" id="blog-url" class="span12 text-center nolink-decoration"> + <a href=""></a> </div> </div> - <div class="row-fluid"> + <div class="row-fluid" data-lift="TestCond.loggedin"> <div class="span12 text-center up-separate"> - <span data-lift="ProfileLocBlogWriter.followButton"></span> + <div id="follow-btn-wrapper"> + <span data-lift="ProfileLocBlogWriter.followButton"></span> + </div> + <div id="following-amount"> + <span data-lift="ProfileLocBlogWriter.followersAmount"></span> + </div> </div> </div> @@ -41,10 +47,54 @@ </div> </div> - <div id="profile-articles-area" class="row-fluid"> - <div class="span12"> - <div class="boxed-articles"> - <span data-lift="ProfileLocBlogWriter.articles"></span> + <div id="profile-articles-area"> + <div class="container"> + <div data-lift="ProfileLocBlogWriter.articles" class="row-fluid"> + + <div id="reader-nav-wrapper" class="span2 offset0"> + <div class="reader-nav-blog reader-nav reader-span-spacing nolink-decoration"> + <ul id="reader-writers" class="nav text-center"> + <li id="writer"> + <a href=""> + <img id="writer-image" src="" /> + </a> + <a id="writer-name" href=""></a> + </li> + </ul> + </div> + </div> + + <div id="boxed-articles-wrapper" class="span8 offset0"> + <div class="boxed-articles boxed-articles-span"> + <div class="article keynav-article"> + <div class="article-inner"> + <div class="article-key"></div> + <div class="row-fluid header text-center"> + <div class="span12"> + <a class="article-user-link" href=""> + <img id="article-user-image" src=""/> + </a> + <div class="writer-name nolink-decoration"> + <a id="article-user-name" class="article-user-link" href=""></a> + </div> + </div> + </div> + <div class="title"> + <a href="" target="_blank"></a> + </div> + <div class="row-fluid sub-header"> + <div class="span12"> + <time class="timeago" datetime=""></time> + <a class="article-share" href="">Share</a> + <a class="article-save" href="">Save</a> + </div> + </div> + <span id="article-content"></span> + </div> + </div> + </div> + </div> + </div> </div> </div> diff --git a/src/main/webapp/error.html b/src/main/webapp/error.html @@ -1,4 +1,5 @@ <div data-lift="surround?with=default;at=content"> <p style="font-size: 1.2em;">We're sorry but an error has occurred. Our staff has been notified and is working on a solution.</p> + <div style="margin: 200px 0"></div> </div> diff --git a/src/main/webapp/following.html b/src/main/webapp/following.html @@ -1,10 +1,30 @@ <div data-lift="surround?with=default;at=content"> - <span data-lift="UserFollowing.title"></span> + <form> <input id="following-search" placeholder="Search writer's name or category" type="text" class="span12"> </form> <div id="writers-container"> - <span data-lift="UserFollowing.following"></span> + + <div data-lift="UserFollowing.following" class="row-fluid writer up-separate"> + <div class="span4"> + <a class="writer-link" href=""> + <img src=""/> + </a> + </div> + <div class="span8"> + <div class="name nolink-decoration"> + <a class="writer-link" href=""></a> + </div> + <div class="categories"></div> + <div class="url nolink-decoration"> + <a href=""></a> + </div> + </div> + </div> + </div> + + <div data-lift="UserFollowing.redirect"></div> + </div> \ No newline at end of file diff --git a/src/main/webapp/img/colorwheel.png b/src/main/webapp/img/colorwheel.png Binary files differ. diff --git a/src/main/webapp/img/features/categories.jpg b/src/main/webapp/img/features/categories.jpg Binary files differ. diff --git a/src/main/webapp/img/features/profile-phone.jpg b/src/main/webapp/img/features/profile-phone.jpg Binary files differ. diff --git a/src/main/webapp/img/features/reader-ipad.jpg b/src/main/webapp/img/features/reader-ipad.jpg Binary files differ. diff --git a/src/main/webapp/img/features/share-laptop1.jpg b/src/main/webapp/img/features/share-laptop1.jpg Binary files differ. diff --git a/src/main/webapp/img/features/video1.jpg b/src/main/webapp/img/features/video1.jpg Binary files differ. diff --git a/src/main/webapp/img/features/writers-8.jpg b/src/main/webapp/img/features/writers-8.jpg Binary files differ. diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html @@ -4,106 +4,159 @@ <title data-lift="Menu.title">Read Means: %*%</title> </lift:head> + <div id="top-index-bg"></div> + <div class="container"> - <div class="marketing"> - Discover and follow blog writers! - <div class="small">with the friendliest rss reader online</div> - <div class="logo"> - <span class="readBlue">Read</span> - <span class="readOrange"> means</span> - </div> - <ul class="inline small" > - <li><a href="/purpose">Purpose</a></li> - <li><a href="#features">Features</a></li> - <li><a href="#myblog">Our Blog</a></li> - <li><a href="#writerswelove">Writers we Love</a></li> - </ul> - </div> + <div class="row-fluid features"> - </div> + <div class="span8 offset0"> - <div id="writerswelove"></div> - <div class="container"> - <div class="articles"> - <div class="article text-center"> - <h2>Writers we Love</h2> - <p>In Read Means, writers are celebrities because we believe writers create content, not content creates writers. Check out our featured writers that you can follow with - <span class="readBlue">Read</span><span class="readOrange"> means</span> - </p> - </div> - </div> - </div> - <span data-lift="embed?what=/templates-hidden/parts/writers-gallery"></span> + <div class="row-fluid"> + <div class="span12 offset0 marketing content" style="background-color:#fff"> + Improve your reading experience + <div class="small" style="font-weight: normal; margin-top: 10px">Discover and follow blog writers with the friendliest rss reader!</div> + <div class="logo" style="margin-top:20px"> + <span class="readBlue">Read</span> + <span class="readOrange"> means</span> + </div> + </div> + </div> + + <div class="row-fluid"> + + <div class="span6 offset0"> + + <div class="row-fluid"> + <div class="span12 offset0 content" style="background-color:#1E7697; color: #fff"> + <div class="text">Discover blog writers by your favorite categories.</div> + <img src="/img/features/categories.jpg" /> + </div> + </div> + + <div class="row-fluid"> + <div class="span12 offset0 content" style="background-color:#FDA348; color: #fff"> + <div class="text">Save and share articles with your Facebook, Twitter and Read Means friends</div> + <img src="/img/features/share-laptop1.jpg" /> + </div> + </div> + + </div> + + <div class="span6 offset0"> + + <div class="row-fluid"> + <div class="span12 offset0 content" style="background-color:#84D5F4; color: #fff; height: 700px"> + <div class="text">Follow blog writers and view all their articles plus the writers they follow.</div> + <img src="/img/features/profile-phone.jpg" /> + </div> + </div> + + </div> + + </div> + + <div class="row-fluid"> + <div class="span12 offset0 content" style="background-color:#f1eee0; color: #666"> + <div class="row-fluid"> + <div class="span4"> + <div class="text">Discover writers that fit you most. Watch a quick 30 second intro video about the writer's purpose.</div> + </div> + <div class="span8"> + <img src="/img/features/video1.jpg" /> + </div> + </div> + + </div> + </div> + + </div> + + + <div class="span4 offset0"> + + <div class="row-fluid"> + <div class="span12 offset0 content" style="background-color:#FB840B; color:#fff; height: 700px"> + <div class="text">Beautiful. Casual. Simple. Clean. Fun.</div> + <img src="/img/features/reader-ipad.jpg" /> + </div> + </div> + + <div class="row-fluid"> + <div class="span12 offset0 content" style="background-color:#00c7e1; color: #fff; height: 700px"> + <div class="text">Explore a category and preview 8 chosen writers</div> + <img src="/img/features/writers-8.jpg" /> + </div> + </div> + + </div> - <div class="container"> - <style type="text/css"> - .features { margin-top: 120px } - .features [class*="span"] { - height: 310px; - padding: 20px; - font-weight: bold; - margin-bottom: 20px; - border-radius: 3px; - } - .features h2 { - padding: 20px; - padding-left: 80px; - color: #fff; - margin-bottom: 50px; - border-radius: 3px; - } - </style> - <div id="features"></div> - <div class="features"> - <h2 style="background-color: #1E7697">Features</h2> - <div class="row-fluid"> - <div class="span4" style="background-color:#1E7697; color: #fff"> - Follow blog writers, not blogs. - </div> - <div class="span4" style="background-color:#A6D2C1; color: #fff"> - View who follows who to find more writers. - </div> - <div class="span4" style="background-color:#FFF"> - Discover new writers by any category. - </div> - </div> - <div class="row-fluid"> - <div class="span4" style="background-color:#84D5F4; color: #fff"> - Save and share articles with those following you in Read Means and in other social networks. - </div> - <div class="span8" style="background-color:#5BC1E9; color: #fff"> - To easily discover writers that fit you the most, watch a quick 30 second intro video about the writer and his or her purpose. - </div> - </div> </div> - - <div class="features"> - <h2 style="background-color: #FB840B">Features for Blog Writers</h2> - <div class="row-fluid"> - <div class="span4" style="background-color:#FB840B; color: #fff"> - Follow blog writers, not blogs. - </div> - <div class="span4" style="background-color:#FBB30B; color: #fff"> - View who follows who to find more writers. + <div data-lift="MyBlog.entry" class="row-fluid" style="margin:100px 0"> + <h1 style="margin:50px 0">Latest Blog Post</h1> + <div id="reader-nav-wrapper" class="span2 offset0"> + <div class="reader-nav-blog reader-nav reader-span-spacing nolink-decoration"> + <ul id="reader-writers" class="nav text-center"> + <li id="writer"> + <a href=""> + <img id="writer-image" src="" /> + </a> + <a id="writer-name" href=""></a> + </li> + </ul> + + </div> </div> - <div class="span4" style="background-color:#FDA348;color:#fff"> - Discover new writers by any category. + + <div id="boxed-articles-wrapper" class="span8 offset0"> + <div class="boxed-articles boxed-articles-span"> + <div class="article keynav-article"> + <div class="article-inner"> + <div class="article-key"></div> + <div class="row-fluid header text-center"> + <div class="span12"> + <a class="article-user-link" href=""> + <img id="article-user-image" src=""/> + </a> + <div class="writer-name nolink-decoration"> + <a id="article-user-name" class="article-user-link" href=""></a> + </div> + </div> + </div> + <div class="title"> + <a href="" target="_blank"></a> + </div> + <div class="row-fluid sub-header"> + <div class="span12"> + <time class="timeago" datetime=""></time> + </div> + </div> + <span id="article-content"></span> + </div> + </div> + </div> </div> </div> - <div class="row-fluid"> - <div class="span4" style="background-color:#0C7C9C; color: #fff"> - Save and share articles with those connected to you. - </div> - <div class="span8" style="background-color:#5BC1E9; color: #fff"> - Enjoy quick 30 second "writer's purpose" videos and read recent - articles to easily search the right writer for you. + + </div> + + <div data-lift="ignore"> + <div id="writerswelove"></div> + <div class="container"> + <div class="articles"> + <div class="article text-center"> + <h2>Writers we Love</h2> + <p>In Read Means, writers are celebrities because we believe writers create content, not content creates writers. Check out our featured writers that you can follow with + <span class="readBlue">Read</span><span class="readOrange"> means</span> + </p> + </div> </div> </div> - </div> - - + <span data-lift="embed?what=/templates-hidden/parts/writers-gallery"></span> </div> + + + <span data-lift="embed?what=/templates-hidden/parts/footer"></span> </div> diff --git a/src/main/webapp/preview.html b/src/main/webapp/preview.html @@ -12,22 +12,22 @@ <div id="profile-inner"> <div class="row-fluid"> - <div id="user-name" class="span12 text-center up-separate"> + <div id="user-name" class="span12 text-center article up-separate" style="background: transparent; padding-top:0; padding-bottom:0; margin-bottom:0"> <span data-lift="PreviewLocReader.name"></span> </div> </div> <div class="row-fluid"> - <div id="user-about" class="span12 text-center up-separate article" style="background: transparent; padding-top:0; padding-bottom:0;"> + <div id="user-about" class="span12 text-center article up-separate" style="background: transparent; padding-top:0; padding-bottom:0; margin-bottom:0"> <span data-lift="PreviewLocWriter.about"></span> </div> </div> <div class="row-fluid"> <div class="span12 text-center"> - <span data-lift="PreviewLocWriter.followButton"></span> + <span data-lift="PreviewLocReader.followButton"></span> <div id="following-amount"> - <span data-lift="PreviewLocWriter.followersAmount"></span> + <span data-lift="PreviewLocReader.followersAmount"></span> </div> </div> </div> @@ -37,4 +37,6 @@ </div> </div> + <div data-lift="PreviewLocWriter.redirect"></div> + </div> \ No newline at end of file diff --git a/src/main/webapp/reader.html b/src/main/webapp/reader.html @@ -1,2 +1,101 @@ -<div data-lift="surround?with=default;at=content"> +<div data-lift="surround?with=base-wrap;at=content"> + + <div class="container"> + <div data-lift="ReaderSnip.comingSoon"></div> + <div data-lift="ReaderSnip.articles" class="row-fluid"> + + <div id="reader-nav-wrapper" class="span2 offset0"> + <div id="reader-nav" class="reader-nav reader-span-spacing nolink-decoration"> + <ul id="reader-writers" class="nav text-center"> + <li id="writer"> + <a href=""> + <img id="writer-image" src="" /> + </a> + <a id="writer-name" href=""></a> + </li> + </ul> + + <div id="reader-categories"> + + <div class="scrollbar" style="float:left; display:none;"><div class="track"><div class="thumb"> + <div class="end"></div></div></div></div> + <div class="viewport" style="height:200px"> + <div class="overview" style="width:100%"> + <ul class="nav"> + + <li><a href="#">Sports</a></li> + <li><a href="#">Fashion</a></li> + <li><a href="#">Programming</a></li> + <li><a href="#">Technology</a></li> + <li><a href="#">Cooking</a></li> + <li><a href="#">Traveling</a></li> + <li><a href="#">Education</a></li> + + </ul> + </div> + </div> + + + </div> + + </div> + </div> + + <div id="boxed-articles-wrapper" class="span8 offset0"> + <div class="boxed-articles boxed-articles-span"> + <div class="article keynav-article"> + <div class="article-inner"> + <div class="article-key"></div> + <div class="row-fluid header text-center"> + <div class="span12"> + <a class="article-user-link" href=""> + <img id="article-user-image" src=""/> + </a> + <div class="writer-name nolink-decoration"> + <a id="article-user-name" class="article-user-link" href=""></a> + </div> + </div> + </div> + <div class="title"> + <a href="" target="_blank"></a> + </div> + <div class="row-fluid sub-header"> + <div class="span12"> + <time class="timeago" datetime=""></time> + <a class="article-share" href="">Share</a> + <a class="article-save" href="">Save</a> + </div> + </div> + <span id="article-content"></span> + </div> + </div> + </div> + </div> + + <div data-lift="ignore" class="span2 offset0"> + <div id="reader-ad-nav" class="reader-span-spacing text-center"> + <h5>Functional Programming</h5> + <ul class="nav nolink-decoration"> + <li> + <img src="https://secure.gravatar.com/avatar/7d6356b8b56a9a71583787904a970daa?s=80&d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png" /> + <a href="/julio">Julio Enrique Cabrera</a> + </li> + <li> + <img src="https://secure.gravatar.com/avatar/51e9266cad7460b23daac1179a72da2b?s=80&d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png" /> + <a href="/">Nathan Hamblen</a> + </li> + <li> + <img src="https://secure.gravatar.com/avatar/375ca615cb17ebcb05c417f3433fea4a?s=80&d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png" /> + <a href="/">Tim Nelson</a> + </li> + </ul> + </div> + </div> + + + </div> + </div> + + + </div> diff --git a/src/main/webapp/search/categories.html b/src/main/webapp/search/categories.html @@ -0,0 +1,55 @@ +<div data-lift="surround?with=base-wrap;at=content" xmlns:lift="http://www.w3.org/1999/xhtml"> + + <lift:head> + <title data-lift="Menu.title">Read Means: %*%</title> + </lift:head> + + <div class="container"> + + <style> + .bg-overlay:hover { + background-color: rgba(0,0,0,0.1); + cursor: pointer; + } + + .bg-overlay { + position: absolute; + z-index: 100; + width: 586px; + height: 300px; + background: -moz-linear-gradient(-45deg, rgba(0,0,0,0.9) 0, rgba(255,255,255,0) 50%); + } + + .category-text { + position:absolute; + cursor: pointer; + z-index:200; + margin: 30px; + font-size: 70px; + font-weight: bold; + text-shadow:4px 4px 1px rgba(0, 0, 0, 0.9); + } + .category-text > a, .category-text > a:hover, .category-text > a:focus { + color:white; + text-decoration: none; + + } + </style> + + <div class="row-fluid" style="margin: 30px 0"> + <div data-lift="ignore" class="span12"> + <form data-lift="form.ajax?class=form-search"> + <div class="input-append"> + <input placeholder="Search Category" class="span8 search-query" type="text" /> + <input class="span3 btn btn-primary" value="Search" /> + </div> + </form> + </div> + </div> + </div> + + <div class="row-fluid nolink-decoration"> + <span data-lift="Search"></span> + </div> + +</div> +\ No newline at end of file diff --git a/src/main/webapp/search/writers.html b/src/main/webapp/search/writers.html @@ -0,0 +1,44 @@ +<div data-lift="surround?with=base-wrap;at=content" xmlns:lift="http://www.w3.org/1999/xhtml"> + + <lift:head> + <title data-lift="Menu.title">Read Means: %*%</title> + </lift:head> + + <style> + img.writer { + width: 200px; + height: 200px; + margin: 20px 0; + } + + .writers-search-container label { + font-size: 150%; + } + .writers-search-container .row-fluid { + margin-bottom: 40px; + } + .writers-search-container [class*="span"] { + background-color: white; + border-right: 3px solid rgb(91, 193, 233); + padding: 30px; + text-align: center; + } + .writers-search-container [class*="span"] > * { + margin: 10px 0; + } + .writers-search-container img { + width: 150px; + height: 150px; + } + + </style> + + <div class="container up-separate"> + <a data-lift="SearchWriters.goBack" class="btn btn-primary btn-small">Go back to Categories</a> + </div> + + <div class="container nolink-decoration writers-search-container"> + <span data-lift="SearchWriters"></span> + </div> + +</div> +\ No newline at end of file diff --git a/src/main/webapp/settings/account.html b/src/main/webapp/settings/account.html @@ -3,49 +3,64 @@ <label>Email</label> <span data-lift="CurrentReader.email"></span> - <br><br> - - <label>Username</label> - <span data-lift="CurrentWriter.username"></span> - <span class="help-inline"><small>www.readmeans.com/{username}</small></span> + <div data-lift="CurrentWriter.showIfWriter"> + <br><br> + <label>Username</label> + <span data-lift="CurrentWriter.username"></span> + <span class="help-inline"><small>www.readmeans.com/{username}</small></span> + </div> <br><br> <label>Name</label> <span data-lift="CurrentReader.name"></span> - <br><br> + <div data-lift="CurrentWriter.showIfWriter"> + <br><br> + <label>About</label> + <span data-lift="CurrentWriter.about" class="user-about-settings"></span> + <style>.user-about-settings{width:300px; height:150px;}</style> + </div> - <label>About</label> - <span data-lift="CurrentWriter.about" class="user-about-settings"></span> - <style>.user-about-settings{width:300px; height:150px;}</style> <br><br> <h3>Media</h3> - <div id="intro-vid" class="well well-small"> + <div id="intro-vid" class="well well-small" data-lift="CurrentWriter.showIfWriter"> <p>Enter a Youtube video id to influence people to follow you. Must be 30 seconds or less:</p> - <span> - <span data-lift="CurrentWriter.introVid"></span> - <img class="yt" src="/img/yt95x40.png"> - <span class="help-inline"><small>www.youtube.com/watch?v=<span style="color:red">0Bmhjf0rKe8</span></small></span> - </span> + <span> + <span data-lift="CurrentWriter.introVid"></span> + <img class="yt" src="/img/yt95x40.png"> + <span class="help-inline"><small>www.youtube.com/watch?v=<span style="color:red">0Bmhjf0rKe8</span></small></span> + </span> </div> <label>Upload a new profile image</label> <span data-lift="CurrentReader.uploadImg" id="pic-fileupload-outer"></span> - <br><br> + <div data-lift="CurrentWriter.showIfWriter"> + <br><br> + <label>Upload a new background image</label> + <div data-lift="CurrentWriter.uploadBgImg" id="bg-fileupload-outer"> - <label>Upload a new background image</label> - <span data-lift="CurrentWriter.uploadBgImg" id="bg-fileupload-outer" ></span> + <div id="backgrounds-q"> + <span>Use a default background?</span> + <input id="backgrounds-q-yes" class="radio" style="margin-left: 20px" /> <span>Yes</span> + <input id="backgrounds-q-no" class="radio" style="margin-left: 20px" /> <span>No</span> + </div> - <br><br> + <div id="bg-fileupload" class="hide"></div> + <div id="backgrounds-list"></div> + </div> + </div> - <label>Enter other Youtube video ids that shows your tone of voice and personality: interviews, speeches, etc.</label> - <span data-lift="CurrentWriter.otherVid"></span> - <span class="help-block"><small>Separate by comma: 0Bmhjf0rKe8, 0Bmhjf0rKe8</small></span> + <div data-lift="CurrentWriter.showIfWriter"> + <br><br> + <label>Enter other Youtube video ids that shows your tone of voice and personality: interviews, speeches, etc.</label> + <span data-lift="CurrentWriter.otherVid"></span> + <span class="help-block"><small>Separate by comma</small></span> + </div> <br><br> diff --git a/src/main/webapp/settings/blog.html b/src/main/webapp/settings/blog.html @@ -0,0 +1,61 @@ +<div data-lift="surround?with=settings-wrap;at=content"> + + <span data-lift="BlogSettings.categoriesEdit"></span> + + <div data-lift="BlogSettings.showIfOwner"> + + <hr> + <label>Name</label> + <span data-lift="BlogSettings.name"></span> + + <br><br> + <label>Blogname</label> + <span data-lift="BlogSettings.blogname"></span> + <span class="help-inline"><small>www.readmeans.com/blog/{blogname}</small></span> + + <br><br> + <label>Description</label> + <span data-lift="BlogSettings.description" class="blog-description-settings"></span> + <style>.blog-description-settings{width:300px; height:150px;}</style> + + <br><br> + <label>Upload a new profile image</label> + <span data-lift="BlogSettings.uploadImg" id="pic-fileupload-outer"></span> + + <br><br> + <label>Upload a new background image</label> + <div data-lift="BlogSettings.uploadBgImg" id="bg-fileupload-outer"> + + <div id="backgrounds-q"> + <span>Use a default background?</span> + <input id="backgrounds-q-yes" class="radio" style="margin-left: 20px" /> <span>Yes</span> + <input id="backgrounds-q-no" class="radio" style="margin-left: 20px" /> <span>No</span> + </div> + + <div id="bg-fileupload" class="hide"></div> + <div id="backgrounds-list"></div> + </div> + + <br><br> + + <h3>Writers</h3> + + <div data-lift="BlogSettings.writers" class="writer"> + <div class="blog-writer row-fluid up-separate"> + <div class="span4"> + <a class="writer-link" href=""> + <img src="" width="200" /> + </a> + </div> + <div class="span8"> + <div class="name"> + <h3><span class="text"></span></h3> + </div> + <div class="input-area input-append"></div> + <div class="categories"></div> + </div> + </div> + </div> + + </div> +</div> +\ No newline at end of file diff --git a/src/main/webapp/settings/blogs.html b/src/main/webapp/settings/blogs.html @@ -1,4 +1,27 @@ -<div lift="surround?with=settings-wrap;at=content"> - <div data-lift="BlogsSnip.blogs"></div> - <a data-lift="BlogsSnip.addBlog" class="btn btn-primary">Add Blog</a> +<div data-lift="surround?with=settings-wrap;at=content"> + + <a data-lift="UserBlogsSnip.addBlog" class="btn btn-primary" style="margin-bottom: 50px">Add Blog</a> + + <div class="notice-block" style="margin-bottom: 50px"> + Edit your current blogs and categories for each blog. + </div> + + <div data-lift="UserBlogsSnip.blogs" class="blog row-fluid up-separate"> + <div class="span4"> + <a class="blog-profile-link" href=""> + <img class="blog-image" src=""/> + </a> + </div> + <div class="span8"> + <div class="name nolink-decoration"> + <a class="blog-settings-link" href=""> + <h3> + <span id="blog-name"></span> + </h3> + </a> + </div> + <a class="blog-settings-link btn btn-primary" href="">Edit</a> + </div> + </div> + </div> diff --git a/src/main/webapp/settings/following.html b/src/main/webapp/settings/following.html @@ -5,6 +5,26 @@ </form> <div id="writers-container"> - <span data-lift="MyFollowing.following"></span> + + <div data-lift="MyFollowing.following" class="row-fluid writer up-separate"> + <div class="span4"> + <a class="writer-link" href=""> + <img src=""/> + </a> + </div> + <div class="span6"> + <div class="name nolink-decoration"> + <a class="writer-link" href=""></a> + </div> + <div class="categories"></div> + <div class="url nolink-decoration"> + <a href=""></a> + </div> + </div> + <div class="span2"> + <span id="follow-btn"></span> + </div> + </div> + </div> </div> \ No newline at end of file diff --git a/src/main/webapp/signup/blog.html b/src/main/webapp/signup/blog.html @@ -1,16 +1,26 @@ -<div data-lift="surround?with=default-wide;at=content"> +<div data-lift="surround?with=base-wrap;at=content"> <div data-lift="SignUp.setupBlog"> - <div class="row-fluid" style="background-color: #ddd"> - <div class="span12"> + <div class="navbar navbar-fixed-top" style="margin:0"> + <div class="navbar-inner"> <div class="boxed-articles"> <form data-lift="form.ajax?class=navbar-form pull-left"> - www.joereader.com/blog/ <input data-lift="ProfileLocBlogReq.blogname" id="blog-site-url" placeholder="Blog Url"><br> + www.readmeans.com/blog/ <input data-lift="ProfileLocBlogReq.blogname" id="blog-site-url" placeholder="Blog Url"><br> </form> + <form data-lift="form.ajax?class=navbar-form pull-right"> <input id="blog-continue" class="btn btn-success"> </form> + + <form class="navbar-form pull-right" style="margin-right: 10px"> + <input value="Upload Media" + href="#settingsModal" + role="button" + class="btn btn-primary" + data-toggle="modal" + type="submit" /> + </form> </div> </div> </div> @@ -21,4 +31,45 @@ </div> </div> </div> + + <div data-lift="tail"> + <div id="settingsModal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="settingsModalLabel" aria-hidden="true"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> + <h3 id="settingsModalLabel">Media Settings</h3> + <div style="padding-top: 20px"> + <div style="display:none;" id="ajax-spinner" class="text-center"> + <img src="/img/spinner.gif" width="32" height="16"/> + </div> + <div data-lift="Notices"></div> + </div> + </div> + + <div class="modal-body"> + + <p>Upload a new background image:</p> + + <div data-lift="ProfileLocBlogReq.uploadBgImg" id="bg-fileupload-outer"> + + <div id="backgrounds-q"> + <span>Use a default background?</span> + <input id="backgrounds-q-yes" class="radio" style="margin-left: 20px" /> <span>Yes</span> + <input id="backgrounds-q-no" class="radio" style="margin-left: 20px" /> <span>No</span> + </div> + + <div id="bg-fileupload" class="hide"></div> + <div id="backgrounds-list"></div> + </div> + + <hr> + <p>Upload a new profile image:</p> + <span data-lift="ProfileLocBlogReq.uploadImg" id="pic-fileupload-outer" ></span> + + </div> + + <div class="modal-footer"> + <button class="btn" data-dismiss="modal" aria-hidden="true">Close</button> + </div> + </div> + </div> </div> \ No newline at end of file diff --git a/src/main/webapp/signup/password.html b/src/main/webapp/signup/password.html @@ -6,6 +6,7 @@ <div class="control-group"> <div class="controls"> + <h2>Last but not least....</h2> <p><strong>the password</strong></p> <input data-lift="CurrentReader.password" id="user-password" placeholder="password"> <span data-alertid="id_password_err" class="notice-block"></span> @@ -16,5 +17,4 @@ </form> </div> - <div style="margin: 340px 0"></div> </div> \ No newline at end of file diff --git a/src/main/webapp/signup/user.html b/src/main/webapp/signup/user.html @@ -2,15 +2,25 @@ <div data-lift="SignUp.setupUser"> - <div class="row-fluid" style="background-color: #ddd"> - <div class="span12"> + <div class="navbar navbar-fixed-top" style="margin:0"> + <div class="navbar-inner"> <div class="boxed-articles"> <form data-lift="form.ajax?class=navbar-form pull-left"> - www.joereader.com/ <input data-lift="CurrentWriter.username" id="user-site-url" placeholder="Your Url"><br> + www.readmeans.com/ <input data-lift="CurrentWriter.username" id="user-site-url" placeholder="Your Url"><br> </form> + <form data-lift="form.ajax?class=navbar-form pull-right"> <input id="user-continue" class="btn btn-success"> </form> + + <form class="navbar-form pull-right" style="margin-right: 10px"> + <input value="Upload Media" + href="#settingsModal" + role="button" + class="btn btn-primary" + data-toggle="modal" + type="submit" /> + </form> </div> </div> </div> diff --git a/src/main/webapp/signup/verify.html b/src/main/webapp/signup/verify.html @@ -2,5 +2,4 @@ <div data-lift="SignUp.setupVerification"></div> <span data-lift="embed?what=/templates-hidden/parts/verification"></span> - <div style="margin: 340px 0"></div> </div> \ No newline at end of file diff --git a/src/main/webapp/sitemap.html b/src/main/webapp/sitemap.html @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8" ?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url data-lift="SitemapContent.base"> + <loc></loc> + <changefreq>daily</changefreq> + <priority>1.0</priority> + <lastmod></lastmod> + </url> + <url data-lift="SitemapContent.list"> + <loc></loc> + <lastmod></lastmod> + </url> +</urlset> +\ No newline at end of file diff --git a/src/main/webapp/templates-hidden/base-wrap.html b/src/main/webapp/templates-hidden/base-wrap.html @@ -82,10 +82,10 @@ </div> <div data-lift="SnipHelpers.setNoticeContainer" id="notices-container"> + <div data-lift="Notices"></div> <div style="display:none;" id="ajax-spinner" class="text-center"> <img src="/img/spinner.gif" width="32" height="16"/> </div> - <div data-lift="Notices"></div> </div> <div id="content"></div><!-- /container --> diff --git a/src/main/webapp/templates-hidden/default-wide.html b/src/main/webapp/templates-hidden/default-wide.html @@ -10,5 +10,4 @@ </div> <div id="content"></div> </div> - <span data-lift="embed?what=/templates-hidden/parts/footer"></span> </div> \ No newline at end of file diff --git a/src/main/webapp/templates-hidden/default.html b/src/main/webapp/templates-hidden/default.html @@ -18,5 +18,4 @@ </div> </div> - <span data-lift="embed?what=/templates-hidden/parts/footer"></span> </div> diff --git a/src/main/webapp/templates-hidden/parts/blog-profile.html b/src/main/webapp/templates-hidden/parts/blog-profile.html @@ -1,7 +1,7 @@ <div data-lift="ProfileLocBlog.surround" id="profile-wrap"> <div id="bg"> - <img data-lift="ProfileLocBlog.bgImg"> + <img data-lift="ProfileLocBlog.bgImg" src=""> </div> <div class="boxed-articles"> @@ -13,7 +13,32 @@ <div class="end"></div></div></div></div> <div class="viewport"> <div class="overview"> - <span data-lift="ProfileLocBlog.writers"></span> + + <div data-lift="ProfileLocBlog.writers"> + <div class="writer"> + <div class="blog-writer"> + <a class="writer-link" href=""> + <img src=""/> + </a> + <div class="name"> + <span class="text"></span> + <span data-lift="ProfileLocBlog.showIfOwner" class="edit-link"> + <a>Edit</a> + </span> + </div> + </div> + <div class="writer-edit-area hide"> + <div class="inline header"> + <div class="name"></div> + <a class="exit-link">Exit</a> + </div> + <div class="input-area input-append"></div> + <div class="help-block msg-area"><small></small></div> + </div> + </div> + <style></style> <!-- don't touch style --> + </div> + </div> </div> </div> @@ -22,25 +47,25 @@ <div id="profile-inner"> <div class="row-fluid"> - <div id="blog-url" class="span12 text-center"> - <span data-lift="ProfileLocBlog.url"></span> + <div data-lift="ProfileLocBlog.url" id="blog-url" class="span12 text-center nolink-decoration"> + <a href=""></a> </div> </div> <div class="row-fluid"> <div id="blog-img" class="span12 text-center"> - <img data-lift="ProfileLocBlog.img"> + <img data-lift="ProfileLocBlog.img" src=""> </div> </div> <div class="row-fluid"> - <div id="blog-name" class="span12 up-separate text-center"> + <div id="blog-name" class="span12 text-center article" style="background: transparent; padding-top:0; padding-bottom:0; margin-bottom:0"> <span data-lift="ProfileLocBlog.name"></span> </div> </div> <div class="row-fluid"> - <div id="blog-description" class="span12 text-center up-separate article"> + <div id="blog-description" class="span12 text-center article short-ver up-separate" style="background: transparent; padding-top:0; padding-bottom:0; margin-bottom:0"> <span data-lift="ProfileLocBlog.description"></span> </div> </div> @@ -49,25 +74,56 @@ </div> </div> - <div id="profile-articles-area" class="row-fluid"> - <div class="span12"> - <div class="boxed-articles"> - <span data-lift="ProfileLocBlog.articles"></span> + <div id="profile-articles-area"> + <div class="container"> + <div data-lift="ProfileLocBlog.articles" class="row-fluid"> + + <div id="reader-nav-wrapper" class="span2 offset0"> + <div class="reader-nav-blog reader-nav reader-span-spacing nolink-decoration"> + <ul id="reader-writers" class="nav text-center"> + <li id="writer"> + <a href=""> + <img id="writer-image" src="" /> + </a> + <a id="writer-name" href=""></a> + </li> + </ul> + </div> + </div> + + <div id="boxed-articles-wrapper" class="span8 offset0"> + <div class="boxed-articles boxed-articles-span"> + <div class="article keynav-article"> + <div class="article-inner"> + <div class="article-key"></div> + <div class="row-fluid header text-center"> + <div class="span12"> + <a class="article-user-link" href=""> + <img id="article-user-image" src=""/> + </a> + <div class="writer-name nolink-decoration"> + <a id="article-user-name" class="article-user-link" href=""></a> + </div> + </div> + </div> + <div class="title"> + <a href="" target="_blank"></a> + </div> + <div class="row-fluid sub-header"> + <div class="span12"> + <time class="timeago" datetime=""></time> + <a class="article-share" href="">Share</a> + <a class="article-save" href="">Save</a> + </div> + </div> + <span id="article-content"></span> + </div> + </div> + </div> + </div> + </div> </div> </div> -</div> - - - - - - - - - - - - <div class="row"><div class="span12"><span data-lift="ProfileLocBlog.uploadBgImg" id="bg-fileupload-outer" ></span></div></div> - <div class="row"><div class="span12"><span data-lift="ProfileLocBlog.uploadImg" id="pic-fileupload-outer" ></span></div></div> +</div> +\ No newline at end of file diff --git a/src/main/webapp/templates-hidden/parts/categories.html b/src/main/webapp/templates-hidden/parts/categories.html @@ -1,6 +1,6 @@ -<div data-lift="CategoriesSnip"> +<div> - <div class="row-fluid"> + <div data-lift="BlogWriterCategoriesSnip" class="row-fluid"> <div class="span8"> <form data-lift="form.ajax?class=input-append"> @@ -8,12 +8,37 @@ <input id="category-add" class="btn btn-primary"> </form> <br> - <div id="chosen-categories" class="well"></div> + <div id="chosen-categories" class="well"> + <div class="inner"></div> + </div> </div> <div class="span4"> <h3>Advice</h3> - People will find you by category. Choose carefully. + People will find you by category. Choose wisely. + </div> + + </div> + + <div data-lift="BlogWriterColorSnip" class="row-fluid"> + <div id="colorpicker-wrapper" class="span12"> + <h3>Primary Color</h3> + <p>Click box to choose a color</p> + <div class="preview"></div> + + <form data-lift="form.ajax?class=input-append"> + <input id="hexVal" placeholder="hex color value" /> + <input id="hexVal-add" class="btn btn-primary"> + </form> + + <span class="help-block"><small>Choose a color that represents your category or blog theme. + While people read your articles, they will see this color. + Color influences the reader's mood so choose wisely!</small></span> + + <div class="colorpicker" style="display:none"> + <canvas id="picker" width="300" height="300"></canvas> + </div> + </div> </div> diff --git a/src/main/webapp/templates-hidden/parts/social.html b/src/main/webapp/templates-hidden/parts/social.html @@ -0,0 +1,47 @@ +<div> + + <div> + + <div id="fb-root"></div> + <script>(function(d, s, id) { + var js, fjs = d.getElementsByTagName(s)[0]; + if (d.getElementById(id)) return; + js = d.createElement(s); js.id = id; + js.src = "//connect.facebook.net/en_US/all.js#xfbml=1"; + fjs.parentNode.insertBefore(js, fjs); + }(document, 'script', 'facebook-jssdk'));</script> + + <div class="fb-follow" data-href="https://www.facebook.com/readmeans" data-width="200" data-show-faces="true"></div> + + </div> + + + <div> + + <a href="https://twitter.com/ReadMeans" class="twitter-follow-button" data-show-count="false">Follow @ReadMeans</a> + <script> + !function(d,s,id){ + var js,fjs=d.getElementsByTagName(s)[0],p=/^http:/.test(d.location)?'http':'https'; + if(!d.getElementById(id)){ + js=d.createElement(s); + js.id=id; + js.src=p+'://platform.twitter.com/widgets.js'; + fjs.parentNode.insertBefore(js,fjs); + }}(document, 'script', 'twitter-wjs');</script> + + </div> + + <div> + <div class="g-follow" data-annotation="bubble" data-height="20" data-href="//plus.google.com/112576307492113436660" data-rel="publisher"></div> + + <script type="text/javascript"> + (function() { + var po = document.createElement('script'); po.type = 'text/javascript'; po.async = true; + po.src = 'https://apis.google.com/js/plusone.js'; + var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(po, s); + })(); + </script> + </div> + + +</div> +\ No newline at end of file diff --git a/src/main/webapp/templates-hidden/parts/user-profile.html b/src/main/webapp/templates-hidden/parts/user-profile.html @@ -6,41 +6,48 @@ <div class="boxed-articles"> <div id="profile"> - <div id="profile-inner"> - <a data-lift="ProfileLocReader.previewPage" class="nolink-decoration"> - <div class="row-fluid"> - <div id="profile-img" class="span12 text-center"> + <div class="row-fluid"> + <div id="profile-img" class="span12 text-center"> + <a data-lift="ProfileLocReader.previewPage" class="nolink-decoration"> <img data-lift="ProfileLocReader.img"> - </div> + </a> </div> + </div> + + <div id="profile-inner"> <div class="row-fluid"> - <div id="user-name" class="span12 text-center up-separate"> - <span data-lift="ProfileLocReader.name"></span> + <div id="user-name" class="span12 text-center article up-separate" style="background: transparent; padding-top:0; padding-bottom:0; margin-bottom:0"> + <a data-lift="ProfileLocReader.previewPage" class="nolink-decoration"> + <span data-lift="ProfileLocReader.name"></span> + </a> </div> </div> - <div class="row-fluid"> - <div id="user-about" class="span12 text-center up-separate article short-ver" style="background: transparent; padding-top:0; padding-bottom:0;"> - <span data-lift="ProfileLocWriter.about"></span> - </div> + <div class="row-fluid" data-lift="ProfileLocWriter.showIfWriter"> + <a data-lift="ProfileLocReader.previewPage" class="nolink-decoration"> + <div id="user-about" class="span12 text-center article short-ver up-separate" style="background: transparent; padding-top:0; padding-bottom:0; margin-bottom:0"> + <span data-lift="ProfileLocWriter.about"></span> + </div> + </a> </div> - </a> - <div class="row-fluid"> - <div class="span12 text-center"> - <span data-lift="ProfileLocWriter.followButton"></span> + <div class="row-fluid" data-lift="ProfileLocWriter.showIfWriterAndLoggedIn"> + <div class="span12 text-center up-separate"> + <div id="follow-btn-wrapper"> + <span data-lift="ProfileLocReader.followButton"></span> + </div> <div id="following-amount"> - <span data-lift="ProfileLocWriter.followersAmount"></span> + <span data-lift="ProfileLocReader.followersAmount"></span> </div> <span data-lift="ProfileLocWriter.uploadButton"></span> </div> </div> - <div class="row-fluid" id="user-follow"> - <div class="span12 text-center"> + <div class="row-fluid" id="user-follow" data-lift="ProfileLocWriter.showIfWriter"> + <div class="span12 text-center up-separate"> <strong><span data-lift="ProfileLocWriter.followingAmount"></span></strong> <div class="inline"> <span data-lift="ProfileLocWriter.followingList"></span> @@ -52,10 +59,55 @@ </div> </div> - <div id="profile-articles-area" class="row-fluid"> - <div class="span12"> - <div class="boxed-articles"> - <span data-lift="ProfileLocWriter.articles"></span> + <div id="profile-articles-area"> + <div class="container"> + <div data-lift="ProfileLocWriter.articles" class="row-fluid"> + + <div id="reader-nav-wrapper" class="span2 offset0"> + <div class="reader-nav-blog reader-nav reader-span-spacing nolink-decoration"> + <ul id="reader-writers" class="nav text-center"> + <li id="writer"> + <a href=""> + <img id="writer-image" src="" /> + </a> + <a id="writer-name" href=""></a> + </li> + </ul> + </div> + </div> + + + <div id="boxed-articles-wrapper" class="span8 offset0"> + <div class="boxed-articles boxed-articles-span"> + <div class="article keynav-article"> + <div class="article-inner"> + <div class="article-key"></div> + <div class="row-fluid header text-center"> + <div class="span12"> + <a class="article-user-link" href=""> + <img id="article-user-image" src=""/> + </a> + <div class="writer-name nolink-decoration"> + <a id="article-user-name" class="article-user-link" href=""></a> + </div> + </div> + </div> + <div class="title"> + <a href="" target="_blank"></a> + </div> + <div class="row-fluid sub-header"> + <div class="span12"> + <time class="timeago" datetime=""></time> + <a class="article-share" href="">Share</a> + <a class="article-save" href="">Save</a> + </div> + </div> + <span id="article-content"></span> + </div> + </div> + </div> + </div> + </div> </div> </div> @@ -66,6 +118,12 @@ <div class="modal-header"> <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> <h3 id="settingsModalLabel">Media Settings</h3> + <div style="padding-top: 20px"> + <div style="display:none;" id="ajax-spinner" class="text-center"> + <img src="/img/spinner.gif" width="32" height="16"/> + </div> + <div data-lift="Notices"></div> + </div> </div> <div class="modal-body"> @@ -79,15 +137,27 @@ </div> <p>Upload a new background image:</p> - <span data-lift="ProfileLocWriter.uploadBgImg" id="bg-fileupload-outer" ></span> + <div data-lift="ProfileLocWriter.uploadBgImg" id="bg-fileupload-outer"> + + <div id="backgrounds-q"> + <span>Use a default background?</span> + <input id="backgrounds-q-yes" class="radio" style="margin-left: 20px" /> <span>Yes</span> + <input id="backgrounds-q-no" class="radio" style="margin-left: 20px" /> <span>No</span> + </div> + + <div id="bg-fileupload" class="hide"></div> + <div id="backgrounds-list"></div> + </div> <hr> <p>Upload a new profile image:</p> <span data-lift="ProfileLocReader.uploadImg" id="pic-fileupload-outer" ></span> + <hr> - <p>Enter other Youtube video ids: interviews, speeches, etc.</p> + + <p>Enter other Youtube video ids that shows your tone of voice and personality: interviews, speeches, etc.</p> <span> <span data-lift="CurrentWriter.otherVid" id="user-other-vid"></span> - <span class="help-inline"><small>Separate by comma: 0Bmhj8, jfKe8</small></span> + <span class="help-inline"><small>Separate by comma</small></span> </span> </div> diff --git a/src/main/webapp/templates-hidden/parts/verification.html b/src/main/webapp/templates-hidden/parts/verification.html @@ -4,8 +4,7 @@ <form data-lift="form.ajax?class=form-search"> Blog Url: <div class="input-append"> - <input id="blog-url" placeholder="ie. www.example.com/blog.html" class="span10 search-query"> - <span data-alertid="blog-url_err" class="notice-block"></span> + <input id="blog-url" placeholder="www.example.com/blog" class="span8 search-query"> <input id="search-blog" class="btn btn-primary"> </div> </form> @@ -22,7 +21,8 @@ <form data-lift="form.ajax"> <div class="up-separate"></div> <div class="well well-small" id="verification-info-input" style="margin:0"></div> - <span class="help-block">Copy and paste this meta tag to the &lt;head&gt; of your index.html file or equivalent.</span> + <span class="help-block">Copy and paste this meta tag to the &lt;head&gt; of your index.html + file or equivalent. For help, look at the Help section.</span> <div class="up-separate"></div> <input id="verify-blog" class="btn btn-primary btn-large"> </form> @@ -30,13 +30,14 @@ </div> <div class="span4"> + <span class="notice-block">Search your blog's web page, not rss feed. RSS auto-discovery should be enabled.</span> <div id="verification-help"> <h3>Help</h3> <p>Select yourself as a writer for your blog. If there's more than one writer, select if you're the owner to edit your blog profile page.</p> <hr> <div> - <p>To verify Wordpress.com blogs, you must login with O-Authentication. It's safe and secure. Just click on the Wordpress logo.</p> + <p>To verify Wordpress.com blogs, you must login with O-Authentication. It's safe and easy to use. Just click on the Wordpress logo.</p> <p> <a id="wordpress-login"><img src="/img/wp.png" width="200" /></a> </p>