10.29.2008

Yet one more .NET photo album online

So we've all seen a million different picture albums online. Outside of the pro's like Snapfish and Picasa nearly every website out there has picture albums with thumbnails that you click to view a larger image. Been there done that.

I've had these on my website since it's inception in 1995 or so... I've used various methods over the years to create these. I've tried Photoshop web photo albums, Picasa web photo albums, Frontpage photo albums etc. I kept running into issues that bothered me when I used someone else's solution so I started coding my own albums in straight HTML using tables etc. This worked perfectly because I had complete control of the code but it sure was tedious!

As my digital photography sophistication increased, at least that's my perception anyway, so did the number of pictures I shot and the size of my picture albums. My Yellowstone road trip album has over 300 images in it. Yeah, that's a lot of friggin pictures! It's even more HTML coding and <TD> tags!!! As any developer knows the more code you hand type the more potential you have for typos...

So I've spent too much time trying to find a better solution for me that met the exacting, and some might say odd or anal, requirements that I had for creating picture albums. Here are my requirements:
- Needs to be a grid of thumbnails that when clicked opens a larger version of the image.
- Needs to load relatively fast.
- Would be fun if you could have a next and previous feature so as to act like a slideshow.
- Some interesting animation would be neat!
- Would like to display the image name, when it was shot and the camera used.
- Would like to be able to easily add comments to any picture.
- Would like the images sorted by the date they were taken.
- Would NOT like to code HTML for any picture album!
- Would NOT like to create thumbnails manually!
- Would like to be able to add comments or change them after the images are uploaded so that I can at least create the album, post it, then update comments later.
- Would like to be able to add a header and body text without having to create a new page for the album.
- Bonus points if I can dynamically decide which style sheet to use.
- Will be developed in .NET 2.0/3.5 and Visual Basic.

Those are the main requirements. There may be other things that I don't remember right now. So here is the strategy I came up with:

There should be a thumbnail creator that will automatically generate thumbnails, and save them to disc, in the correct format, landscape or portrait, from a set of source images. The thumbnailer should also read the meta-data of the images for date taken, camera type, and also file name. It should be robust enough that if the meta-data is missing it should use alternate data. The thumbnailer should finally take this data and store it in an XML file saved to the same folder as the images.

The next step would then be to create a slideshow engine that uses the generated XML document to display the thumbnails sorted by date taken and link to the larger version of each image. The slideshow engine should have the facility to decide which stylesheet to apply based on a querystring value. It should also decide if there are header and body text files that it can insert into the page to give content.

Simple yes? Well yes and no. I ran into a few roadblocks along the way. Don't worry, I'll supply code below... Originally I created the entire thumbnailer/slideshow engine as one super dooper photo album engine. Worked great on my local server too! Problem was when I uploaded it to godaddy.com it could no longer read the meta-data on the images. I also ran into permissions issues with creating the XML file and with saving the thumbnails.

So my solution then became the two part process mentioned above. Actually there are the preceding steps of picking the images and then resizing them for the web, but I'll leave that for another topic perhaps...

So the Thumbnailer:
You'll need these imports:

Imports System
Imports System.IO
Imports System.Text
Imports System.Drawing
Imports System.Drawing.Image
Imports System.Windows.Media
Imports System.Windows.Media.Imaging
Imports System.Xml

I have a predefined image folder where all images will be stored in subfolders. When I call the thumbnailer from the browser I need to include the folder I want processed. I decided to go ahead and enumerate these folders to make it easier to go through them. I just dump this to a label on the page as list of links.

Dim oFileSystem = Server.CreateObject("Scripting.FileSystemObject")
Dim oFolder = oFileSystem.GetFolder(Server.MapPath(sfullPath + sSubFolder))
Dim oSubFolders = oFolder.subFolders
Trace.Warn("Page_load", "Folder Count: " + CStr(oSubFolders.count))
If oSubFolders.Count > 0 Then
lblFolderList.Visible = True
lblFolderList.Text = ""
Dim oSingleFolder = oFolder.subFolders
For Each oSingleFolder In oSubFolders
lblFolderList.Text += "<a href=pictionator.aspx?s=" + _
Server.UrlPathEncode(oSingleFolder.Name) + ">" + oSingleFolder.Name + _
"</a><br>"
Next
oSingleFolder = Nothing
End If
oFileSystem = Nothing
oFolder = Nothing
oSubFolders = Nothing

Now I have a list of links that I can just click on to process any subfolder in my images folder.

First step is to generate thumbnails:

Dim sSubFolder As String = ""
Dim sfullPath As String = "imges/"
If Len(Request("s")) > 0 Then
sSubFolder = Request("s") + "/"
End If
Dim oDir As System.IO.DirectoryInfo
oDir = New System.IO.DirectoryInfo(Server.MapPath(sfullPath + sSubFolder))
If oDir.Exists() Then
Dim oFileSystem = Server.CreateObject("Scripting.FileSystemObject")
Dim oFolder = oFileSystem.GetFolder(Server.MapPath(sfullPath + sSubFolder))
Dim oFiles = oFolder.Files
Response.Write("File Count: " + CStr(oFiles.Count))
If oFiles.Count > 0 Then
Dim oFile = oFolder.Files
Dim fileNameLink, fileNameExt As String
Dim i As Int32 = 0
Dim thW As Integer = 0
Dim thH As Integer = 0
Dim imgFile As String
Dim myimg As System.Drawing.Image
For Each oFile In oFiles
'Basically on process JPG's that do not have a thumbnail already
If Not File.Exists(Server.MapPath(sfullPath + sSubFolder) + "\" + _
fileNameLink + "_sm." + fileNameExt) And Right(fileNameLink, 3) <>_
"_sm" And fileNameExt.ToLower = "jpg" Then
imgFile = (Server.MapPath(sfullPath + sSubFolder) + "\" + _
fileNameLink + "." + fileNameExt)
myimg = System.Drawing.Image.FromFile(imgFile)
'decide format of the image
If myimg.Width > myimg.Height Then
thH = 120
thW = 150
Else
thH = 150
thW = 120
End If
myimg = myimg.GetThumbnailImage(thW, thH, Nothing, IntPtr.Zero)
myimg.Save(Replace(imgFile, "." + fileNameExt, "_sm." + _
fileNameExt), myimg.RawFormat)
End If
Next
End If
End If

The next step is to build the XML document:

Protected Sub buildXMLDoc()
If Request.QueryString("s") Is Nothing Then
lblMessage.Text = "Sorry that folder does not seem to exist."
Else
Try
Dim sSubFolder As String
Dim sfullPath As String = "images/"
sSubFolder = Request.QueryString("s")
Dim dir As New IO.DirectoryInfo(Server.MapPath(sfullPath + sSubFolder))
'Get a list of only .jpg files. Others can expand this to different
'file types.
Dim fileSet As IO.FileInfo() = dir.GetFiles("*.jpg")
Dim afile As IO.FileInfo
Dim imageXMLWriter As XmlTextWriter = New _
XmlTextWriter(Server.MapPath(sfullPath + sSubFolder + "/picXML.xml"), _
Encoding.UTF8)
imageXMLWriter.Formatting = Formatting.Indented
'Beginning of the XML Doc.
imageXMLWriter.WriteStartDocument()
imageXMLWriter.WriteStartElement("imageList")

'list the names of all files in the specified directory
For Each afile In fileSet
If InStr(afile.Name.ToString, "_sm") = 0 Then
'Add node for each image.
addImageNode(sfullPath + sSubFolder + "/" + afile.Name.ToString,_
afile.Name.ToString, imageXMLWriter)
End If
Next

'End and close the writer
imageXMLWriter.WriteEndElement()
imageXMLWriter.WriteEndDocument()
imageXMLWriter.Close()

dir = Nothing
fileSet = Nothing
afile = Nothing

Catch ex As Exception
End Try
End If
End Sub

We need to create the nodes:

Private Sub addImageNode(ByVal fileNamePath As String, ByVal fileName As String, ByRef theXMLDoc As XmlWriter)
Dim thumbExt As String = ""
theXMLDoc.WriteStartElement("image")
Try
'I've used several different identifiers for thumbnails over the years.
If File.Exists(Server.MapPath(Replace(fileNamePath.ToLower, ".jpg", _
"_sm.jpg"))) Then
thumbExt = "_sm.jpg"
ElseIf File.Exists(Server.MapPath(Replace(fileNamePath.ToLower, ".jpg", _
"_small.jpg"))) Then
thumbExt = "_small.jpg"
ElseIf File.Exists(Server.MapPath(Replace(fileNamePath.ToLower, ".jpg", _
"_th.jpg"))) Then
thumbExt = "_th.jpg"
End If

Dim img As BitmapSource = BitmapFrame.Create(New _
Uri(Server.MapPath(fileNamePath)))
Dim meta As BitmapMetadata = img.Metadata
'create the data elements for the image
theXMLDoc.WriteElementString("thumbnail", Replace(fileNamePath.ToLower, _
".jpg", thumbExt))
theXMLDoc.WriteElementString("filename", fileNamePath)
Try
theXMLDoc.WriteElementString("Comment", _
FormatDateTime(meta.DateTaken.ToString, DateFormat.ShortDate) + " - " + _
fileName.ToString + " - " + meta.CameraModel.ToString)
Catch ex1 As Exception
theXMLDoc.WriteElementString("Comment", fileName.ToString)
End Try
theXMLDoc.WriteElementString("ImageName", fileName.ToString)
theXMLDoc.WriteElementString("CameraModel", meta.CameraModel)
Try
theXMLDoc.WriteElementString("datetaken", FormatDateTime(meta.DateTaken,_
DateFormat.GeneralDate))
theXMLDoc.WriteElementString("sortnode", _
DateTime.Parse(meta.DateTaken).Ticks)
Catch ex2 As Exception
Dim oFile As FileInfo = New FileInfo(Server.MapPath(fileNamePath))
theXMLDoc.WriteElementString("datetaken", _
FormatDateTime(oFile.CreationTime(), DateFormat.ShortDate))
theXMLDoc.WriteElementString("sortnode", _
DateTime.Parse(oFile.CreationTime().Ticks))
oFile = Nothing
End Try
theXMLDoc.WriteElementString("rel", "lightbox[images]")

meta = Nothing
img = Nothing
Catch ex As Exception
End Try
theXMLDoc.WriteEndElement()
End Sub


The final step that I do is to create a header.txt and a body.txt in each image subfolder that contains the text content of the page. I put as little HTML in my content as possible, however this allows me to tell a story about the images below and I still don't have to edit the slideshow page!

The reason this is now a two step process is because all of the stuff above has to run on my local server where I have complete control of permissions and stuff. Your hosting service may allow you to run all this stuff on their server. Mine wouldn't...

Now we have the Slideshow engine itself. After I post all my image subfolders and all the content to my hosting server this is the part that creates the slideshow for the user:

The ASPX page looks like this:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
<title>Ebertworld Slideshow Engine</title>
<asp:Literal ID="litStyleSheet" runat="server"></asp:Literal>
<link href="style.css" rel="stylesheet" type="text/css" />
<script type="text/javascript" src="js/prototype.js"></script>
<script type="text/javascript" src="js/scriptaculous.js?load=effects"></script>
<script type="text/javascript" src="js/lightwindow.js"></script>
<link rel="stylesheet" href="css/lightwindow.css" type="text/css" media="screen" />
</head>
<link rel="shortcut icon" href="../websiteicon.ico" />
<body>
<form id="form1" runat="server">
<div style="width:800px;">
<!--#include file="nav.htm"-->
<br />
<asp:Label ID="lblMessage" runat="server"></asp:Label><br />
<asp:Label ID="lblHeader" runat="server" Visible="false"></asp:Label><br />
<p>
<asp:LinkButton id="lnkShowStoryTop" runat="server" Visible="False">More of
the story...</asp:LinkButton>
<asp:label id="lblBody" runat="server" Visible="false"></asp:label>
<asp:Label id="lblBodyTeaser" runat="server"></asp:Label>
 <asp:LinkButton id="lnkShowStory" runat="server"><br />More of the
story...</asp:LinkButton>
</p>
<div>
<asp:DataList ID="dlImageList" runat="server" RepeatColumns="4"
RepeatDirection="Horizontal" HorizontalAlign="Center">
<ItemTemplate>
<div style="text-align:center;">
<asp:HyperLink ID="hprLink" runat="server" NavigateUrl='<%#
eval("filename") %>' class="lightwindow" rel='<%# eval("rel") %>'
title='<%# eval("Comment") %>'>
<asp:Image ID="thumbnail" runat="server" AlternateText='<%#
eval("Comment") %>' ImageUrl='<%# eval("thumbnail") %>' ToolTip='<%#
eval("Comment") %>' />
</asp:HyperLink><br />
<asp:Label ID="lblTitle" runat="server" Text='<%# eval("ImageName") %>'
Font-Size="6pt" ForeColor="#FFFF66"></asp:Label>
</div>
</ItemTemplate>

</asp:DataList>
</div>
</div>
</form>
</body>
</html>

You might notice some references to prototype, scriptaculous and lightwindow. I'm using these awesome javascript libraries for the image display features. You could skip that or use something else like Mootools, that's up to you. The other thing to notice here is that I'm using a literal in the header for placement of a stylesheet link...


Here is the slideshow engine in all it's glory:

Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Load
If Request.QueryString("s") Is Nothing Then
lblMessage.Text = "Sorry that folder does not seem to exist."
lblBodyTeaser.Visible = False
lnkShowStory.Visible = False
Else
Try
If Request.QueryString("p") Is Nothing Then
litStyleSheet.Text = "<link rel='stylesheet' type='text/css' href='../Style.css' />"
Else
Select Case Request.QueryString("p")
Case "1"
litStyleSheet.Text = "<link rel='stylesheet' type='text/css' href='css/1/style.css' />"
Case "2"
litStyleSheet.Text = "<link rel='stylesheet' type='text/css' href='../2/Style.css' />"
Case "3"
litStyleSheet.Text = "<link rel='stylesheet' type='text/css' href='../3/Style.css' />"
Case Else
litStyleSheet.Text = "<link rel='stylesheet' type='text/css' href='../Style.css' />"
End Select
End If
Dim sSubFolder As String
Dim sfullPath As String = "images/"
sSubFolder = Request.QueryString("s")
Dim myFileText As String
Dim sr As StreamReader

If File.Exists(Server.MapPath(sfullPath + sSubFolder + "/header.txt")) Then
sr = New StreamReader(Server.MapPath(sfullPath + sSubFolder + "/header.txt"))
myFileText = sr.ReadToEnd()
sr.Close()
lblHeader.Text = myFileText
lblHeader.Visible = True
End If

If File.Exists(Server.MapPath(sfullPath + sSubFolder + "/body.txt")) Then
sr = New StreamReader(Server.MapPath(sfullPath + sSubFolder + "/body.txt"))
myFileText = sr.ReadToEnd()
sr.Close()
lblBody.Text = myFileText
lblBodyTeaser.Text = Left(myFileText, 120) + "..."
lblBodyTeaser.Visible = True
End If
sr = Nothing
If File.Exists(Server.MapPath(sfullPath + sSubFolder + "/imageXML.xml")) Then

Dim xmlRdr As XmlReader = XmlReader.Create(New IO.StreamReader(Server.MapPath(sfullPath + sSubFolder + "/imageXML.xml")))

Dim ds As DataSet = New DataSet
ds.ReadXml(xmlRdr)
Dim dv As DataView
dv = ds.Tables(0).DefaultView
dv.Sort = "sortnode"

dlImageList.DataSource = dv
dlImageList.DataBind()
xmlRdr.Close()
xmlRdr = Nothing
Else
lblMessage.Text = "Sorry didn't find an image list..."
End If
Catch ex As Exception
End Try
End If
End Sub

You can see that I'm also processing the header.txt and body.txt files if they exist. Pretty simple huh? I used a text hiding feature for the body text so that people who only want to look at the pictures can, while those that want to read the story have that option too. The mechanisms for hiding and showing that text is here:

Private Sub lnkShowStoryTop_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles lnkShowStoryTop.Click
If lblBody.Visible = True Then
lblBody.Visible = False
lblBodyTeaser.Visible = True
lnkShowStory.Text = "More of the story..."
lnkShowStory.ToolTip = "Click to view the entire event narrative"
lnkShowStoryTop.Text = "More of the story..."
lnkShowStoryTop.Visible = False
lnkShowStoryTop.ToolTip = "Click to view the entire event narrative"
Else
lblBodyTeaser.Visible = False
lblBody.Visible = True
lnkShowStory.Text = "Hide..."
lnkShowStory.ToolTip = "Click to hide the event narrative"
lnkShowStoryTop.Text = "Hide..."
lnkShowStoryTop.Visible = True
lnkShowStoryTop.ToolTip = "Click to hide the event narrative"
End If
End Sub
Private Sub lnkShowStory_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles lnkShowStory.Click
If lblBodY.Visible = True Then
lblBodY.Visible = False
lblBodyTeaser.Visible = True
lnkShowStory.Text = "More of the story..."
lnkShowStory.ToolTip = "Click to view the entire event narrative"
lnkShowStoryTop.Text = "More of the story..."
lnkShowStoryTop.Visible = False
lnkShowStoryTop.ToolTip = "Click to view the entire event narrative"
Else
lblBodyTeaser.Visible = False
lblBodY.Visible = True
lnkShowStory.Text = "Hide..."
lnkShowStory.ToolTip = "Click to hide the event narrative"
lnkShowStoryTop.Text = "Hide..."
lnkShowStoryTop.Visible = True
lnkShowStoryTop.ToolTip = "Click to hide the event narrative"
End If
End Sub

One final comment.

You may have noticed an extra node in my XML document that I didn't really mention. The "sortNode". I kept running into the problem of when sorting my pictures by the dateTaken they would sort as if the date was a text string not a date. So a picture taken on 12/3/07 would show up before a picture taken on 3/3/07. I went down the path of trying to use either an XSD or an XSLT to help me sort the nodes but the solution did not prosent itself in a timely manner and I can have a short attention span from time to time. I call it the Shiny Object Syndrome...

Anyway, I decided that I just needed a way for the gridview to natively sort on an element in my XML document properly. I knew that it could handle strings and numbers pretty well. Then it dawned on me. Dates on a computer are stored as ticks, 100 millisecond segments of time since a given point in the past. This value is a big int! Perfect! So I just added the sort node and formatted the dateTaken value in ticks and I had my perfect sort value.