re-work the image edit super page to use jQuery and add image tag editing
authorTony Cook <tony@develop-help.com>
Mon, 18 May 2015 09:19:30 +0000 (19:19 +1000)
committerTony Cook <tony@develop-help.com>
Mon, 18 May 2015 09:19:30 +0000 (19:19 +1000)
MANIFEST
site/cgi-bin/bse.cfg
site/cgi-bin/modules/BSE/Edit/Article.pm
site/cgi-bin/modules/BSE/TB/Image.pm
site/htdocs/css/admin.css
site/htdocs/js/admin_jedit.js [new file with mode: 0644]
site/htdocs/js/bse_apij.js [new file with mode: 0644]
site/htdocs/js/jquery.mustache.js [new file with mode: 0644]
site/templates/admin/article_img.tmpl
site/templates/admin/basej.tmpl [new file with mode: 0644]
site/templates/preload.tmpl

index 8e9c9fa..faf7de5 100644 (file)
--- a/MANIFEST
+++ b/MANIFEST
@@ -532,6 +532,7 @@ site/htdocs/js/admin-ui/menu.js
 site/htdocs/js/admin.js
 site/htdocs/js/admin_edit.js
 site/htdocs/js/admin_editprodopt.js
+site/htdocs/js/admin_jedit.js
 site/htdocs/js/admin_messages.js
 site/htdocs/js/admin_prodopts.js
 site/htdocs/js/admin_siteusers.js
@@ -541,6 +542,7 @@ site/htdocs/js/bse.js
 site/htdocs/js/bse_adminpage.js
 site/htdocs/js/bse_adminui.js
 site/htdocs/js/bse_api.js
+site/htdocs/js/bse_apij.js
 site/htdocs/js/bse_dialog.js
 site/htdocs/js/bse_flowplayer.js
 site/htdocs/js/bse_loader.js
@@ -550,6 +552,7 @@ site/htdocs/js/builder.js
 site/htdocs/js/controls.js
 site/htdocs/js/dragdrop.js
 site/htdocs/js/effects.js
+site/htdocs/js/jquery.mustache.js
 site/htdocs/js/prototype.js
 site/htdocs/js/sadmin.js
 site/htdocs/js/scriptaculous.js
@@ -572,6 +575,7 @@ site/templates/admin/article_img.tmpl
 site/templates/admin/back/detail.tmpl
 site/templates/admin/back/list.tmpl
 site/templates/admin/base.tmpl
+site/templates/admin/basej.tmpl
 site/templates/admin/catalog.tmpl      # embedded in the shopadmin catalog/product display
 site/templates/admin/catalog_custom.tmpl
 site/templates/admin/changepw.tmpl
index 04a8d46..3480cd9 100644 (file)
@@ -447,6 +447,16 @@ inline:<script type="text/javascript" src="/js/prototype.js"></script>
 <script type="text/javascript" src="/js/scriptaculous.js"></script>
 <script type="text/javascript" src="/js/scriptoverride.js"></script>
 INLINE
+jquerydebug=<<INLINE
+inline:<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.js"></script>
+<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/mustache.js/0.8.1/mustache.js"></script>
+<script type="text/javascript" src="/js/jquery.mustache.js"></script>
+INLINE
+jquery=<<INLINE
+inline:<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
+<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/mustache.js/0.8.1/mustache.min.js"></script>
+<script type="text/javascript" src="/js/jquery.mustache.js"></script>
+INLINE
 
 [nuser controllers]
 user=BSE::UI::User
index 813876d..63d60fd 100644 (file)
@@ -16,7 +16,7 @@ use List::Util qw(first);
 use constant MAX_FILE_DISPLAYNAME_LENGTH => 255;
 use constant ARTICLE_CUSTOM_FIELDS_CFG => "article custom fields";
 
-our $VERSION = "1.055";
+our $VERSION = "1.057";
 
 =head1 NAME
 
@@ -73,7 +73,8 @@ sub article_dispatch {
   my $action;
   my %actions = $self->article_actions;
   for my $check (keys %actions) {
-    if ($cgi->param($check) || $cgi->param("$check.x")) {
+    if ($cgi->param($check) || $cgi->param("$check.x")
+       || $cgi->param("a_$check") || $cgi->param("a_$check.x")) {
       $action = $check;
       last;
     }
@@ -1241,6 +1242,49 @@ sub _custom_fields {
   return \%active;
 }
 
+=back
+
+=head1 Common Edit Page Tags
+
+Variables:
+
+=over
+
+=item *
+
+C<article> - the article being edited.  This is a dummy article when a
+new article is being created.
+
+=item *
+
+C<isnew> - true if a new article is being created.
+
+=item *
+
+C<custom> - describes custom tags.
+
+=item *
+
+C<errors> - errors from the last submission of the page.
+
+=item *
+
+C<image_stores> - a function returning an array of possible image
+storages.
+
+=item *
+
+C<thumbs> - for the image list, whether thumbs should be displayed
+instead of full size images.
+
+=item *
+
+C<can_thumbs> - true if thumbnails are available.
+
+=back
+
+=cut
+
 sub low_edit_tags {
   my ($self, $acts, $request, $article, $articles, $msg, $errors) = @_;
 
@@ -1295,6 +1339,12 @@ sub low_edit_tags {
   $request->set_variable(errors => $errors || {});
   my $article_type = $cfg->entry('level names', $article->{level}, 'Article');
   $request->set_variable(article_type => $article_type);
+  $request->set_variable(thumbs => defined $thumbs_obj);
+  $request->set_variable(can_thumbs => defined $thumbs_obj_real);
+  $request->set_variable(image_stores =>
+                        sub {
+                          $self->iter_image_stores;
+                        });
 
   return
     (
@@ -2084,6 +2134,8 @@ sub save_new_more {
   # nothing to do here
 }
 
+=over
+
 =item save
 
 Error codes:
@@ -3127,6 +3179,24 @@ sub save_image_changes {
        if length $image->{name};
     }
 
+    if ($cgi->param("_save_image_tags$image->{id}")) {
+      my @tags = $cgi->param("tags$image->{id}");
+      my @errors;
+      my $index = 0;
+      for my $tag (@tags) {
+       my $error;
+       if ($tag =~ /\S/
+           && !BSE::TB::Tags->valid_name($tag, \$error)) {
+         $errors[$index] = "msg:bse/admin/edit/tags/invalid/$error";
+         $errors{"tags$image->{id}"} = \@errors;
+       }
+       ++$index;
+      }
+      unless (@errors) {
+       $changes{$id}{tags} = [ grep /\S/, @tags ];
+      }
+    }
+
     my $filename = $cgi->param("image$id");
     if (defined $filename && length $filename) {
       my $in_fh = $cgi->upload("image$id");
@@ -3208,8 +3278,13 @@ sub save_image_changes {
     if ($changes{$id}) {
       my $changes = $changes{$id};
       ++$changes_found;
-      
+
       for my $field (keys %$changes) {
+       my $tags = delete $changes->{$field};
+       if ($tags) {
+         my $error;
+         $image->set_tags($tags, \$error);
+       }
        $image->{$field} = $changes->{$field};
       }
       $image->save;
index 0a05d3d..df29c05 100644 (file)
@@ -8,7 +8,7 @@ use vars qw/@ISA/;
 @ISA = qw/Squirrel::Row BSE::ThumbCommon BSE::TB::TagOwner/;
 use Carp qw(confess);
 
-our $VERSION = "1.010";
+our $VERSION = "1.011";
 
 =head1 NAME
 
@@ -145,8 +145,7 @@ Returns HTML.
 sub inline {
   my ($self, %opts) = @_;
 
-  my $cfg = delete $opts{cfg}
-    or confess "Missing cfg parameter";
+  my $cfg = delete $opts{cfg} || BSE::Cfg->single;
 
   my $handler = $self->_handler_object($cfg);
 
index c47187e..653a83a 100644 (file)
@@ -140,6 +140,10 @@ table td {
   background-color: #FFF;
 }
 
+table td.col_tags {
+  vertical-align: top;
+}
+
 table tr.bad td {
   background-color: #FF8080;
 }
diff --git a/site/htdocs/js/admin_jedit.js b/site/htdocs/js/admin_jedit.js
new file mode 100644 (file)
index 0000000..2a6518a
--- /dev/null
@@ -0,0 +1,45 @@
+(function($) {
+    $(function() {
+       'use strict';
+       $.Mustache.options.warnOnMissingTemplates = true;
+       $.Mustache.addFromDom();
+       $(".tag").each(function() {
+           var closed = this;
+           var input = $("input", this);
+           var del = $($.Mustache.render("del_link", { }));
+           var del_click = $(".tag_delete_click", del);
+           if (del_click.length == 0)
+               del_click = del;
+           del_click.click(function () {
+               closed.remove();
+               return false;
+           });
+           input.after(del);
+       });
+       $(".tags").each(function() {
+           var tags = $(this);
+           var fname = this.dataset["name"];
+           if (!fname)
+               fname = "tag";
+           var add_div = $($.Mustache.render("add_link", {
+               fname: fname
+           }));
+           var add_a = $(".tag_add_click", add_div);
+           add_a.click(function() {
+               var div_text = $.Mustache.render("tag_field", {
+                   fname: fname
+               });
+               var div = $(div_text);
+               var del = $(".tag_delete_click", div);
+               del.click(function() {
+                   div.remove();
+                   return false;
+               });
+               add_div.before(div);
+
+               return false;
+           });
+           tags.append(add_div);
+       });
+    });
+})(jQuery);
\ No newline at end of file
diff --git a/site/htdocs/js/bse_apij.js b/site/htdocs/js/bse_apij.js
new file mode 100644 (file)
index 0000000..fc542c1
--- /dev/null
@@ -0,0 +1,1059 @@
+// requires jQuery, currently incomplete, still has some
+// prototype.js code
+
+// true to use the File API if available
+// TODO: progress reporting
+// TODO: start reporting
+// TODO: utf8 filenames
+// TODO: quotes in filenames(?)
+var bse_use_file_api = true;
+
+function BSEAPI(parameters) {
+    if (!parameters) parameters = {};
+    this.initialized = true;
+    this.onException = function(e) {
+       alert(e);
+    };
+    this.onFailure = function(error) {
+       alert(error.message);
+    };
+    this._load_csrfp(parameters);
+    this.onConfig = parameters.onConfig;
+    delete parameters.onConfig;
+    this._load_config(parameters);
+}
+
+(function() {
+    var $ = jQuery;
+BSEAPI.prototype = {
+    _load_csrfp: function (param) {
+       this.get_csrfp
+       ($.extend
+        ({
+            id: -1,
+            name: this._csrfp_names,
+            onSuccess: function(csrfp) {
+                this._csrfp = csrfp;
+                window.setTimeout(this._load_csrfp.bind(this), 600000);
+            }.bind(this),
+            onFailure: function() {
+                // ignore this
+                this._csrfp = null;
+            }
+        }, param));
+    },
+    _load_config: function(param) {
+       this.get_base_config
+       ($.extend
+        ({
+            onSuccess:function(conf) {
+                this.conf = conf;
+                if (this.onConfig)
+                    this.onConfig(conf);
+            }.bind(this),
+            onFailure: function(err) {
+            }
+        }, param));
+    },
+    // logon to the server
+    // logon - logon name of user
+    // password - password of user
+    // onSuccess - called on successful logon (no parameters)
+    // onFailure - called with an error object on failure.
+    logon: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("logon() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       if (parameters.logon == null)
+           this._badparm("logon() Missing logon parameter");
+       if (parameters.password == null)
+           this._badparm("logon() Missing password parameter");
+       new Ajax.Request('/cgi-bin/admin/logon.pl',
+                        {
+                            parameters: {
+                                a_logon: 1,
+                                logon: parameters.logon,
+                                password: parameters.password
+                            },
+                            onSuccess: function (success, failure, resp) {
+                                if (resp.responseJSON) {
+                                    if(resp.responseJSON.success != 0) {
+                                        this._load_csrfp();
+                                        success(resp.responseJSON.user);
+                                    }
+                                    else {
+                                        failure(this._wrap_json_failure(resp), resp);
+                                    }
+                                }
+                                else {
+                                    failure(this._wrap_nojson_failure(resp), resp);
+                                }
+                            }.bind(this, success, failure),
+                            onFailure: function (failure, resp) {
+                                failure(this._wrap_req_failure(resp), resp);
+                            }.bind(this, failure),
+                            onException: this.onException
+                        });
+    },
+    userinfo: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("logon() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       new Ajax.Request('/cgi-bin/admin/logon.pl',
+                        {
+                            parameters: {
+                                a_userinfo: 1
+                            },
+                            onSuccess: function (success, failure, resp) {
+                                if (resp.responseJSON) {
+                                    if(resp.responseJSON.success != 0) {
+                                        success(resp.responseJSON);
+                                    }
+                                    else {
+                                        failure(this._wrap_json_failure(resp), resp);
+                                    }
+                                }
+                                else {
+                                    failure(this._wrap_nojson_failure(resp), resp);
+                                }
+                            }.bind(this, success, failure),
+                            onFailure: function (failure, resp) {
+                                failure(this._wrap_req_failure(resp), resp);
+                            }.bind(this, failure),
+                            onException: this.onException
+                        });
+    },
+    logoff: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("logon() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       new Ajax.Request('/cgi-bin/admin/logon.pl',
+                        {
+                            parameters: {
+                                a_logoff: 1
+                            },
+                            onSuccess: function (success, failure, resp) {
+                                if (resp.responseJSON) {
+                                    if(resp.responseJSON.success != 0) {
+                                        success();
+                                    }
+                                    else {
+                                        failure(this._wrap_json_failure(resp), resp);
+                                    }
+                                }
+                                else {
+                                    failure(this._wrap_nojson_failure(resp), resp);
+                                }
+                            }.bind(this, success, failure),
+                            onFailure: function (failure, resp) {
+                                failure(this._wrap_req_failure(resp), resp);
+                            }.bind(this, failure),
+                            onException: this.onException
+                        });
+    },
+    change_password: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("change_password() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       new Ajax.Request('/cgi-bin/admin/changepw.pl',
+                        {
+                            parameters: {
+                                a_change: 1,
+                                oldpassword: parameters.oldpassword,
+                                newpassword: parameters.newpassword,
+                                confirm: parameters.newpassword
+                            },
+                            onSuccess: function (success, failure, resp) {
+                                if (resp.responseJSON) {
+                                    if(resp.responseJSON.success != 0) {
+                                        success();
+                                    }
+                                    else {
+                                        failure(this._wrap_json_failure(resp), resp);
+                                    }
+                                }
+                                else {
+                                    failure(this._wrap_nojson_failure(resp), resp);
+                                }
+                            }.bind(this, success, failure),
+                            onFailure: function (failure, resp) {
+                                failure(this._wrap_req_failure(resp), resp);
+                            }.bind(this, failure),
+                            onException: this.onException
+                        });
+    },
+    // fetch a tree of articles;
+    // id - parent of tree to fetch
+    // depth - optional depth of tree to fetch (default is large)
+    // onSuccess - called with tree on success
+    // onFailure - called with error object on failure
+    tree: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("tree() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       var req_parms = { id: -1, a_tree: 1 };
+       if (parameters.id)
+           req_parms.id = parameters.id;
+       if (parameters.depth)
+           req_parms.depth = parameters.depth;
+       new Ajax.Request('/cgi-bin/admin/add.pl',
+                        {
+                            parameters: req_parms,
+                            onSuccess: function(success, failure, resp) {
+                                if (resp.responseJSON) {
+                                    if (resp.responseJSON.success != 0) {
+                                        success(resp.responseJSON.articles);
+                                    }
+                                    else {
+                                        failure(this._wrap_json_failure(resp), resp);
+                                    }
+                                }
+                                else {
+                                    failure(this._wrap_nojson_failure(resp), resp);
+                                }
+                            }.bind(this, success, failure),
+                            onFailure: function(failure, resp) {
+                                failure(this._wrap_req_failure(resp), resp);
+                            }.bind(this, failure),
+                            onException: this.onException
+                        });
+    },
+    article: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("tree() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       if (parameters.id == null)
+           this._badparm("article() missing id parameter");
+       var req_parms = { a_article: 1, id: parameters.id };
+       new Ajax.Request('/cgi-bin/admin/add.pl',
+                        {
+                            parameters: req_parms,
+                            onSuccess: function(success, failure, resp) {
+                                if (resp.responseJSON) {
+                                    if (resp.responseJSON.success != 0) {
+                                        success(resp.responseJSON.article);
+                                    }
+                                    else {
+                                        failure(this._wrap_json_failure(resp), resp);
+                                    }
+                                }
+                                else {
+                                    failure(this._wrap_nojson_failure(resp), resp);
+                                }
+                            }.bind(this, success, failure),
+                            onFailure: function(failure, resp) {
+                                failure(this._wrap_req_failure(resp), resp);
+                            }.bind(this, failure),
+                            onException: this.onException
+                        });
+    },
+    // create a new article, accepts all article fields except id
+    new_article: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("tree() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       if (parameters.title == null)
+           this._badparm("new_article() missing title parameter");
+       if (parameters.parentid == null)
+           this._badparm("new_article() missing parentid parameter");
+       if (parameters.id != null)
+           this._badparm("new_article() can't accept an id parameter");
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       this._do_add_request("save", parameters,
+                            function(success, resp) {
+                                success(resp.article);
+                            }.bind(this, success),
+                            failure);
+    },
+    save_article: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("tree() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       if (parameters.id == null)
+           this._badparm("save_article() missing id parameter");
+       if (parameters.lastModified == null)
+           this._badparm("save_article() missing lastModified parameter");
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       this._do_add_request("save", parameters,
+                            function(success, result) {
+                                success(result.article);
+                            }.bind(this, success),
+                            failure);
+    },
+    get_base_config: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("get_config() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       this._do_api_request("a_config", parameters, success, failure);
+    },
+    get_config: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("get_config() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       if (parameters.id == null && parameters.parentid == null)
+            this._badparm("get_config() missing both id and parentid");
+       this._do_add_request("a_config", parameters, success, failure);
+    },
+    get_csrfp: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("get_csrfp() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       if (parameters.id == null && parameters.id == null)
+            this._badparm("get_csrfp() missing both id and parentid");
+       this._do_add_request("a_csrfp", parameters,
+                            function(success, result) {
+                                success(result.tokens);
+                            }.bind(this, success),
+                            failure);
+    },
+    get_file_progress: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("get_file_progress() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       if (parameters._upload == null)
+            this._badparm("get_file_progress() missing _upload");
+       this._do_request("/cgi-bin/fileprogress.pl", null, parameters,
+                        function(success, result) {
+                            success(result.progress);
+                        }.bind(this, success),
+                        failure);
+    },
+    thumb_link: function(im, geoid) {
+       return "/cgi-bin/thumb.pl?image="+im.id+"&g="+geoid+"&page="+im.articleId+"&f="+encodeURIComponent(im.image);
+    },
+    
+    // parameters:
+    //  image - file input element (required)
+    //  id - owner article of the new image (required)
+    //  name - name of the image to add (default: "")
+    //  alt - alt text for the image (default: "")
+    //  url - url for the image (default: "")
+    //  storage - storage for the image (default: auto)
+    //  onSuccess: called on success in adding the image, with the image object
+    //    (required)
+    //  onFailure: called on failure (optional)
+    //  onStart: called when the image upload starts (optional)
+    //  onComplete: called when the image upload is complete (success
+    //    or failure) (optional)
+    //  onProgress: called occasionally during the image upload with
+    //    the approximate amount sent and the total to be sent (optional)
+    add_image_file: function(parameters) {
+       parameters._csrfp = this._csrfp.admin_add_image;
+       var success = parameters.onSuccess;
+       parameters.onSuccess = function(success, result) {
+           success(result.image);
+       }.bind(this, success);
+       this._do_complex_request("/cgi-bin/admin/add.pl", "addimg", parameters);
+    },
+    save_image_file: function(parameters) {
+       parameters._csrfp = this._csrfp.admin_save_image;
+       var success = parameters.onSuccess;
+       parameters.onSuccess = function(success, result) {
+           success(result.image);
+       }.bind(this, success);
+       this._do_complex_request("/cgi-bin/admin/add.pl", "a_save_image", parameters);
+    },
+    remove_image_file: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("remove_image_file() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       var im = parameters.image;
+       if (!im) this._badparm("remove_image_file() missing image parameter");
+       this._do_add_request
+        (
+           "removeimg_"+im.id,
+           {
+               id: im.articleId
+           },
+           success, failure
+       );
+    },
+    images_set_order: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("remove_image_file() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       var id = parameters.id;
+       if (!id) this._badparm("images_set_order() missing id parameter");
+       var order = parameters.order.join(",");
+       this._do_add_request("a_order_images", { id: id, order: order }, success, failure);
+    },
+    
+    // Message catalog functions
+    message_catalog: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("remove_image_file() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       var extra = {};
+       if (parameters.tree) {
+           extra.tree = 1;
+       }
+       if (!failure) failure = this.onFailure;
+       this._do_request
+       (
+           "/cgi-bin/admin/messages.pl", "a_catalog", extra,
+           function(success, resp) {
+               success(resp.messages);
+           }.bind(this, success),
+           failure
+       );
+    },
+    message_detail: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("message_detail() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       var id = parameters.id;
+       if (id == null) this._badparm("message_detail() missing id parameter");
+       this._do_request
+       (
+           "/cgi-bin/admin/messages.pl", "a_detail", { id: id }, success, failure
+       );
+    },
+    // requires id, language_code, message
+    message_save: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("message_save() missing onSuccess parameter");
+       var my_success = function(success, resp) {
+           success(resp.definition);
+       }.bind(this, success);
+       delete parameters.success;
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       delete parameters.failure;
+       this._do_request("/cgi-bin/admin/messages.pl", "a_save", parameters,
+                        my_success, failure);
+    },
+    // requires id, language_code
+    message_delete: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("message_delete() missing onSuccess parameter");
+       delete parameters.success;
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       delete parameters.failure;
+       this._do_request("/cgi-bin/admin/messages.pl", "a_delete", parameters,
+                        success, failure);
+    },
+    
+    // requires name, value
+    set_state: function(parameters) {
+       var success = parameters.onSuccess || function() {};
+       var failure = parameters.onFailure || this.onFailure;
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       this._do_request("/cgi-bin/admin/menu.pl", "a_set_state", parameters, success, failure);
+    },
+    // requires name
+    get_state: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("get_state() missing onSuccess parameter");
+       var my_success = function(success, result) {
+           success(result.value);
+       }.bind(this, success);
+       var failure = parameters.onFailure || this.onFailure;
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       this._do_request("/cgi-bin/admin/menu.pl", "a_get_state", parameters, my_success, failure);
+    },
+    // requires name
+    delete_state: function(parameters) {
+       var success = parameters.onSuccess || function() {};
+       var failure = parameters.onFailure || this.onFailure;
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       this._do_request("/cgi-bin/admin/menu.pl", "a_delete_state", parameters, success, failure);
+    },
+    
+    // requires name, a prefix for the state entries we want
+    get_matching_state: function(parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("get_matching_state() missing onSuccess parameter");
+       var my_success = function(success, result) {
+           success(result.entries);
+       }.bind(this, success);
+       var failure = parameters.onFailure || this.onFailure;
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       this._do_request("/cgi-bin/admin/menu.pl", "a_get_matching_state", parameters, my_success, failure);
+       
+    },
+    
+    // requires name, a prefix for the state entries we want
+    delete_matching_state: function(parameters) {
+       var success = parameters.onSuccess || function() {};
+       var failure = parameters.onFailure || this.onFailure;
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       this._do_request("/cgi-bin/admin/menu.pl", "a_delete_matching_state", parameters, success, failure);
+       
+    },
+    
+    _progress_handler: function(parms) {
+       if (parms.finished) return;
+       this.get_file_progress(
+           {
+               _upload: parms.up_id,
+               onSuccess: function(parms, prog) {
+                   if (!parms.finished) {
+                       if (prog) {
+                           if (prog.total)
+                               parms.total = prog.total;
+                           parms.progress(prog);
+                       }
+                       parms.updates += 1;
+                       parms.timeout = window.setTimeout
+                       (this._progress_handler.bind(this, parms),
+                        parms.updates > 5 ? 6000 : 1500);
+                   }
+               }.bind(this, parms)
+           });
+    },
+    _hidden: function(name, value) {
+       var hidden = document.createElement("input");
+       hidden.type = "hidden";
+       hidden.name = name;
+       hidden.value = value;
+       
+       return hidden;
+    },
+    _wrap_json_failure: function(resp) {
+       return resp.responseJSON;
+    },
+    _wrap_nojson_failure: function(resp) {
+       return {
+           success: 0,
+           message: "Unexpected non-JSON response from server",
+           errors: {},
+           error_code: "NOTJSON"
+       };
+    },
+    _wrap_req_failure: function(resp) {
+       return {
+           success: 0,
+           message: "Server error requesing content: " + resp.statusText,
+           errors: {},
+           error_code: "SERVFAIL"
+       };
+    },
+    _badparm: function(msg) {
+       this.onException(msg);
+    },
+    _add_complex_item: function(form, key, val, clone) {
+       if (typeof(val) == "string" || typeof(val) == "number") {
+           form.appendChild(this._hidden(key, val));
+       }
+       else if (typeof(val) == "object") {
+           if (val.constructor == Array) {
+               for (var i = 0; i < val.length; ++i) {
+                   this._add_complex_item(form, key, val[i], clone);
+               }
+           }
+           else {
+               // presumed to be a file field
+               if (clone) {
+                   var cloned = val.cloneNode(true);
+                   val.parentNode.insertBefore(cloned, val);
+               }
+               val.name = key;
+               form.appendChild(val);
+           }
+       }
+    },
+    _populate_complex_form: function(form, req_parms, clone) {
+       for (var key in req_parms) {
+            this._add_complex_item(form, key, req_parms[key], clone);
+       }
+    },
+    // perform a request through an iframe
+    // parameters can contain:
+    // onSuccess: callback called on successful processs
+    // onFailure: called on failed processing
+    // onStart: called when the form is submitted
+    // onProgress: called occasionally with submission progres info
+    // onComplete: called on completion (before onSuccess/onFailure)
+    // clone: if true, clone any file objects supplied
+    //
+    // all other parameters are treated as form fields.
+    // if a value is an array, it is treated as multiple values for
+    // that field
+    //
+    // Bugs: should fallback to Ajax if there are no form fields
+    _do_complex_request: function(url, action, parameters) {
+       var success = parameters.onSuccess;
+       if (!success) this._badparm("tree() missing onSuccess parameter");
+       var failure = parameters.onFailure;
+       if (!failure) failure = this.onFailure;
+       var on_complete = parameters.onComplete;
+       var on_start = parameters.onStart;
+       var on_progress = parameters.onProgress;
+       var clone = parameters.clone;
+       
+       delete parameters.onSuccess;
+       delete parameters.onFailure;
+       delete parameters.onComplete;
+       delete parameters.onProgress;
+       delete parameters.onStart;
+       delete parameters.clone;
+       
+       // stuff we use in the callbacks
+       var parms =
+           {
+               success: success,
+               failure: failure,
+               start: on_start,
+               progress: on_progress,
+               complete: on_complete,
+               // track the number of progress updates done
+               updates: 0,
+               finished: 0
+           };
+       
+       parms.up_id = this._new_upload_id();
+       if (url.match(/\?/))
+           url += "&";
+       else
+           url += "?";
+       url += "_upload=" + parms.up_id;
+       
+       if (window.FormData) {
+           if (this._do_complex_formdata(url, action, parms, parameters))
+               return;
+       }
+       
+       if (window.FileReader && bse_use_file_api) {
+           if (this._do_complex_file_api(url, action, parms, parameters))
+               return;
+       }
+       
+       // setup the iframe
+       parms.ifr = new Element("iframe", {
+           src: "about:blank",
+           id: "bseiframe"+parms.up_id,
+           name: "bseiframe"+parms.up_id,
+           width: 400,
+           height: 100
+       });
+       parms.ifr.style.display = "none";
+       
+       // setup the form
+       var form = new Element
+       ("form",
+        {
+            method: "post",
+            action: url,
+            enctype: "multipart/form-data",
+            // the following for IE
+            encoding: "multipart/form-data",
+            id: "bseform"+parms.up_id,
+            target: "bseiframe"+parms.up_id
+        });
+       parms.form = form;
+       form.style.display = "none";
+       // _upload must come before anything large
+       //form.appendChild(this._hidden("_upload", parms.up_id));
+       form.appendChild(this._hidden("_",1));
+       this._populate_complex_form(form, parameters, clone);
+       // trigger BSE's alternative JSON return handling
+       form.appendChild(this._hidden("_", 1));
+       form.appendChild(this._hidden(action, 1));
+       
+       document.body.appendChild(parms.ifr);
+       document.body.appendChild(form);
+       var onLoad = function(parms) {
+           // we should get json back in the body
+            var ifr = parms.ifr;
+           var form = parms.form;
+           var text = Try.these(
+               function(ifr) {
+                   var text = ifr.contentDocument.body.textContent;
+                   ifr.contentDocument.close();
+                   return text;
+               }.bind(this, ifr),
+               function(ifr) {
+                   var text = ifr.contentWindow.document.body.innerText;
+                   ifr.contentWindow.document.close();
+                   return text;
+               }.bind(this, ifr)
+           );
+           var data;
+           eval("data = " + text + ";");
+           document.body.removeChild(ifr);
+           document.body.removeChild(form);
+            if (parms.progress != null && parms.total != null)
+               parms.progress({ done: parms.total, total: parms.total});
+           if (parms.complete != null)
+               parms.complete();
+           parms.finished = 1;
+           if (data != null) {
+               if (data.success != null && data.success != 0) {
+                   parms.success(data);
+               }
+               else {
+                   parms.failure(data);
+               }
+           }
+           else {
+               parms.failure(this._wrap_req_failure({statusText: "Unknown"}));
+           }
+       }.bind(this, parms);
+       if (window.attachEvent) {
+           parms.ifr.attachEvent("onload", onLoad);
+       }
+       else {
+           parms.ifr.addEventListener("load", onLoad, false);
+       }
+       
+       if (on_start != null)
+           on_start();
+       
+       if (on_progress != null) {
+           parms.timeout = window.setTimeout ( this._progress_handler.bind(this, parms), 200 );
+       }
+       
+       form.submit();
+    },
+    // flatten the parameters
+    _flat_parms: function(flat, key, val) {
+       if (typeof(val) == "string" || typeof(val) == "number") {
+           flat.push([ key, val, false ]);
+       }
+       else if (typeof(val) == "object") {
+           if (val.constructor == Array) {
+               for (var i = 0; i < val.length; ++i) {
+                   this._flat_parms(flat, key, val[i]);
+               }
+           }
+           else if (val.constructor == File) {
+               // File object from drag and drop
+               flat.push([key, val, true]);
+           }
+           else {
+               // this should handle File objects, not just elements
+               // or perhaps data transfer objects
+               // push the individual files if there's multiple
+               for (var i = 0; i < val.files.length; ++i) {
+                   flat.push([key, val.files[i], true]);
+               }
+           }
+       }
+    },
+    _file_progress_event: function(state, evt) {
+       if (evt.lengthComputable) {
+           var filename;
+           for (var i = 0; i < state.fileoffsets.length; ++i) {
+               if (evt.loaded > state.fileoffsets[i][0])
+                   filename = state.fileoffsets[i][1];
+           }
+           state.last_filename = filename;
+           state.progress
+           (
+               {
+                   done: evt.loaded,
+                   total: evt.total,
+                   filename: filename,
+                   complete: 0
+               }
+           );
+       }
+    },
+    _file_load_event: function(state, evt) {
+       if (evt.lengthComputable) {
+           state.progress
+           (
+               {
+                   done: evt.total,
+                   total: evt.total,
+                   filename: state.last_filename,
+                   complete: 1
+               }
+           );
+       }
+    },
+    _file_readystatechange_event: function(state, event) {
+       if (state.xhr.readyState == 4) {
+           if (state.complete)
+               state.complete();
+           if (state.xhr.status == 200) {
+               var data;
+               try {
+                   data = state.xhr.responseText.evalJSON(false);
+               } catch (e) {
+                   state.failure(this._wrap_nojson_failure(state.xhr));
+                   return;
+               }
+               
+               if (data.success != null && data.success != 0 ) {
+                   state.success(data);
+               }
+               else {
+                   state.failure(this._wrap_json_failure({ responseJSON: data}));
+               }
+           }
+           else {
+               state.failure(this._wrap_req_failure(state.xhr));
+           }
+       }
+    },
+    _build_api_req_data: function(state) {
+       while (state.index < state.flat.length) {
+           var entry = state.flat[state.index];
+           if (entry[2]) {
+               // file object
+               var fr  = new FileReader;
+               fr.addEventListener
+               ("loadend", function(state, fr, event) {
+                   var entry = state.flat[state.index];
+                   state.req_data += "--" + state.sep + "\r\n";
+                   // TODO: filenames with quotes
+                   state.fileoffsets.push([ state.req_data.length, entry[1].fileName]);
+                   state.req_data += "Content-Disposition: form-data; name=\"" + entry[0] + "\"; filename=\"" + this._encode_utf8(entry[1].fileName) + "\"\r\n\r\n";
+                   state.req_data += event.target.result + "\r\n";
+                   ++state.index;
+                   this._build_api_req_data(state);
+               }.bind(this, state, fr), false);
+               fr.readAsBinaryString(entry[1]);
+               return;
+           }
+           else {
+               // just plain data
+               state.req_data += "--" + state.sep;
+               state.req_data += "Content-Disposition: form-data; name=\"" + entry[0] + "\"\r\n\r\n";
+               state.req_data += this._encode_utf8(entry[1]) + "\r\n";
+               ++state.index;
+           }
+       }
+       
+       // everything should be state.req_data now
+       state.req_data += "--"  + state.sep + "--\r\n";
+       
+       state.xhr = new XMLHttpRequest();
+       if (state.start)
+           state.start();
+       if (state.progress && state.xhr.upload) {
+           state.xhr.upload.addEventListener
+           (
+               "progress",
+               this._file_progress_event.bind(this, state),
+               false
+           );
+           state.xhr.upload.addEventListener
+           (
+               "load",
+               this._file_load_event.bind(this, state),
+               false
+           );
+       }
+       state.xhr.open("POST", state.url, true);
+       state.xhr.onreadystatechange = this._file_readystatechange_event.bind(this, state);
+       state.xhr.setRequestHeader("Content-Type", "multipart/form-data; boundary="+state.sep);
+       state.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
+       state.xhr.sendAsBinary(state.req_data);
+    },
+    // use the HTML5 file API to perform the upload
+    _do_complex_file_api: function(url, action, state, req_parms) {
+       state.url = url;
+       //state.url = "/cgi-bin/dump.pl";
+       if (action != null)
+           req_parms[action] = 1;
+       state.sep = "x" + state.up_id + "x";
+       state.fileoffsets = new Array;
+       
+       // flatten the request parameters
+       var flat = new Array;
+       for (var key in req_parms) {
+            this._flat_parms(flat, key, req_parms[key]);
+       }
+       state.index = 0;
+       state.flat = flat;
+       state.req_data = '';
+       this._build_api_req_data(state);
+       // the rest happens elsewhere
+       
+       return true;
+    },
+    _do_complex_formdata: function(url, action, state, params) {
+       state.url = url;
+       if (action != null)
+           params[action] = 1;
+       
+       state.fileoffsets = new Array();
+       
+       var offset = 0;
+       var fd = new FormData();
+       for (var key in params) {
+           var val = params[key];
+           if (typeof(val) == "string" || typeof(val) == "number") {
+               fd.append(key, val);
+           }
+           else {
+               if (val.constructor == Array) {
+                   for (var i = 0; i < val.length; ++i) {
+                       fd.append(key, val);
+                   }
+               }
+               else if (val.constructor == File) {
+                   // file object
+                   fd.append(key, val);
+               }
+               else {
+                   // hopefully a file input
+                   for (var i = 0; i < val.files.length; ++i) {
+                       var file = val.files[i];
+                       state.fileoffsets.push([ offset, file.fileName ]);
+                       fd.append(key, file);
+                       offset += file.fileSize;
+                   }
+               }
+           }
+       }
+       
+       // FIXME: duplicate code (mostly)
+       state.xhr = new XMLHttpRequest();
+       if (state.start)
+           state.start();
+       if (state.progress && state.xhr.upload) {
+           state.xhr.upload.addEventListener
+           (
+               "progress",
+               this._file_progress_event.bind(this, state),
+               false
+           );
+           state.xhr.upload.addEventListener
+           (
+               "load",
+               this._file_load_event.bind(this, state),
+               false
+           );
+       }
+       state.xhr.open("POST", state.url, true);
+       state.xhr.onreadystatechange = this._file_readystatechange_event.bind(this, state);
+       // is this needed?
+       //state.xhr.setRequestHeader("Content-Type", "multipart/form-data; boundary="+state.sep);
+       state.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
+       state.xhr.send(fd);
+       
+       return true;
+    },
+    // in the future this might call a proxy
+    _do_add_request: function(action, other_parms, success, failure) {
+       this._do_request("/cgi-bin/admin/add.pl", action, other_parms, success, failure);
+    },
+    _do_api_request: function(action, other_parms, success, failure) {
+       this._do_request("/cgi-bin/api.pl", action, other_parms, success, failure);
+    },
+    _do_request: function(url, action, other_parms, success, failure) {
+       if (action != null)
+            other_parms[action] = 1;
+       var async = true;
+       if (other_parms.hasOwnProperty("_async")) {
+           async = other_parms._async;
+           delete other_parms._async;
+       }
+       $.ajax(
+           {
+               url: url,
+               async: async,
+               data: other_parms,
+               dataType: "json",
+               success: function (success, failure, resp) {
+                   if (resp) {
+                       if (resp.success != null && resp.success != 0) {
+                           success(resp);
+                       }
+                       else {
+                           failure(this._wrap_json_failure(resp), resp);
+                       }
+                   }
+                   else {
+                       failure(this._wrap_nojson_failure(resp), resp);
+                   }
+               }.bind(this, success, failure),
+               error: function(failure, resp) {
+                   failure(this._wrap_req_failure(resp), resp);
+               }.bind(this, failure)
+           });
+    },
+    _new_upload_id: function () {
+       this._upload_id += 1;
+       return new Date().valueOf() + "_" + this._upload_id;
+    },
+    _encode_utf8: function(str) {
+       return unescape(encodeURIComponent(str));
+    },
+    // we request these names on startup, on login
+    // and occasionally otherwise, to avoid them going stale
+    _csrfp_names:
+    [
+       "admin_add_image",
+       "admin_save_image"
+    ],
+    _upload_id: 0
+};
+
+BSEAPI.can_drag_and_drop = function() {
+    // hopefully they're implemented at the same time
+    if (window.FormData != null)
+       return true;
+    
+    if (bse_use_file_api && window.FileReader != null)
+       return true;
+    
+    return false;
+};
+
+BSEAPI.make_drop_zone = function(options) {
+    options.element.addEventListener
+    (
+       "dragenter",
+       function(options, e) {
+           e.stopPropagation();
+           e.preventDefault();
+       }.bind(this, options),
+       false
+    );
+    options.element.addEventListener
+    (
+       "dragover",
+       function(options, e) {
+           e.stopPropagation();
+           e.preventDefault();
+       }.bind(this, options),
+       false
+    );
+    options.element.addEventListener
+    (
+       "drop",
+       function(options, e) {
+           e.stopPropagation();
+           e.preventDefault();
+           
+           options.onDrop(e.dataTransfer.files);
+       }.bind(this, options),
+       false
+    );
+};
+})();
\ No newline at end of file
diff --git a/site/htdocs/js/jquery.mustache.js b/site/htdocs/js/jquery.mustache.js
new file mode 100644 (file)
index 0000000..eacc825
--- /dev/null
@@ -0,0 +1,210 @@
+/*global jQuery, window */
+(function ($, window) {
+       'use strict';
+
+       var templateMap = {},
+               instance = null,
+               options = {
+                       // Should an error be thrown if an attempt is made to render a non-existent template.  If false, the
+                       // operation will fail silently.
+                       warnOnMissingTemplates: false,
+
+                       // Should an error be thrown if an attempt is made to overwrite a template which has already been added.
+                       // If true the original template will be overwritten with the new value.
+                       allowOverwrite: true,
+
+                       // The 'type' attribute which you use to denoate a Mustache Template in the DOM; eg:
+                       // `<script type="text/html" id="my-template"></script>`
+                       domTemplateType: 'text/html',
+
+                       // Specifies the `dataType` attribute used when external templates are loaded.
+                       externalTemplateDataType: 'text'
+               };
+
+       function getMustache() {
+               // Lazily retrieve Mustache from the window global if it hasn't been defined by
+               // the User.
+               if (instance === null) {
+                       instance = window.Mustache;
+                       if (instance === void 0) {
+                               $.error("Failed to locate Mustache instance, are you sure it has been loaded?");
+                       }
+               }
+               return instance;
+       }
+
+       /**
+        * @return {boolean} if the supplied templateName has been added.
+        */
+       function has(templateName) {
+               return templateMap[templateName] !== void 0;
+       }
+
+       /**
+        * Registers a template so that it can be used by $.Mustache.
+        *
+        * @param templateName          A name which uniquely identifies this template.
+        * @param templateHtml          The HTML which makes us the template; this will be rendered by Mustache when render()
+        *                                                      is invoked.
+        * @throws                                      If options.allowOverwrite is false and the templateName has already been registered.
+        */
+       function add(templateName, templateHtml) {
+               if (!options.allowOverwrite && has(templateName)) {
+                       $.error('TemplateName: ' + templateName + ' is already mapped.');
+                       return;
+               }
+               templateMap[templateName] = $.trim(templateHtml);
+       }
+
+       /**
+        * Adds one or more tempaltes from the DOM using either the supplied templateElementIds or by retrieving all script
+        * tags of the 'domTemplateType'.  Templates added in this fashion will be registered with their elementId value.
+        *
+        * @param [...templateElementIds]       List of element id's present on the DOM which contain templates to be added; 
+        *                                                                      if none are supplied all script tags that are of the same type as the 
+        *                                                                      `options.domTemplateType` configuration value will be added.
+        */
+       function addFromDom() {
+               var templateElementIds;
+
+               // If no args are supplied, all script blocks will be read from the document.
+               if (arguments.length === 0) {
+                       templateElementIds = $('script[type="' + options.domTemplateType + '"]').map(function () {
+                               return this.id;
+                       });
+               }
+               else {
+                       templateElementIds = $.makeArray(arguments);
+               }
+
+               $.each(templateElementIds, function() {
+                       var templateElement = document.getElementById(this);
+
+                       if (templateElement === null) {
+                               $.error('No such elementId: #' + this);
+                       }
+                       else {
+                               add(this, $(templateElement).html());
+                       }
+               });
+       }
+
+       /**
+        * Removes a template, the contents of the removed Template will be returned.
+        *
+        * @param templateName          The name of the previously registered Mustache template that you wish to remove.
+        * @returns                                     String which represents the raw content of the template.
+        */
+       function remove(templateName) {
+               var result = templateMap[templateName];
+               delete templateMap[templateName];
+               return result;
+       }
+
+       /**
+        * Removes all templates and tells Mustache to flush its cache.
+        */
+       function clear() {
+               templateMap = {};
+               getMustache().clearCache();
+       }
+
+       /**
+        * Renders a previously added Mustache template using the supplied templateData object.  Note if the supplied
+        * templateName doesn't exist an empty String will be returned.
+        */
+       function render(templateName, templateData) {
+               if (!has(templateName)) {
+                       if (options.warnOnMissingTemplates) {
+                               $.error('No template registered for: ' + templateName);
+                       }
+                       return '';
+               }
+               return getMustache().to_html(templateMap[templateName], templateData, templateMap);
+       }
+
+       /**
+        * Loads the external Mustache templates located at the supplied URL and registers them for later use.  This method
+        * returns a jQuery Promise and also support an `onComplete` callback.
+        *
+        * @param url                   URL of the external Mustache template file to load.
+        * @param onComplete    Optional callback function which will be invoked when the templates from the supplied URL
+        *                                              have been loaded and are ready for use.
+        * @returns                             jQuery deferred promise which will complete when the templates have been loaded and are
+        *                                              ready for use.
+        */
+       function load(url, onComplete) {
+               return $.ajax({
+                               url: url,
+                               dataType: options.externalTemplateDataType
+                       }).done(function (templates) {
+                               $(templates).filter('script').each(function (i, el) {
+                                       add(el.id, $(el).html());
+                               });
+
+                               if ($.isFunction(onComplete)) {
+                                       onComplete();
+                               }
+                       });
+       }
+
+       /**
+        * Returns an Array of templateNames which have been registered and can be retrieved via
+        * $.Mustache.render() or $(element).mustache().
+        */
+       function templates() {
+               return $.map(templateMap, function (value, key) {
+                       return key;
+               });
+       }
+
+       // Expose the public methods on jQuery.Mustache
+       $.Mustache = {
+               options: options,
+               load: load,
+               has: has,
+               add: add,
+               addFromDom: addFromDom,
+               remove: remove,
+               clear: clear,
+               render: render,
+               templates: templates,
+               instance: instance
+       };
+
+       /**
+        * Renders one or more viewModels into the current jQuery element.
+        *
+        * @param templateName  The name of the Mustache template you wish to render, Note that the
+        *                                              template must have been previously loaded and / or added.
+        * @param templateData  One or more JavaScript objects which will be used to render the Mustache
+        *                                              template.
+        * @param options.method        jQuery method to use when rendering, defaults to 'append'.
+        */
+       $.fn.mustache = function (templateName, templateData, options) {
+               var settings = $.extend({
+                       method: 'append'
+               }, options);
+
+               var renderTemplate = function (obj, viewModel) {
+                       $(obj)[settings.method](render(templateName, viewModel));
+               };
+
+               return this.each(function () {
+                       var element = this;
+
+                       // Render a collection of viewModels.
+                       if ($.isArray(templateData)) {
+                               $.each(templateData, function () {
+                                       renderTemplate(element, this);
+                               });
+                       }
+
+                       // Render a single viewModel.
+                       else {
+                               renderTemplate(element, templateData);
+                       }
+               });
+       };
+
+}(jQuery, window));
\ No newline at end of file
index e17b97b..bdb173c 100644 (file)
@@ -1,29 +1,56 @@
-<:wrap admin/base.tmpl title=>"Image Wizard", js => "admin_edit.js" :>
-<h1><:ifEq [article id] "-1":>Global<:or:><: articleType :><:eif:> Image Wizard</h1>
-<:ifMessage:> 
-<p><b><:message:> </b></p>
-<:or:><:eif:>
-<p>| <a href="/cgi-bin/admin/menu.pl">Admin menu</a> | 
-<:switch:>
-<:case Match [article generator] "Product":><a href="/cgi-bin/admin/add.pl?id=<:article id:>">Edit
-    product</a> | <a href="/cgi-bin/admin/shopadmin.pl">Manage catalogs</a>
-<:case Eq [article id] [cfg articles shop]:><a href="/cgi-bin/admin/add.pl?id=<:article id:>">Edit shop</a>
-<:case Eq [article id] "-1":><a href="/cgi-bin/admin/add.pl?id=<:article id:>">Edit sections</a>
-<:case default:><a href="/cgi-bin/admin/add.pl?id=<:article id:>"><:ifMatch
-    [article generator] "Catalog":>Edit catalog<:or:>Edit article<:eif:></a>
-<:endswitch:>
-|
-<:if Thumbs:><a href="<:script:>?id=<:article id:>&amp;_t=img&amp;f_showfull=1">Full size images</a> |<:or Thumbs:><:ifCanThumbs:><a href="<:script:>?id=<:article id:>&amp;_t=img">With Thumbnails</a> |<:or:><:eif:><:eif Thumbs:>
+<:.set article_type = cfg.entry("level names", article.level, "Article") -:>
+<:.define image_move:>
+<:-.if images.size > 1 -:>
+<:.set up_url = loop.is_first ? ""
+  : cfg.admin_url2("add", "moveimgup", 
+                   { id: article.id,
+                     imageid: image.id,
+                    _t: "img",
+                    _csrfp: request.get_csrf_token("admin_move_image")
+                  }) -:>
+<:.set down_url = loop.is_last ? ""
+  : cfg.admin_url2("add", "moveimgdown", 
+                   { id: article.id,
+                     imageid: image.id,
+                    _t: "img",
+                    _csrfp: request.get_csrf_token("admin_move_image")
+                  }) -:>
+<:.call "make_arrows", down_url:down_url, up_url: up_url -:>
+<:-.end if -:>
+<:.end define:>
+<:.wrap "admin/basej.tmpl", title:"Image Wizard", js:"admin_jedit.js" :>
+<h1><:.if article.id == -1:>Global<:.else:><:= article_type :><:.end if:> Image Wizard</h1>
+
+<:.call "messages":>
+
+<p>| <a href="<:= cfg.admin_url("menu") :>">Admin menu</a> | 
+<:.if article.generator =~ /Product/ :>
+  <a href="<:= cfg.admin_url("add", { id: article.id }) :>">Edit product</a> |
+  <a href="<:= cfg.admin_url("shopadmin") :>">Manage catalogs</a> |
+<:.elsif article.id == cfg.entry("articles", "shop", 3) -:>
+  <a href="/cgi-bin/admin/add.pl?id=<:article id:>">Edit shop</a> |
+<:.elsif article.id == -1 -:>
+  <a href="/cgi-bin/admin/add.pl?id=<:article id:>">Edit sections</a> |
+<:.else -:>
+<a href="/cgi-bin/admin/add.pl?id=<:article id:>"><:.if article.generator =~ /Catalog/:>Edit catalog<:.else:>Edit article<:.end if:></a> |
+<:.end if :>
+
+<:.if thumbs -:>
+  <a href="<:= cfg.admin_url("add", {id:article.id, "_t": "img", f_showfull:1}) :>">Full size images</a> |
+<:.else -:>
+  <:.if can_thumbs -:>
+  <a href="<:= cfg.admin_url("add", {id:article.id, _t: "img"}):>">With Thumbnails</a> |
+  <:.end if :>
+<:.end if:>
 </p>
-<:if UserCan edit_images_add:article:>
+<:.if request.user_can("edit_images_add", article) -:>
 <h2>Add new image</h2>
 
 <form method="post" action="<:script:>" enctype="multipart/form-data" name="add">
 
-<input type="hidden" name="level" value="<: level :>" />
-<input type="hidden" name="id" value="<: article id :>" />
-<input type="hidden" name="parentid" value="<: article parentid :>" />
-<input type="hidden" name="imgtype" value="<: articleType :>" />
+<input type="hidden" name="level" value="<:= article.level :>" />
+<input type="hidden" name="id" value="<:= article.id :>" />
+<input type="hidden" name="parentid" value="<:= article.parentid :>" />
 <input type="hidden" name="_t" value="img" />
 <:csrfp admin_add_image hidden:>
         <table>
             <td> 
               <input type="file" name="image" /> 
             </td>
-            <td class="help"><:help image file:> <:error_img image:></td>
+            <td class="help"><:help image file:> <:.call "error_img", field: "image":></td>
           </tr>
           <tr> 
             <th>Alt text for image:</th>
             <td> 
-              <input type="text" name="altIn" value="<:old altIn:>" />
+              <input type="text" name="altIn" value="<:.call "old", field:"altIn":>" />
             </td>
-            <td class="help"><:help image alt:> <:error_img altIn:></td>
+            <td class="help"><:help image alt:> <:.call "error_img", field: "altIn":></td>
           </tr>
           <tr> 
             <th>URL for image:</th>
             <td> 
-              <input type="text" name="url" value="<:old url:>" />
+              <input type="text" name="url" value="<:.call "old", field: "url":>" />
             </td>
-            <td class="help"><:help image url:> <:error_img url:></td>
+            <td class="help"><:help image url:> <:.call "error_img", field: "url":></td>
           </tr>
           <tr> 
             <th>Identifier for image:</th>
             <td> 
-              <input type="text" name="name" value="<:old name:>" />
+              <input type="text" name="name" value="<:.call "old", field: "name":>" />
             </td>
-            <td class="help"><:help image name:> <:error_img name:></td>
+            <td class="help"><:help image name:> <:.call "error_img", field: "name":></td>
           </tr>
   <tr>
     <th>Tags</th>
     <td>
       <input type="hidden" name="_save_tags" value="1" />
-      <div id="tags">
+      <div class="tags">
       <:- .set tags = [ cgi.param("tags") ] :>
       <:- .if tags.size == 0 :>
         <:% tags.push("") :>
       <:- .end if :>
       <:.for tag in tags :>
-        <div class="tag"><input type="text" name="tags" value="<:= tag :>" /><:.call "error_img_n", "field":"tags", "index":loop.index :></div>
+        <div class="tag"><input type="text" name="tags" value="<:= tag :>" /><:.call "error_img_n", field:"tags", index:loop.index :></div>
       <:.end for:>
       </div>
     </td>
           </tr>
         </table>
 </form>
-<:or UserCan:><:eif UserCan:>
+<:.end if -:>
 
-<form method="post" action="<:script:>" enctype="multipart/form-data" name="manage">
-<input type="hidden" name="level" value="<: level :>" />
-<input type="hidden" name="id" value="<: article id :>" />
-<input type="hidden" name="parentid" value="<: article parentid :>" />
-<input type="hidden" name="imgtype" value="<: articleType :>" />
+<:.set images = [ article.images ] -:>
+<:.set can_save = request.user_can("edit_images_save", article) -:>
+<:.set can_delete = request.user_can("edit_images_delete", article) -:>
+<:.set delete_token = request.get_csrf_token("admin_remove_image") -:>
+
+<form method="post" action="<:= cfg.admin_url("add") :>" enctype="multipart/form-data" name="manage">
+<input type="hidden" name="level" value="<:= article.level :>" />
+<input type="hidden" name="id" value="<:= article.id :>" />
+<input type="hidden" name="parentid" value="<:= article.parentid :>" />
 <input type="hidden" name="_t" value="img" />
 <:csrfp admin_save_images hidden:>
   <h2>Manage images</h2>
-
         <table class="editform images">
-          <:if Images:><:if Eq [article id] "-1":><:or Eq:><:if Cfg basic auto_images 1:><tr> 
-            <th colspan="5">First Image Position</th>
+<:.if images.size:><:.if article.id != -1
+  and cfg.entry("basic", "auto_images", 1)-:>
+<tr> 
+            <th colspan="6">First Image Position</th>
           </tr>
                  <tr> 
-            <td colspan="5"> 
-<input type="radio" name="imagePos" value="tl" <: ifEq [article imagePos] "tl":>checked<:eif:> />Top Left &nbsp;
-<input type="radio" name="imagePos" value="tr"  <: ifEq [article imagePos] "tr":>checked<:eif:> />Top Right &nbsp;
-<input type="radio" name="imagePos" value="bl"  <: ifEq [article imagePos] "bl":>checked<:eif:> />Bottom Left &nbsp;
-<input type="radio" name="imagePos" value="br"  <: ifEq [article imagePos] "br":>checked<:eif:> />Bottom Right
-<input type="radio" name="imagePos" value="xx"  <: ifEq [article imagePos] "xx":>checked<:eif:> />Don't automatically insert images
+            <td colspan="6"> 
+<input type="radio" name="imagePos" value="tl" <:= article.imagePos eq "tl" ? "checked " : "":>/>Top Left &nbsp;
+<input type="radio" name="imagePos" value="tr" <:= article.imagePos eq "tr" ? "checked " : "":>/>Top Right &nbsp;
+<input type="radio" name="imagePos" value="bl" <:= article.imagePos eq "bl" ? "checked " : "":>/>Bottom Left &nbsp;
+<input type="radio" name="imagePos" value="br" <:= article.imagePos eq "br" ? "checked " : "":>/>Bottom Right
+<input type="radio" name="imagePos" value="xx" <:= article.imagePos eq "xx" ? "checked " : "":>/>Don't automatically insert images
 
 <:help image position:>
 
            </td>
-          </tr><:eif Cfg:><:eif Eq:>
-<:if Thumbs:>
+          </tr>
+<:-.end if:>
+<:.if thumbs:>
           <tr> 
             <th>Image</th>
             <th colspan="2"> &nbsp;</th>
+           <th class="col_tags">Tags</th>
             <th class="col_modify"> Modify</th>
            <th class="col_move"> Move</th>
           </tr>
-<:iterator begin images:>
+<:.for image in images:>
        <tr>
-          <td rowspan="5" class="col_thumbnail"><a href="#" onclick="window.open('<:image src:>', 'fullimage', 'width=<:arithmetic [image width]+20:>,height=<:arithmetic [image height] + 30:>,location=no,status=no,menubar=no,scrollbars=yes'); return false;"><:thumbimage editor:></a></td>
+          <td rowspan="5" class="col_thumbnail"><a href="#" onclick="window.open('<:= image.src:>', 'fullimage', 'width=<:= 20 + image.width:>,height=<:= 30 + image.height:>,location=no,status=no,menubar=no,scrollbars=yes'); return false;"><:= image.thumb("geo", "editor") |raw:></a></td>
            <th>Alt text:</th>
             <td class="col_field"> 
-              <:ifUserCan edit_images_save:article:><input type="text" name="alt<:image id:>" value="<: oldi [concatenate alt [image id] ] 0 image alt :>" size="32" /><:or:><: image alt :><:eif:>
+              <:.if can_save:><input type="text" name="alt<:= image.id:>" value="<:.call "old", field:"alt" _ image.id, default: image.alt :>" size="32" /><:.else:><:= image.alt :><:.end if:>
             </td>
+           <td class="col_tags" rowspan="5">
+           <input type="hidden" name="_save_image_tags<:= image.id:>" value="1">
+           <div class="tags" data-name="tags<:= image.id :>">
+             <:.if cgi.param("_save_image_tags" _ image.id) -:>
+               <:.set image_tags = [ cgi.param("tags" _ image.id) ] -:>
+             <:.else -:>
+               <:.set image_tags = [ image.tags ] -:>
+               <:% image_tags.push("") -:>
+             <:.end if -:>
+             <:.for tag in image_tags :>
+             <div class="tag"><input type="text" name="tags<:= image.id :>" value="<:= tag :>"><:.call "error_img_n", field:"tags" _ image.id, index: loop.index :></div>
+             <:.end for :>
+           </div>
+           </td>
             <td class="col_modify" rowspan="5"> 
-              <:ifUserCan edit_images_delete:article:><b><a href="<:script:>?id=<:article id:>&amp;removeimg_<: image id :>=1&amp;_t=img&amp;_csrfp=<:csrfp admin_remove_image:>" onClick="return window.confirm('Are you sure you want to delete this Image')">Delete</a></b><:or:><:eif:>
-<:ifUserCan edit_images_save:article:><a href="<:script:>?a_edit_image=1&amp;id=<:article id:>&amp;image_id=<: image id :>">Edit</a><:or:><:eif:></td>
-            <td class="col_move" rowspan="5"><:imgmove:></td>
+<:.if can_delete -:>
+  <b><a href="<:= cfg.admin_url("add", { id:article.id, "removeimg_" _ image.id:1, _t:"img", _csrfp: delete_token}):>" onClick="return window.confirm('Are you sure you want to delete this Image')">Delete</a></b>
+<:-.end if:>
+<:.if can_save -:>
+<a href="<:= cfg.admin_url2("add", "edit_image", { id:article.id, image_id:image. id}):>">Edit</a>
+<:-.end if-:>
+</td>
+            <td class="col_move" rowspan="5">
+<:.call "image_move":></td>
          </tr>
          <tr>        
             <th>URL:</th>
             <td class="col_field"> 
-              <:ifUserCan edit_images_save:article:><input type="text" name="url<:image id:>" value="<: oldi [concatenate url [image id] ] 0 image url :>" size="32" /><:or:><: image url :><:eif:>
+              <:.if can_save :><input type="text" name="url<:= image.id :>" value="<:.call "old", field:"url" _ image.id, default:image.url :>" size="32" /><:.else:><:= image.url :><:.end if:>
             </td>
           </tr>
           <tr>
            <th>Identifier:</th>
             <td class="col_field"> 
-              <:ifUserCan edit_images_save:article:><input type="text" name="name<:image id:>" value="<: oldi [concatenate name [image id] ] 0 image name :>" size="32" /> <:error_img [concatenate "name" [image id] ]:><:or:><: image name :><:eif:>
+              <:.if can_save :><input type="text" name="name<:= image.id:>" value="<:.call "old", field: "name" _ image.id, default: image.name :>" size="32" /> <:.call "error_img", field: "name" _ image.id :><:.else:><:= image.name :><:.end if:>
             </td>
         </tr>
           <tr>
            <th>Image file:</th>
             <td class="col_field"> 
-              <:ifUserCan edit_images_save:article:><input type="file" name="image<:image id:>" size="32" /> <:error_img [concatenate "image" [image id] ]:><:or:><: image displayName :><:eif:>
+              <:.if can_save :><input type="file" name="image<:= image.id:>" size="32" /> <:.call "error_img", field: "image" _ image.id:><:.else:><:= image.displayName :><:.end if:>
             </td>
         </tr>
           <tr>
            <th>Stored:</th>
             <td class="col_field"> 
-              <:ifUserCan edit_images_save:article:><select name="storage<:image id:>">
+              <:.if can_save -:>
+<:.set stores = [ image_stores() ] -:>
+<:.set oldstore = cgi.param("storage").defined
+       ? cgi.param("storage") : image.storage -:>
+
+<select name="storage<:= image.id:>">
 <option value="">(Auto)</option>
-<:iterator begin image_stores:>
-<option value="<:image_store name:>" <:ifEq [oldi [concatenate storage [image id] ] 0 image storage] [image_store name]:>selected="selected"<:or:><:eif:>><:image_store description:></option>
-<:iterator end image_stores:>
-</select><:error_img [concatenate "storage" [image id] ]:><:or:><: image storage :><:eif:>
+<:.for store in stores :>
+<option value="<:= store.name:>"<:= oldstore eq store.name ? " selected" : "" :>><:= store.description:></option>
+<:.end for -:>
+</select><:.call "error_img", field: "storage" _ image.id:><:.else:><:= image.storage :><:.end if:>
             </td>
         </tr>
-<:iterator end images:>          
-<:or Thumbs:>
+<:.end for:>          
+<:.else :>
           <tr> 
             <th colspan="5">Image</th>
           </tr>
-          <: iterator begin images :> 
+          <:.for image in images :> 
           <tr> 
-            <td class="col_image" colspan="5"><:image:></td>
+            <td class="col_image" colspan="5"><:= image.inline("align", "center") |raw:></td>
           </tr>
           <tr> 
             <th> Alt Text</th>
           </tr>
           <tr> 
             <td> 
-              <:ifUserCan edit_images_save:article:><input type="text" name="alt<:image id:>" value="<: oldi [concatenate alt [image id] ] 0 image alt :>" size="32" /><:or:><: image alt :><:eif:>
+              <:.if can_save:><input type="text" name="alt<:= image.id:>" value="<:.call "old", field: "alt" _ image.id, default: image.alt:>" size="32" /><:.else:><:= image.alt :><:.end if:>
             </td>
             <td class="col_url"> 
-              <:ifUserCan edit_images_save:article:><input type="text" name="url<:image id:>" value="<: oldi [concatenate url [image id] ] 0 image url :>" size="32" /><:or:><: image url :><:eif:>
+              <:.if can_save:><input type="text" name="url<:=image.id:>" value="<:.call "old", field: "url" _ image.id, default: image.url :>" size="32" /><:.else:><:= image.url :><:.end if:>
             </td>
             <td class="col_identifier"> 
-              <:ifUserCan edit_images_save:article:><input type="text" name="name<:image id:>" value="<: oldi [concatenate name [image id] ] 0 image name :>" size="32" /> <:error_img [concatenate "name" [image id] ]:><:or:><: image name :><:eif:>
+              <:.if can_save:><input type="text" name="name<:= image.id:>" value="<:.call "old", field: "name" _ image.id, default: image.name:>" size="32" /> <:.call "error_img", field: "name" _ image.id :><:.else:><:= image.name :><:.end if:>
             </td>
             <td class="col_modify"> 
-              <:ifUserCan edit_images_delete:article:><b><a href="<:script:>?id=<:article id:>&amp;removeimg_<: image id :>=1&amp;_t=img&amp;_csrfp=<:csrfp admin_remove_image:>" onClick="return window.confirm('Are you sure you want to delete this Image')">Delete</a></b><:or:><:eif:></td>
-            <td class="col_move"><:imgmove:></td>
+              <:.if can_save:><b><a href="<:= cfg.admin_url("add", { id:article.id, "removeimg_" _ image.id:1, _t:"img", _csrfp: delete_token}):>" onClick="return window.confirm('Are you sure you want to delete this Image')">Delete</a></b><:.end if:></td>
+            <td class="col_move"><:.call "image_move":></td>
           </tr>
-          <: iterator separator images :> 
+          <:.if !loop.is_last :> 
           <tr> 
             <td colspan="5">&nbsp;</td>
           </tr>
-          <: iterator end images :> 
-<:eif Thumbs:>
-<:ifUserCan edit_images_save:article:>
+         <:.end if:>
+          <: .end for :> 
+<:.end if:>
+<:.if can_save:>
           <tr> 
-            <td colspan="5" class="buttons"> 
+            <td colspan="6" class="buttons"> 
               <input type="submit" name="process" value="Save changes" />
             </td>
          </tr>
-<:or:><:eif:>
-                 <:or Images:><tr><td colspan="5" align="center" bgcolor="#FFFFFF"><:if Eq [article id] "-1":>There are no global images<:or Eq:>No images
-                     are attached to this article<:eif Eq:></td>
-          </tr><:eif Images:>
+<:.end if:>
+<:.else-:>
+  <tr><td colspan="5" align="center" bgcolor="#FFFFFF">
+  <:-= article.id == -1 ? "There are no global images"
+                        : "No images are attached to this article" :></td>
+          </tr><:.end if:>
         </table>
 
-  <p>
-    <input type="submit" name="back" value=" &lt;&lt;  Back  " />
-  </p>
-
 </form>
+<script type="text/html" id="add_link">
+<div class="tag_add"><a href="#" class="tag_add_click">Add</a></div>
+</script>
+<script type="text/html" id="tag_field">
+<div class="tag"><input type="text" name="{{fname}}"><a href="#" class="tag_delete_click">Delete</a></div>
+</script>
+<script type="text/html" id="del_link">
+<a href="#" class="tag_delete_click">Delete</a>
+</script>
diff --git a/site/templates/admin/basej.tmpl b/site/templates/admin/basej.tmpl
new file mode 100644 (file)
index 0000000..ee9e71f
--- /dev/null
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html lang="<:= request.language | html -:>">
+  <head>
+    <title><:= params.title :> - BSE</title>
+    <link rel="stylesheet" href="/css/admin.css" type="text/css" />
+<:.if params.css -:>
+  <link rel="stylesheet" href="/css/<:= params.css :>" type="text/css" />
+<:- .end if:>
+<:ajax jquery:>
+<:.if params.api -:>
+  <script type="text/javascript" src="/js/bse_apij.js"></script>
+<:.end if -:>   
+<:.if params.js -:>
+  <script type="text/javascript" src="/js/<:= params.js:>"></script>
+<:.end if -:>
+  </head>
+  <body<:.if params.bodyid :> id="<:= params.bodyid :>"<:.end if:>>
+<:.if params.showtitle :>
+<h1><:= params.title :></h1>
+<:.end if:>
+<:wrap here:>
+  </body>
+</html>
index 6fb9882..925d8d7 100644 (file)
@@ -106,10 +106,11 @@ Page <:= pages.page :> of <:= pages.pagecount :>
 </div>
 <:-.end define -:>
 
-<:.define old -:>
-<:# parameters: field, default -:>
-<:  .if cgi.param(field).defined -:>
-<:= cgi.param(field) -:>
+<:.define old; default: "", index: 0 -:>
+<:# parameters: field, default, index  -:>
+<:  .set vals = [ cgi.param(field) ] -:>
+<:  .if index < vals.size -:>
+<:= vals[index] -:>
 <:  .else -:>
 <:= default | html -:>
 <:  .end if -:>