Click here to Skip to main content
Click here to Skip to main content

Riverside Internet Forums

, 18 Aug 2003
Rate this:
Please Sign up or sign in to vote.
A set of ASP.NET custom controls that allow forums to be quickly added to a website.

Riverside Internet Forum

<!------------------------------- STEP 3 ---------------------------><!-- Add the article text. Please use simple formatting (

,

etc) -->

Introduction

This article describes a set of ASP.NET custom controls that make it quick and easy to add a forum to any web page.  The forum code was initially written using VB script and ASP pages and one of the main goals was to mimic the look and feel of the CodeProject forums.  Subsequently, the forums have been re-written in C# as ASP.NET custom controls.  When the forums are viewed in Tree View mode, the look and feel is very similar to the CodeProject forums.  However, in Flat View the forums look have been designed to look more like the forums found at http://www.asp.net/.

The Riverside Internet forums presented here are part of a larger set of ASP.NET custom controls that make up the Riverside Internet WebSolution.  The complete WebSolution, as well as containing forums, provides controls for managing folders, documents and general web content.  Eventually it is hoped that all of this code will be submitted to CodeProject.

Getting Started

  1. Unzip RiversideInternetForums.zip into the directory C:\RiversideInternetForums
  2. Using IIS, add a new Virtual Directory with alias RiversideInternetForums and specify as the path to the directory that contains the content, C:\RiversideInternetForums.
  3. From within IIS, grant Write access to the avatars directory.
  4. Open WebSolution.zip to extract the file WebSolution.sql.
  5. Send WebSolution.sql to your SQL Server to create the database RiversideInternetForums, the tables WS_Webs, WS_Users, WS_Threads, WS_Posts, associated indices and stored procedures.
  6. Edit C:\RiversideInternetForums\Web.config so that the application setting "WebSolutionConnectionString" is a valid connection string for the RiversideInternetForums database.
  7. You will have to add a new user to the RiversideInternetForums database, which is permitted in the database roles: public and db_owner.  When using a Trusted Connection, the name of this user will take the form <COMPUTER NAME>\ASPNET.
  8. Start your browser and open the virtual directory RiversideInternetForums, for example http://localhost/riversideinternetforums/, to see the results.  You should be presented with an empty forum.
  9. Click on Join to setup a new user.  Then start posting!

A warning: Although not described in this article, the Riverside Internet WebSolution is designed to support multiple websites from a single database/codebase installation.  Multiple websites are supported by associating a web identifier (WebID) to each website (WebDomain).  These associations are stored in the database table WS_Webs.  By default, the WebSolution.sql script inserts the web identifier 1 for the website "localhost".  However, if your IIS server is not referred to by "localhost", then you will need to change the value "localhost" to reflect the name of your IIS server.  Similarly, if your website can be accessed through a URL such as http://www.riversideinternet.com/, then a row must be inserted in to WS_Webs that has WebID equal to 1 and WebDomain equal to 'riversideinternet.com'.  WebDomain is always lowercase and is set to the host name of HTTP requests with the initial www dropped).  WS_Webs.Folder can be left NULL.

The first thing to note is how simple it is to add a forum to a web page.  All that is required is the following two lines (see default.aspx):

<%@ Register TagPrefix="WS" Namespace="RiversideInternet.WebSolution"
    Assembly="RiversideInternet.WebSolution" %>
<WS:Forum ForumID="1" Runat="server" id="_forum" />

The control that provides links for Join, Logon, My Settings and Logoff at the top of default.aspx is created using the following code:

 <WS:LoginControl
BorderStyle="None" Runat="server" id= "_loginControl" />

Certain actions, such as starting a new thread or posting a reply, require a user to be logged on.  If a user is not currently logged on, performing one of these actions will cause the user to be redirected to a login page.  The page redirected to is determined by the application setting "WebSolutionUserManagementURL" found in Web.config.  This page should contain the user management control:

<WS:UserManagement Runat="server" id= "_userManagement" />

The forums use "Forms" Authentication to determine whether a user is logged on or off.  Therefore the authentication mode found in Web.config should be set to "Forms".

Other application settings found in Web.config are "WebSolutionAvatarsURL", which is the web address where avatars can be found.  In the Web.config supplied, this is set to "/RiversideInternetForums/avatars/".  The application setting "WebSolutionAvatarsPath" should point to the physical location on disc where avatars are located.  In the example Web.config provided, this is set to "C:\RiversideInternetForums\avatars\".  Finally, the application setting "WebSolutionImagesURL" must point to the web address where forum images are found.  In the example code, this is set to "/RiversideInternetForums/images/".

The appearance of the forum, user management and other controls is determined by the style sheet websolution.css.  This file can be customised to change the appearance of the various controls.

Forum Overview

In the example above, only one page is required to display the forum - namely, default.aspx.  The custom control RiversideInternet.WebSolution.Forum detemines what HTML should be rendered by looking at the query string.  The query string variable threadid is used to identify a thread to view.  The query string variable postid may also be used to specify a post within a thread.  If neither postid or threadid is specified, the forum code knows it should display a paged list of forum threads.

25 threads are displayed per page with information such as thread title, thread starter, number of replies, number of views and the name of the last poster all present.  A thread with more than 30 posts is indicated by the image of a folder on fire.  Threads are ordered descending by the time of the last post.  Therefore if someone replies to a thread that is some way down the list, it will be elevated to the top of the list.

When threadid or postid are specified in the query string, the forum code knows it should display a particular thread.  Instead of rendering the paged list of forum threads, an individual thread is displayed.  A thread can be displayed in flat view mode or tree view mode.  In flat view mode, a thread has it's posts displayed in order oldest to newest and the body of each post is visible.  In tree view mode, a thread is displayed as a hierarchy of posts and looks similar to a thread on the CodeProject forums.  In both modes, 30 posts are displayed per page.  A cookie is used to store whether the user is in tree view or flat view mode and this setting affects all forums.

The query string variable forumaction is used when starting a new thread, editing a post, replying to a post, quoting a post or performing a search.  With the exception of search, every one of these actions requires a user to be logged on.  If no user is logged on, the web browser will be redirected to a login page.  After a successful logon, the browser will be automatically redirected back to the previous page.  When forumaction has the value "new" (and no postid is specified), the forum control knows that it should render a form for starting a new thread.  When forumaction has the value "reply" (and postid is specified), the forum control renders a form for posting a reply.  The "quote" action is similar to the "reply" action, except the post being replied to is encompassed by [QUOTE] tags and some additional text is included identifying who posted the message being quoted.  This is most useful when a forum is in flat mode, as it can sometimes be difficult to identify the post a user is replying to.  The action "edit" is used when a user is editing a post.  A user may only edit posts that they have authored.

When forumaction equals "search", the forum code knows to perform a search.  In this case, an additional query string variable called searchterms is used which contains the terms used to search the forum.  Posts that match the search critieria are displayed in a paged list, order by posted date descending.  In order for a post to be identified by a search, each search term specified must be found somewhere in the body of the post.

User Management Overview

In the example above, only one page is required to display user management information - namely, usermanagement.aspx.  The custom control RiversideInternet.WebSolution.UserManagement determines what HTML should be rendered by looking at the query string.  The action performed depends on the query string variable useraction, which can take one of four possible values: "login", "logoff", "join" and "settings".

When useraction has the value "login", the user management control renders a login form that prompts the user for an e-mail address and password.  Ticking the remember me check box causes a durable cookie (one that is saved across browser sessions) to be written to the client and this will have the effect of logging the user on automatically for subsequent browser sessions.  If useraction has the value "logoff", the user management code renders a simple HTML page which contains a logoff button.  When this button is pressed, the currently logged on user is logged off.

The useraction values "join" and "settings" are used to sign a user up or modify the logged on user's settings.  In each case, the user management control renders a form displaying Alias, e-mail, password, confirm password and avatar text boxes.  A user's alias is the name by which he or she is known on the forums.  A user's e-mail address is used in conjunction with a password in order to login.  It may also be used when a user has submitted a post with the "Notify by e-mail" option ticked.  When such a post is replied to, a notification e-mail is sent to the author of that post.  Finally, the avatar text box is used to upload an image to the web server.  Avatars are images that are displayed underneath user information towards the left hand side of every post.  If a user has not uploaded an avatar, no image is displayed alongside posts by that user.  Avatars must be no larger than 150 by 150 pixels, must be less than 100K in size and must be in the format GIF, JPEG or PNG.  The forums screenshot, shown above, displays posts by Nick's Mum, Pud and The Mole.  Their avatars, a dog wearing glasses, a Triumph Dolomite Sprint and a monkey, are all visible.

Role-Based Security With Forms Authentication

As has already been stated, the forums use "Forms" Authentication to determine whether a user is logged on or off.  Therefore the authentication mode found in Web.config should be set to "Forms".  The forums use role-based authentication to determine what operations a user can perform.  The roles a user can perform are stored in a comma separated list in the database field WS_Users.Roles. Currently, WS_Users.Roles must be administered using raw SQL or Enterprise Manager. Users with "ForumAdmin" role, may edit the posts of other users as well as enter sticky (pinned) threads. When users with "ForumAdmin" role post, an additional drop down list is visible that allows the admin user to specify whether a thread is sticky and the duration that it should stick.

Role-based security has been implemented using code written by Heath Stewart.  Thanks, Heath!  For more information on role-based security, please read Heath Stewart's article.

Functionality Controlled By Query String 

In order to understand how the query string controls functionality, it is necessary to understand three of the commonly overridden methods of System.Web.UI.Control.  Namely, CreateChildControls, OnPreRender and Render.  The CreateChildControls function is often overridden in composite controls (such as the forum and user management controls described above) in order to create child controls in preparation for postback or rendering.  OnPreRender is called to implement any work that is required by a control before rendering, for example getting data from a database.  Finally, the Render method is used to write markup text (HTML) to the output stream.

All RiversideInternet.WebSolution controls, such as the forum and user management controls, derive from WebSolutionControlWebSolutionControl, in turn, derives from System.Web.UI.WebControls.WebControl which derives from System.Web.UI.Control.  What is important is that in WebSolutionControl, three functions are overridden: CreateChildControls, OnPreRender and Render.

public class WebSolutionControl : WebControl, INamingContainer
{
    ...
    private WebSolutionObject _webSolutionObject;


    protected virtual void CreateObject()
    {
    }

    protected override void CreateChildControls()
    {
        CreateObject();
        if (_webSolutionObject != null)
            _webSolutionObject.CreateChildControls();
    }

    protected override void OnPreRender(System.EventArgs e)
    {
        if (_webSolutionObject != null)
            _webSolutionObject.OnPreRender();
    }

    protected override void Render(System.Web.UI.HtmlTextWriter writer)
    {
        if (_webSolutionObject != null)
            _webSolutionObject.Render(writer);
    }

    ...
}

CreateChildControls, called before OnPreRender and Render, calls the virtual function CreateObject.  The task of CreateObject is to create a WebSolutionObject and store it's reference in the member variable _webSolutionObject.  The WebSolutionObject class contains some commonly used functions and three public virtual functions CreateChildControls, OnPreRender and Render.  After the call to CreateObject, the function _webSolutionObject.CreateChildControls is called.  Following CreateChildControls are calls to OnPreRender and Render and these functions simply delegate out the work by calling _webSolutionObject.OnPreRender and _webSolutionObject.Render.

The Forum control overrides CreateObject to create either a ForumThreads, a ForumThread, a ForumForm or a ForumSearch object, depending on the query string.  As these objects are derived from WebSolutionObject, they can (and do) implement CreateChildControls, OnPreRender and Render functions.  These four classes are responsible for implementing the four areas of functionality described in the Forum Overview section above.

Similarly, the UserManagement control overrides CreateObject to create either a UserLogin, UserLogoff or UserSettings object depending on the query string.  These three classes are reponsible for implementing the three areas of functionality described in the User Management Overview section above.

It should also be noted that WebSolutionControl implements INamingContainerINamingContainer is a marker interface that does not have any methods but causes the page to create a new naming scope under each WebSolutionControl.  When this interface is implemented, any child controls contained are guaranteed to have identifiers (represented by the UniqueID property) that are truly unique on the page.

Database Access Layer

All of the controls implemented in the Riverside Internet WebSolution use a middle-tier component to provide communication between the WebSolution controls and the SQL Server database.  The middle-tier component is comprised of the classes ForumDB and UserDB.  Only these classes use System.Data and System.Data.SqlClient and information is passed back to WebSolution controls via simple helper classes.

For example, UserDB has a static function called GetUser that takes a user identifier (an integer) as an input parameter and returns information about the specified user.  Information is returned in a User object that has properties Alias, Avatar, Email, Password, PostCount, UseAvatar, UserID and WebID that correspond to each of the fields found in the WS_Users table.

Other classes used to communicate between the middle-tier database access layer and the server controls include: ForumThreadInfo, ForumSearchInfo and ForumPost.  When viewing the paged list of forum threads, the information required to render each row in the list is obtained from a ForumThreadInfo object.  In fact, the function ForumDB.GetThreads returns a ForumThreadInfoCollection, which is an ArrayList of ForumThreadInfo, and this collection is used to render the paged list of forum threads.  Forum search results are coded in the same way.  However, in this case ForumDB.GetForumSearchResults returns a ForumSearchInfoCollection .

ForumDB.GetThreads calls the stored procedure WS_GetThreads, which takes three input parameters: a forum identifier (@ForumID int), a page size (@PageSize int) and a page index (@PageIndex int).  The query string variable threadspage (the page index) is used to determine which page of threads a user is viewing.  With a page size of 25 and threadspage equal to 1, the first 25 threads are visible.  When threadspage equals 2, threads 26-50 are visible and so on.  The important thing to note is that WS_GetThreads does not return all forum threads to the web server.  Rather, it returns a page of threads depending on @PageSize and @PageIndex .  In this way network traffic is reduced and the hard work is done on the database server where it is usually possible to get the best performance for this kind of operation.  Furthermore, paging results in this way greatly simplifies the C# code.

Paging is achieved by creating a temporary table, #PageIndex, inside WS_GetThreads which has an identity field, IndexID, and a ThreadID field into which all thread identifiers belonging to a particular forum are inserted.  The stored procedure WS_GetThreads then SELECTs out and returns only a subset of threads stored in #PageIndex depending on @PageSize and @PageIndex.

CREATE PROCEDURE WS_GetThreads
(
    @ForumID   int,
    @PageSize  int,
    @PageIndex int
)
AS

DECLARE @PageLowerBound int
DECLARE @PageUpperBound int

SET @PageLowerBound = @PageSize * @PageIndex
SET @PageUpperBound = @PageLowerBound + @PageSize + 1

CREATE TABLE #PageIndex 
(
    IndexID  int IDENTITY (1, 1) NOT NULL,
    ThreadID int
)

INSERT INTO #PageIndex (ThreadID)

SELECT 
    ThreadID
FROM 
    WS_Threads
WHERE 
    ForumID = @ForumID
ORDER BY 
    PinnedDate DESC

SELECT
    ...
FROM
    WS_Threads, ..., #PageIndex PageIndex
WHERE
    WS_Threads.ThreadID = PageIndex.ThreadID AND
    PageIndex.IndexID   > @PageLowerBound    AND
    PageIndex.IndexID   < @PageUpperBound    AND
    ...
ORDER BY
    PageIndex.IndexID
GO        

The original idea for the paging technique described above, found in stored procedures WS_GetThreads and WS_GetForumSearchResults, came from the http://www.asp.net/ forums.

Finally, a few words of explanation about the WS_Threads and WS_Posts tables, which are at the heart of the Forum control.  When a new thread is started, a row is inserted into both WS_Posts and WS_Threads tables.  WS_Posts.PostID is an identity field and so is automatically assigned a unique value when a row is inserted into WS_Posts.  The row inserted into WS_Threads has ThreadID set to this automatically generated value (WS_Posts.PostID).  As replies to a thread are posted, rows are inserted into WS_Posts.  The ParentPostID field is set to the identifier of the post replied to.  When a new thread is started, the row inserted into WS_Posts has ParentPostID equal to 0 - that is, it has no parent.  For every reply inserted into WS_Posts, WS_Threads.Replies is incremented by 1 and WS_Threads.LastPostedPostID is updated to reflect the post which has most recently been made to a particular thread.

The table WS_Posts has fields PostLevel, TreeSortOrder and FlatSortOrder that are maintained so that the stored procedure WS_GetThread can quickly obtain a paged list of thread posts.  PostLevel and TreeSortOrder are used when viewing a thread in tree view mode, while FlatSortOrder is used when viewing a thread in flat view mode.

The PostLevel field indicates at what depth in the tree view hiearchy a post sits.  When a thread is started, a row is inserted into WS_Posts with PostLevel 0.  Replies to this post will have PostLevel 1.  Replies to any of these posts will have PostLevel 2.  On screen, PostLevel indicates the size of horizontal ident from the left hand edge before a post is rendered.  The TreeSortOrder field maintains the order in which posts are displayed in tree view mode.  When a new thread is started a row is inserted into WS_Posts with TreeSortOrder equal to 0 (call this Post 1).  The first reply to this post will have TreeSortOrder 1 (Post 2).  Replying to Post 1 again will cause another row to be inserted into WS_Posts with TreeSortOrder 2 (Post 3).  However, if a reply is posted to Post 2 (call this Post 4), this post will assume TreeSortOrder 2 with Post 3 shifted downwards to TreeSortOrder 3.

Image illustrating PostLevel and TreeSortOrder

Image illustrating PostLevel and TreeSortOrder

The FlatSortOrder field maintains the order in which posts are displayed in flat view mode.  In flat view mode, posts are simply displayed in order oldest to newest.  Therefore, when a new thread is started the first post has FlatSortOrder 0.  Subsequent replies have FlatSortOrder equal to 1, 2, 3, etc.

Image illustrating FlatSortOrder

PostLevel, TreeSortOrder and FlatSortOrder are automatically updated in the stored procedure WS_AddPost, which is based on a similar stored procedure found in the http://www.asp.net/ forums.

Interesting Bits And Pieces

Here are a few other interesting bits and pieces that are worth talking about...

Uploading And Validating Avatars

The UserManagement control, when in "join" or "settings" mode, facilitates the upload of an avatar from the client machine to the web server.  The avatar must be no larger than 150 by 150 pixels, less than or equal to 100K in size and be of the type GIF, JPEG or PNG.  In ASP.NET it is easy to upload files to a web server, using the System.Web.UI.HtmlControls.HtmlInputFile control.  However, in order for this to work the Web Form (in the example code provided, usermanagement.aspx) must have the property enctype set to "multipart/form-data".  The code for uploading the avatar can be found in the UserSettings class, while the code for validating the properties of the uploaded file (dimensions, size, type) can be found in the ValidAvatar class, which is derived from System.Web.UI.WebControls.BaseValidator.  The HtmlInputFile control is used in ValidAvatar to validate the size and type, while the System.Drawing.Image class is used to validate image dimensions.

Sending E-mail From An ASP.NET Application

The function EmailReplyNotification in the ForumForm class illustrates how to use the System.Web.Mail.MailMessage and System.Web.Mail.SmtpMail classes for sending e-mail.

Case Insensitive String Replace

The ForumText class is used to perform forum string manipulations such as replacing text like Smile | :) , Wink | ;) , Frown | :( with the appropriate emoticons.  Of particular interest is the function FormatDisableHtml, which replaces potentially malicious HTML such as <script with &lt;script.  As HTML is case insensitive, all upper and lower case combinations of <script (for example: <SCRIPT, <script and <ScRiPt) must be replaced by &lt;script.  In order to do this, a case insensitive string replace is required and unfortunately the replace method of the System.String class is case sensitive.  A quick search on Google revealed this article by Brian Bilbro, from which the code in WebSolutionUtils.ReplaceCaseInsensitive is based.

Things Still To Do

The Riverside Internet WebSolution is a work in progress and there are still a great many things to code and improve.  Forum things still to do include:

  • Administrator rights for deleting posts without having to use raw SQL statements!
  • Time zone management.
  • Improve user management control.
  • Improve the way look and feel is customised by utilising System.Web.UI.WebControls.WebControl properties.
  • Improve design time support within Visual Studio .NET.
  • Improve error handling.
  • Implement posts that can't be replied to.
  • Improve searching.
  • etc. etc.

History

Version 1 - 9th June 2003

  • This is the first version uploaded to CodeProject.

Version 2 - 13th August 2003

  • Last Post column in thread list now displays date and time of last post and provides a link to navigate straight to the last post.
  • Role based forms authentication introduced.  Users with role "ForumAdmin", can modify other users posts and post pinned (sticky) threads.
  • Dynamic tree view mode added.
  • QueryString simplified by removing threadpage and threadid parameters. Instead, a single postid parameter is used.
  • WS_Users.UseAvatar field now obsolete, so removed.
  • WS_Threads.IsPinned field now obsolete, so removed.
  • WS_Users.Roles field added for role based authentication.
  • Fixed e-mailing of reply notification bugs.
  • Fixed bugs where malicious HTML can be executed when displaying Alias and post subject in search list.
  • Fixed bug that replaces the word "select" with "&lt;select".
  • Fixed bug that meant confirm password was need not always required on user settings and join pages.
  • Previously, updating a user's settings may have caused the user's avatar to be deleted.  This bug has been fixed.
  • Update database from Version 1 to Version 2 with the file update1to2.sql found in WebSolution.zip (download above).

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

About the Author

Mike Puddephat
Web Developer
United Kingdom United Kingdom
I graduated from the University of Manchester, England in 1993 with a BSc in Computer Science and Mathematics. This was followed by a MSc in Pure Mathematics, obtained from the University of Liverpool, England in 1994. I went on to complete a PhD in Medical Imaging, also at Liverpool University, England.
 
In 1998, I left academia and began working for SimCorp (formerly Bank of America) in London as a computer programmer producing treasury management software. At SimCorp I mainly programmed front end GUI stuff using MFC and C++. I was also involved with the development of the corporate Intranet using ASP.NET.
 
I am currently working as a contractor, mainly writing ASP.NET websites and WinForms applications for Reuters (based in Canary Wharf, London). Instead of C++, I am now mainly coding in C#.
 
Visit Riverside Internet[^]
Visit Mike Puddephat Online[^]

Comments and Discussions

 
Question7th step in riverside internet forum Pinmembersukumari124-Mar-13 17:39 
Questionmy web Pinmembermohammad-mirshahi1-Aug-12 9:17 
QuestionHTTP Error 404 - Not Found PinmemberSumit Kumar Singh India11-Nov-11 1:39 
Generaladd a seach button Pinmembersunil23323-Apr-11 7:01 
Generaltest Pinmembergurujaii31-Oct-10 19:50 
GeneralRe: test PinmemberRamkumar_S29-Dec-10 6:54 
GeneralRe: test PinmemberRamkumar_S29-Dec-10 6:54 
GeneralRe: test PinmemberRamkumar_S29-Dec-10 6:54 
Generali want to test it PinmemberSumit Kumar Singh India9-Dec-11 1:02 
GeneralRe: test PinmemberRamkumar_S29-Dec-10 6:55 
GeneralLogin error Pinmembersara_432128-Sep-10 23:06 
When I click Logon. I am getting the following error. An unhandled exception of type 'System.StackOverflowException' occurred in System.Web.dll.
Please helpe meConfused | :confused:
QuestionConfuse? Pinmemberspotvice294-May-10 19:51 
GeneralLogin Pinmemberubahariya23-Jan-10 6:52 
GeneralRe: Login PinmemberKarthic_KKK27-Jan-10 5:37 
GeneralRe: Login Pinmemberubahariya28-Jan-10 23:23 
GeneralRe: Login Pinmemberdeepali_h26-Feb-10 18:08 
GeneralRe: 2 level Pinmemberdeepali_h26-Feb-10 18:12 
GeneralRe: Login Pinmemberyetti8219-Jan-13 15:12 
GeneralRe: Login Pinmemberdeepali_h26-Feb-10 18:10 
Questiondownloading Problem Pinmemberasanka128ruwan25-Aug-09 19:03 
Generalcreate forum use xml instead sql PinmemberMember 41747207-Jan-09 0:07 
QuestionSQL Query and .Net code for Advanced search in my application ?? Pinmemberbabisharma11-Nov-08 7:26 
QuestionHow to display all logged users in different browsers? Pinmemberpradees4u5-Nov-08 19:47 
Questionsend mail Pinmemberamar elzaman30-May-08 23:56 
AnswerRe: send mail Pinmembersenthilmuruganbtech5-Aug-08 20:41 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web01 | 2.8.140721.1 | Last Updated 19 Aug 2003
Article Copyright 2003 by Mike Puddephat
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid