-
Notifications
You must be signed in to change notification settings - Fork 1
07. User profiles and publishes
In 1batch, there are two URLs for profiles:
/profile - your own profile / editing space
/profile/mapmeld - the profile for another user ("mapmeld"). If you go to your own profile with this URL, you should be redirected back to /profile
These are two separate router.get() calls in app.js. The simpler one is another user's profile, because we only request public information, and we don't require you to be logged in at all:
// someone else's profile
router.get('/profile/:username', async function (ctx) {
var requser = ctx.req.user;
if (requser && ctx.params.username.toLowerCase() === requser.name) {
// redirect to your own profile
return ctx.redirect('/profile');
}
if (ctx.params.username.indexOf('@') > -1) {
// usernames which are still emails are anonymous
return printNoExist(ctx);
}
// we're looking for the one user with this name (there should only be one)
// the only public information that's interesting to us is their _id, username, and whether they publicly posted yet
// your app may need more fields to be public
var user = await User.findOne({ name: req.params.username.toLowerCase() }, '_id username posted').exec();
if (!user) { throw 'user does not exist'; }
// request all images which are published by this user, get source urls
var images = await Image.find({ published: true, hidden: false, user_id: user.name }).select('_id src').exec();
// there's a function called responsiveImg which returns proper URLs from Cloudinary
// we'll talk about it later
images = images.map(responsiveImg);
ctx.render('profile', {
user: user, // the user who created this profile
images: images, // the user's images
posted: user.posted, // the user's post date (if they posted)
forUser: (requser || null), // whether the browsing user is signed in or not
csrfToken: ctx.csrf
});
});responsiveImg is going to be used several times. It takes in an array of Image objects, and asks Cloudinary to return formatted URLs for that image. It also requests the image in multiple sizes, which we can use to show people this page on desktop, mobile, or retina screens. responsiveImg looks like this:
function responsiveImg(img, makeBig) {
var baseSize = 300;
if (makeBig) {
baseSize *= 2;
}
var geturl = cloudinary.url;
var out = {
_id: img._id,
src: {
mini: geturl(img.src, { format: "jpg", width: baseSize * 2/3, height: baseSize * 2/3, crop: "fill" }).replace('http:', ''),
main: geturl(img.src, { format: "jpg", width: baseSize, height: baseSize, crop: "fill" }).replace('http:', ''),
retina: geturl(img.src, { format: "jpg", width: baseSize * 2, height: baseSize * 2, crop: "fill" }).replace('http:', '')
}
};
return out;
}Another detail is we remove 'http:' from the Cloudinary URL. Browsers can throw errors when an HTTPS page has an HTTP resource, and vice versa. Using // means to keep the same protocol as the current page.
Now we can design the public profile page itself, using a Pug template:
extends ./layout.pug
block content
.row
.col-sm-12.underbar
if forUser
a.btn.btn-info.pull-right(href="/logout") Log Out
else
a.btn.btn-primary.pull-right(href="/profile") Log In
h2
a(href="/feed") 1batch
small an app for sharing 8 photos
.row
.col-sm-12.col-md-6
h1= user.name
if posted
h4 posted #{posted}
else
h4 hasn't posted yet!
if posted
.row.profilephotos
for index in [0, 1, 2, 3]
if index < images.length
.col-sm-6.col-lg-3
img(src=images[index].src.main, srcset=images[index].src.main + " 1x, " + images[index].src.retina + " 2x")
.row.profilephotos
for index in [4, 5, 6, 7]
if index < images.length
.col-sm-6.col-lg-3
img(src=images[index].src.main, srcset=images[index].src.main + " 1x, " + images[index].src.retina + " 2x")We're using Bootstrap CSS, so the page is divided up into several divs using the .row class.
The first row has a Log In or Log Out button, depending on the current status of the user.
That's followed by the username and the phrase "posted {time}" or "hasn't posted yet!"
The image rows are more complex. Bootstrap divides the page up into a grid with 12 units. The class col-sm-6 means that at a small page size, the photos should take up six units (half the page), and col-lg-3 means that a larger page size, the photos should take up three (a quarter of the page). At the smallest size, Bootstrap automatically reduces the page to a single column.
If you're familiar with HTML, you'll recognize that we're setting the image's src attribute. We also use the newer srcset to mention the retina-resolution image, and let the device decide which to use. The srcset attribute and techniques for supporting it on older devices is explained here: http://www.sitepoint.com/how-to-build-responsive-images-with-srcset/
Now we just need to create our own profile page, with editing abilities. In 1batch I currently have this as one Pug view, but I'll explain it as a separate view (and possibly implement it this way later on)
extends ./layout.pug
block content
.row
.col-sm-12.underbar
a.btn.btn-info.pull-right(href="/logout") Log Out
h2
a(href="/feed") 1batch
small an app for sharing 8 photos
.row
.col-sm-12.col-md-6
h1= user.name
if posted
h4 posted #{posted}
else
h4 You haven't posted yet!
if posted
.row
.col-sm-12
.well
p Click a photo to go to its photo page. You can leave comments, hide it, or remove it.
.row.profilephotos
for index in [0, 1, 2, 3]
if index < images.length
.col-sm-6.col-lg-3
img(src=images[index].src.main, srcset=images[index].src.main + " 1x, " + images[index].src.retina + " 2x")
.row.profilephotos
for index in [4, 5, 6, 7]
if index < images.length
.col-sm-6.col-lg-3
img(src=images[index].src.main, srcset=images[index].src.main + " 1x, " + images[index].src.retina + " 2x")
if (!posted || user.republish)
.row
.col-sm-12.col-md-6
form.well(action="/upload?_csrf=" + csrfToken, method="POST", enctype="multipart/form-data")
label Upload a photo that you might use. You can change your mind before posting.
hr
input(type="file", name="upload")
button.btn.btn-success.pull-right Upload
.clearfix
.row
.col-sm-12.savedphotos
if saved.length
h4
span Stored images (
span.pickcount 0
span / 8 picked)
small double-tap to pick
for image in saved
.col-sm-6.col-lg-4
a.pcon(href="javascript:void(0);", class=(image.picked ? 'picked' : ''))
img(id=image._id, src=image.src.main, srcset=image.src.main + " 1x, " + image.src.retina + " 2x")
span.pick
if !image.picked
button Pick ✓
else
button Remove x
else
p No images uploaded yet...
if saved.length
.row
.col-sm-12.postnow.well
p Once you've picked 1 to 8 photos that you really like:
br
button.btn.btn-success Publish PhotosOK, this got a lot longer. Let's break it down row-by-row.
The first few rows are still for log in / log out, your post date, and links to your profile photos (if they have been published yet).
If you haven't published or it's time for you to republish, you will see an upload form. This is a standard HTML form, but note how we've designed it to better post photos: form.well(action="/upload?_csrf=" + csrfToken, method="POST", enctype="multipart/form-data"). The action attribute URL includes your csrfToken, and the enctype uses a type which is recommended for any file upload.
In the next row, you'll see a list of all saved photos, where you will be able to pick photos to publish.
One section that looks unusual is this:
span Stored images (
span.pickcount 0
span / 8 picked)This will appear to the user as "Stored images (0 / 8 picked)" but with the "0" inside a special <span class="pickcount"></span> which can be updated by client-side JavaScript. When Pug is turning each line into HTML, it ignores any whitespace at the beginning or end of the line, so the space between the 0 and / has to be specified using an HTML escape code, which for a non-breaking space is - this also allows you to add longer blocks of whitespace.
A pickable image is printed out like this:
.image-container(class=(image.picked ? 'picked' : ''))
img(id=image._id, src=image.src.main, srcset=image.src.main + " 1x, " + image.src.retina + " 2x")
span.pick-container
button.picker Pick ✓
button.remove Remove xHere the <a> element is more of a block than a link. It will hold the image and then overlay either the Pick or Remove button over it. The trick to making these appear properly is in CSS:
/* the button container is placed over the center of the image */
.savedphotos .pick-container {
display: none;
position: absolute;
top: 50%;
left: 0;
text-align: center;
width: 100%;
}
/* both buttons should look like this */
/* darkgreen default is only for the picker button */
.pick-container button {
background: darkgreen;
color: #fff;
padding: 10px;
border-radius: 30px;
}
/* clarify that the remove button is red and usually hidden */
.pick-container button.remove {
background: red;
display: none;
}
/* when the photo is picked, the picker should hide and the remove should appear */
.picked button.picker {
display: none;
}
.picked button.remove {
display: block;
}Now to switch between picked and un-picked, we need some client-side JavaScript. Include this at the end of the Pug template:
block scripts
script(type="text/javascript", src="/lib/jquery-1.11.3.min.js")
script(type="text/javascript", src="/image-select.js")We're loading the ever-useful jQuery library and our own JavaScript called image-select. Here's what image-select should do:
- when the user clicks (or taps) on a photo, it becomes maybe-picked (with checkmark visible)
- when the user clicks again, it becomes picked (with remove button visible)
- when the user clicks again, it becomes unpicked (no button visible)
$(function() {
$(".savedphotos .image-container").click(function(e) {
if ($(e.currentTarget).hasClass("picked")) {
// already picked, removing it
$(e.currentTarget).removeClass("picked");
} else if ($(e.currentTarget).hasClass("maybe-pick")) {
// confirmed a maybe-pick
$(e.currentTarget).removeClass("maybe-pick")
.addClass("picked");
} else {
// add maybe-pick; click again to confirm
$(e.currentTarget).addClass("maybe-pick");
}
updatePickCount();
});
function updatePickCount() {
var count = $(".picked").length;
$(".pickcount").text(count);
}
updatePickCount();
});There are some interesting things going on here:
- The code is wrapped in jQuery's
$(function(){ ... });, to keep it separate from other code, and waiting for the page to load before running it. - Any and all saved images are checked for clicks and taps using
$(".savedphotos .image-container").click() - If the user uploads a new photo, that'll refresh the page so we don't have to worry about attaching this event listener to any new elements.
-
updatePickCount()is defined to check for picked photos, and it is run immediately so the page can display "0/8 picked" before any clicks are made
Look closer at the function inside the click event listener. We use $(e.currentTarget) because regardless of what part of the image-container gets clicked by the user, we want to see the original image-container, with the image and buttons inside.
Try clicking around to select and un-select some images.
Your client-side changes aren't being posted back to the server yet. Let's set that up.
When the user picks a photo, let's make a call from the client-side back to the server:
$(e.currentTarget).find("button").prop("disabled", true);
$.post("/pick", {
id: $(e.currentTarget).find("img").attr("id"),
_csrf: $(".csrf").val(),
makePick: true
}, function(response) {
$(e.currentTarget).find("button").prop("disabled", false);
if (response.status && response.status === 'success') {
console.log('Success!');
} else {
console.log("An error occurred");
}
});We are posting to one URL, /pick, with a packet of data which includes the image's MongoID (conveniently saved on the page), the CSRF token, and an action (makePick: true). To un-pick a photo, we'll do the same but set makePick to false.
We should embed the CSRF token in the Pug template for our use:
input.csrf(type="hidden", value=csrfToken)When a response comes back, it goes into the callback function. We then know if there was any kind of error. It also allows us to disable and re-enable both buttons while we are waiting for a response from the server.
But what happens on the server to confirm the pick? We need to go back into Node and set that up:
async function pick (ctx) {
var requser = ctx.req.user;
if (requser.posted) {
// would immediately publish, and we don't allow that
return printError('you already posted', ctx);
}
var imgcount = await Image.update({ _id: ctx.request.body.id, user_id: requser.name },
{ picked: (ctx.request.body.makePick === 'true') }).exec();
if (!imgcount) {
return printError('that isn\'t your image', ctx);
}
ctx.body = { status: 'success' };
}After some basic checks, we use Mongoose's update command to say, hey, if there's a photo with this MongoID that belongs to me (the logged-in user), then set its picked value to makePick. This returns a count of zero if something's really not right, like this photo not belonging to you. Otherwise we return a JSON object confirming that it succeeded.
I have some client-side code checking that you don't somehow post more than eight images, but client-side code can always be overridden by the user, so the server-side check is more important. Here's what it looks like on the server:
async function publish (ctx) {
var requser = ctx.req.user;
if (ctx.request.body.makePublish === 'true') {
// publish
if (requser.posted) {
return printError('you already posted', ctx);
}
var count = await Image.count({ user_id: requser.name, picked: true, hidden: false }).exec();
if (!count) {
return printError('you have no picked images', ctx);
}
if (count > 8) {
return printError('you have too many picked images', ctx);
}
await User.update({ name: requser.name }, { posted: (new Date()) }).exec();
requser.posted = new Date();
await Image.update({ user_id: requser.name, picked: true, hidden: false }, { published: true }, { multi: true });
ctx.body = { status: 'success' };
} else {
// un-publish to be described later
}
}We use the Image.count function to make sure that we haven't loaded zero or too many (>8) photos.
We then update the User and their Images, using { multi: true } to make sure that it applies to all of the picked and not-hidden photos.