diff --git a/blocks/ORB/New.pm b/blocks/ORB/New.pm
new file mode 100644
index 0000000..4f2ed2a
--- /dev/null
+++ b/blocks/ORB/New.pm
@@ -0,0 +1,279 @@
+## @file
+# This file contains the implementation of the new page.
+#
+# @author Chris Page <chris@starforge.co.uk>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+
+## @class
+package ORB::New;
+
+use strict;
+use parent qw(ORB); # This class extends the ORB block class
+use experimental qw(smartmatch);
+use v5.14;
+
+# How many ingredient rows should appear in the empty form?
+use constant DEFAULT_INGREDIENT_COUNT => 5;
+
+
+## @method private $ _build_timereq($seconds)
+# Given a time requirement in seconds, generate a string representing
+# the time required in the form "X days, Y hours and Z minues",
+# optionally dropping parts of the string depending on whether
+# X, Y, or Z are zero.
+#
+# @param seconds The number of seconds required to make the recipe.
+# @return A string representing the seconds.
+sub _build_timereq {
+ my $self = shift;
+ my $seconds = shift;
+
+ my $days = int($seconds / (24 * 60 * 60));
+ my $hours = ($seconds / (60 * 60)) % 24;
+ my $mins = ($seconds / 60) % 60;
+
+ # localisation needed...
+ my @parts = ();
+ push(@parts, "$days days") if($days);
+ push(@parts, "$hours hours") if($hours);
+ push(@parts, "$mins minutes") if($mins);
+
+ my $count = scalar(@parts);
+ if($count == 3) {
+ return $parts[0].", ".$parts[1]." and ".$parts[2];
+ } elsif($count == 2) {
+ return $parts[0]." and ".$parts[1];
+ } elsif($count == 1) {
+ return $parts[0];
+ }
+
+ return "";
+}
+
+
+sub _build_temptypes {
+ my $self = shift;
+ my $default = shift;
+
+ # Supported types are in the column enum list
+ my $tempenum = $self -> get_enum_values($self -> {"settings"} -> {"database"} -> {"recipes"}, "temptype");
+ return $tempenum
+ unless(ref($tempenum) eq "ARRAY");
+
+ # convert to something build_optionlist will understand
+ map { $_ = { name => $_, value => $_ } } @{$tempenum};
+
+ return $self -> {"template"} -> build_optionlist($tempenum, $default);
+}
+
+
+sub _get_units {
+ my $self = shift;
+
+ return $self -> {"units"}
+ if($self -> {"units"});
+
+ $self -> {"units"} = [
+ { value => "None", name => "None" },
+ @{ $self -> {"system"} -> {"entities"} -> {"units"} -> as_options(1) }
+ ];
+
+ return $self -> {"units"};
+}
+
+
+sub _build_ingredients {
+ my $self = shift;
+ my $args = shift;
+ my $units = shift;
+ my $preps = shift;
+
+ my @ingreds = ();
+
+ # If any ingredients are present in the argument list, push them into templated strings
+ if($args -> {"ingredients"} && scalar(@{$args -> {"ingredients"}})) {
+ foreach my $ingred (@{$args -> {"ingredients"}}) {
+ # Ensure we never try to deal with undef elements in the array
+ next unless($ingred);
+
+ # Which template to use depends on whether this is a separator
+ my $template = $ingred -> {"separator"} ? "new/separator.tem" : "new/ingredient.tem";
+
+ my $unitopts = $self -> {"template"} -> build_optionlist($units, $args -> {"units"});
+ my $prepopts = $self -> {"template"} -> build_optionlist($preps, $args -> {"prep"});
+
+ push(@ingreds,
+ $self -> {"template"} -> load_template($template,
+ { "%(quantity)s" => $ingred -> {"quantity"},
+ "%(name)s" => $ingred -> {"name"},
+ "%(notes)s" => $ingred -> {"notes"},
+ "%(units)s" => $unitopts,
+ "%(preps)s" => $prepopts,
+ }));
+ }
+
+ # if the ingredient list is empty, generate some empties
+ } else {
+ # Only need to calculate these once for the empty ingredients
+ my $unitopts = $self -> {"template"} -> build_optionlist($units);
+ my $prepopts = $self -> {"template"} -> build_optionlist($preps);
+
+ for(my $i = 0; $i < DEFAULT_INGREDIENT_COUNT; ++$i) {
+ push(@ingreds,
+ $self -> {"template"} -> load_template("new/ingredient.tem",
+ { "%(quantity)s" => "",
+ "%(name)s" => "",
+ "%(notes)s" => "",
+ "%(units)s" => $unitopts,
+ "%(preps)s" => $prepopts,
+ }));
+ }
+ }
+
+ return join("", @ingreds);
+}
+
+
+sub _generate_new {
+ my $self = shift;
+ my ($args, $errors);
+
+ if($errors) {
+ $self -> log("new", "Errors detected in addition: $errors");
+
+ my $errorlist = $self -> {"template"} -> load_template("error/error_list.tem", {"%(message)s" => "{L_NEW_ERRORS}",
+ "%(errors)s" => $errors });
+ $errors = $self -> {"template"} -> load_template("error/page_error.tem", { "%(message)s" => $errorlist });
+ }
+
+ # Prebuild arrays for units and prep methods
+ my $units = $self -> _get_units();
+ my $preps = $self -> {"system"} -> {"entities"} -> {"prep"} -> as_options(1);
+
+ # And convert them to optionlists for the later template call
+ my $unitopts = $self -> {"template"} -> build_optionlist($units);
+ my $prepopts = $self -> {"template"} -> build_optionlist($preps);
+
+ # Build the list of ingredients
+ my $ingredients = $self -> _build_ingredients($args, $units, $preps);
+
+ # Build up the type and status data
+ my $typeopts = $self -> {"template"} -> build_optionlist($self -> {"system"} -> {"entities"} -> {"types"} -> as_options(),
+ $args -> {"type"});
+
+ my $statusopts = $self -> {"template"} -> build_optionlist($self -> {"system"} -> {"entities"} -> {"states"} -> as_options(0, visible => {value => 1}),
+ $args -> {"status"});
+
+ # Convert the time fields
+ my ($timemins, $timesecs) = ("", 0);
+ if($args -> {"timemins"}) {
+ $timesecs = $args -> {"timemins"} * 60;
+ $timemins = $self -> _build_timereq($timesecs);
+ }
+
+ # And squirt out the page content
+ my $body = $self -> {"template"} -> load_template("new/content.tem",
+ {
+ "%(errors)s" => $errors,
+ "%(name)s" => $args -> {"name"} // "",
+ "%(source)s" => $args -> {"source"} // "",
+ "%(yield)s" => $args -> {"yield"} // "",
+ "%(timereq)s" => $args -> {"timereq"} // "",
+ "%(timemins)s" => $timemins,
+ "%(timesecs)s" => $timesecs,
+ "%(temp)s" => $args -> {"temp"} // "",
+ "%(temptypes)s" => $self -> _build_temptypes($args -> {"temptype"}),
+ "%(types)s" => $typeopts,
+ "%(units)s" => $unitopts,
+ "%(preps)s" => $prepopts,
+ "%(status)s" => $statusopts,
+ "%(ingreds)s" => $ingredients,
+ "%(method)s" => $args -> {"method"} // "",
+ "%(notes)s" => $args -> {"notes"} // "",
+ });
+
+ return ($self -> {"template"} -> replace_langvar("NEW_TITLE"),
+ $body,
+ $self -> {"template"} -> load_template("new/extrahead.tem"),
+ $self -> {"template"} -> load_template("new/extrajs.tem"));
+}
+
+
+# ============================================================================
+# UI handler/dispatcher functions
+
+## @method private @ _fatal_error($error)
+# Generate the tile and content for an error page.
+#
+# @param error A string containing the error message to display
+# @return The title of the error page and an error message to place in the page.
+sub _fatal_error {
+ my $self = shift;
+ my $error = shift;
+
+ return ("{L_VIEW_ERROR_FATAL}",
+ $self -> {"template"} -> load_template("error/page_error.tem",
+ { "%(message)s" => $error,
+ "%(url-logout)s" => $self -> build_url(block => "login", pathinfo => ["signout"])
+ })
+ );
+}
+
+
+## @method private $ _dispatch_ui()
+# Implements the core behaviour dispatcher for non-api functions. This will
+# inspect the state of the pathinfo and invoke the appropriate handler
+# function to generate content for the user.
+#
+# @return A string containing the page HTML.
+sub _dispatch_ui {
+ my $self = shift;
+
+ my ($title, $body, $extrahead, $extrajs) = $self -> _generate_new();
+
+ # Done generating the page content, return the filled in page template
+ return $self -> generate_orb_page(title => $title,
+ content => $body,
+ extrahead => $extrahead,
+ extrajs => $extrajs,
+ active => '-',
+ doclink => 'summary');
+}
+
+
+# ============================================================================
+# Module interface functions
+
+## @method $ page_display()
+# Generate the page content for this module.
+sub page_display {
+ my $self = shift;
+
+ # Is this an API call, or a normal page operation?
+ my $apiop = $self -> is_api_operation();
+ if(defined($apiop)) {
+ # API call - dispatch to appropriate handler.
+ given($apiop) {
+ default {
+ return $self -> api_response($self -> api_errorhash('bad_op',
+ $self -> {"template"} -> replace_langvar("API_BAD_OP")))
+ }
+ }
+ } else {
+ return $self -> _dispatch_ui();
+ }
+}
+
+1;
\ No newline at end of file
diff --git a/lang/en/new.lang b/lang/en/new.lang
new file mode 100644
index 0000000..da9c0fa
--- /dev/null
+++ b/lang/en/new.lang
@@ -0,0 +1,47 @@
+NEW_TITLE = Create Recipe
+
+NEW_NAME = Name
+NEW_NAME_DOC = The name of the recipe
+NEW_NAME_PH = Recipe name
+
+NEW_SOURCE = Source
+NEW_SOURCE_DOC = Information about the source this recipe was based on
+NEW_SOURCE_PH = http://source.url
+
+NEW_YIELD = Yield
+NEW_YIELD_DOC = How many servings does this recipe make?
+NEW_YIELD_PH = X servings
+
+NEW_PREPINFO = Prep info
+NEW_PREPINFO_DOC = How much time each step of the recipe take?
+NEW_PREPINFO_PH = 10 min prep + 20 min cook
+
+NEW_TIMEREQ = Time required
+NEW_TIMEREQ_DOC = How long does this recipe take in total?
+NEW_TIMEREQ_PH = 1 hour 10 minutes
+
+NEW_OVENTEMP = Oven preheat
+NEW_OVENTEMP_DOC = Initial oven temperature (show changes in method)
+NEW_OVENTEMP_PH = None
+
+NEW_TYPE = Type
+NEW_STATUS = Status
+NEW_TAGS = Tags
+
+NEW_ADD_SEP = Add Separator
+NEW_ADD_INGRED = Add Ingredient
+NEW_ADD_INGRED5 = Add 5 Ingredients
+NEW_ADD_INGRED10 = Add 10 Ingredients
+
+
+NEW_INGREDIENTS = Ingredients
+NEW_ING_QUANT_PH = Quantity
+NEW_ING_ING_PH = Ingredient
+NEW_ING_NOTE_PH = Notes
+NEW_ING_SEP_PH = Separator text
+NEW_ING_DELETE = Delete
+
+NEW_METHOD = Method
+NEW_NOTES = Notes
+
+NEW_CREATE = Add Recipe
diff --git a/lang/en/validate.lang b/lang/en/validate.lang
index ccc5797..5fc48dd 100755
--- a/lang/en/validate.lang
+++ b/lang/en/validate.lang
@@ -16,3 +16,5 @@ BLOCK_VALIDATE_RANGEMAX = The value provided for '***field***' is out of range
BLOCK_ERROR_TITLE = Fatal System Error
BLOCK_ERROR_SUMMARY = The system has encountered an unrecoverable error.
BLOCK_ERROR_TEXT = A serious error has been encountered while processing your request. The following information was generated by the system, please contact moodlesupport@cs.man.ac.uk about this, including this error and a description of what you were doing when it happened!
***error***
+
+BLOCK_ERROR_BADENUM = Unable to fetch enum values from ***table***.***col***: ***errstr***
\ No newline at end of file
diff --git a/templates/default/css/new.css b/templates/default/css/new.css
new file mode 100644
index 0000000..6645f65
--- /dev/null
+++ b/templates/default/css/new.css
@@ -0,0 +1,27 @@
+
+#ingredients {
+ margin: 0px;
+ padding: 0px;
+}
+
+#ingredients li {
+ list-style: none;
+ margin-bottom: 0.5rem;
+ padding-left: 1rem;
+ background-repeat: no-repeat;
+ background-image: url('../images/draghandle.png');
+ background-position: left center;
+}
+
+#ingredients li input,
+#ingredients li select,
+#ingredients li button
+{
+ margin: 0px;
+ height: auto;
+}
+
+#ingredients .ui-state-highlight {
+ height: calc(2.75rem - 2px);
+ background-image: none;
+}
diff --git a/templates/default/images/draghandle.png b/templates/default/images/draghandle.png
new file mode 100755
index 0000000..21708a8
Binary files /dev/null and b/templates/default/images/draghandle.png differ
diff --git a/templates/default/images/draghandle.xcf b/templates/default/images/draghandle.xcf
new file mode 100755
index 0000000..24cc474
Binary files /dev/null and b/templates/default/images/draghandle.xcf differ
diff --git a/templates/default/js/new.js b/templates/default/js/new.js
new file mode 100644
index 0000000..a103be2
--- /dev/null
+++ b/templates/default/js/new.js
@@ -0,0 +1,74 @@
+
+
+function add_separator()
+{
+ var $new = $('#templates li.separator').clone(true);
+ $new.hide().appendTo($('#ingredients')).fadeIn(300);
+}
+
+
+function add_ingredient(count)
+{
+ for(i = 0; i < count; ++i) {
+ var $new = $('#templates li.ingred').clone(true);
+ $new.hide().appendTo($('#ingredients')).fadeIn(300);
+
+ $new.find(".ingredient").autocomplete({
+ source: api.ingredients,
+ minLength: 2
+ });
+
+ }
+}
+
+
+$(function() {
+ $('#timemins').timeDurationPicker({
+ lang: 'en_US',
+ seconds: false,
+ minutes: true,
+ hours: true,
+ days: true,
+ months: false,
+ years: false,
+ onSelect: function(element, seconds, humanDuration) {
+ $('#timemins').val(humanDuration);
+ $('#timesecs').val(seconds);
+ console.log(seconds, humanDuration);
+ }
+ });
+
+ $('#tags').select2({
+ theme: "foundation",
+ tags: true,
+ tokenSeparators: [','],
+ minimumInputLength: 2,
+ multiple: true,
+ ajax: {
+ delay: 250,
+ dataType: 'json',
+ url: api.tags
+ }
+ });
+
+ CKEDITOR.replace('method');
+ CKEDITOR.replace('notes');
+
+ $('#ingredients').sortable({
+ placeholder: "ui-state-highlight"
+ });
+
+ $("#ingredients .ingredient").autocomplete({
+ source: api.ingredients,
+ minLength: 2
+ });
+
+ // Handle addition of separators and ingredients
+ $('#addsep').on('click', function() { add_separator(); });
+ $('.adding').on('click', function() { add_ingredient($(this).data('count')); });
+
+ // Handle removal of separators and ingredients
+ $('.deletectrl').on('click', function() {
+ $(this).parents('li').fadeOut(300, function() { $(this).remove(); });
+ });
+});
diff --git a/templates/default/new/content.tem b/templates/default/new/content.tem
new file mode 100644
index 0000000..38a4169
--- /dev/null
+++ b/templates/default/new/content.tem
@@ -0,0 +1,142 @@
+%(errors)s
+