summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/index.js139
-rw-r--r--app/node_modules/okadminview/index.js320
-rw-r--r--app/node_modules/okadminview/package.json17
-rw-r--r--app/node_modules/okdb/index.js9
-rw-r--r--app/node_modules/okquery/index.js26
-rw-r--r--app/node_modules/okresource/index.js209
-rw-r--r--app/node_modules/okresource/package.json1
-rw-r--r--app/node_modules/okrest/index.js20
-rw-r--r--app/node_modules/okrest/package.json11
-rw-r--r--app/node_modules/okserver/index.js7
-rw-r--r--app/node_modules/oktemplate/index.js7
-rw-r--r--app/node_modules/oktemplate/package.json3
-rw-r--r--app/node_modules/okutil/index.js29
-rw-r--r--app/node_modules/okview/index.js90
-rw-r--r--app/node_modules/okview/package.json7
-rw-r--r--examples/db.json30
-rw-r--r--examples/index.js23
-rw-r--r--examples/public/css/main.css19
-rw-r--r--examples/public/images/brown046.jpgbin0 -> 5108 bytes
-rw-r--r--examples/templates/bread.liquid14
-rw-r--r--examples/templates/index.liquid28
-rw-r--r--examples/templates/page.liquid19
-rw-r--r--examples/templates/project.liquid2
-rw-r--r--package.json3
-rw-r--r--themes/okadmin/public/css/main.css103
-rw-r--r--themes/okadmin/templates/index.liquid26
-rw-r--r--themes/okadmin/templates/partials/head.liquid13
-rw-r--r--themes/okadmin/templates/partials/inputs.liquid19
-rw-r--r--themes/okadmin/templates/partials/tail.liquid3
-rw-r--r--themes/okadmin/templates/resource.liquid15
-rw-r--r--themes/okadmin/templates/resource_new.liquid15
31 files changed, 1047 insertions, 180 deletions
diff --git a/app/index.js b/app/index.js
index c8dceb5..4ec8730 100644
--- a/app/index.js
+++ b/app/index.js
@@ -4,13 +4,14 @@ var withTrailingSlash = require('okutil').withTrailingSlash;
var withoutTrailingSlash = require('okutil').withoutTrailingSlash;
var assign = require('object-assign');
var express = require('express');
+var Q = require('q');
var OKQuery = require('okquery');
var OKView = require('okview');
+var OKAdminView = require('okadminview');
var OKDB = require('okdb');
var OKResource = require('okresource')
var OKTemplate = require('oktemplate');
var OKServer = require('okserver');
-var OKRestEndpoint = require('okrest');
var OKSchema = require('okschema');
/**
@@ -33,6 +34,34 @@ function OKCMS(options) {
var adminTemplateRoot = options.templateRoot ||
path.join(__dirname, '../themes/okadmin/templates');
+ // Set metadata defaults
+ // TODO Abstract this out somewhere else
+ var meta = {
+ type: 'meta',
+ get: function() {
+ return Q.promise(function(resolve, reject) {
+ db.getMeta().then(function(metadata) {
+ resolve(assign({}, {
+ static: ''
+ }, metadata));
+ }).fail(reject);
+ });
+ }
+ };
+
+ var adminMeta ={
+ type: 'meta',
+ get: function() {
+ return Q.promise(function(resolve, reject) {
+ db.getMeta().then(function(metadata) {
+ resolve(assign({}, {
+ static: withoutTrailingSlash(adminPath)
+ }, metadata));
+ }).fail(reject);
+ });
+ }
+ };
+
var schemaConfig = options.schemas || {};
var resourceConfig = options.resources || [];
var viewConfig = options.views || {
@@ -45,24 +74,23 @@ function OKCMS(options) {
new OKTemplate({root: adminTemplateRoot});
var db = new OKDB(options.db || 'fs');
- // Special query to get project wide meta data
- var meta = this._meta = {
- type: 'meta',
- get: function() {
- return db.getMeta();
- }
- };
var schemas = this._schemas = this._createSchemas(schemaConfig);
- var resources = this._resources =
+ var resourceCache = this._resourceCache =
this._createResources(resourceConfig, db, schemas);
// Create view instances from config
var views = this._views =
- this._createViews(viewConfig, db, meta, resources, templateProvider);
+ this._createViews(viewConfig, db, meta, resourceCache, templateProvider);
+ var adminViews = this._adminViews =
+ this._createAdminViews(adminPath, app, express, resourceConfig,
+ resourceCache, adminTemplateProvider, adminMeta);
+
var server = this._server = new OKServer({
express: express,
app: app,
- views: views,
+ // Merge admin views with normal views
+ views: assign(views, adminViews),
+ // Specify root folders and paths for serving static assets
root: root,
adminRoot: adminRoot,
adminPath: adminPath
@@ -85,22 +113,24 @@ OKCMS.prototype._createSchemas = function(schemaConfig) {
OKCMS.prototype._createResources = function(resourceConfig, db, schemaCache) {
resourceConfig = resourceConfig || {};
- var typeCache = {};
- return resourceConfig.reduce(function(cache, config) {
+ var resources = resourceConfig.map(function(config) {
var type = config.type;
var schema = schemaCache[type];
if (!schema)
throw new Error('Resource config references nonexistent schema');
- // If we already created resource class, just skip
- if (cache[type])
- return cache;
- cache[type] = OKResource({
+ var resource = OKResource({
type: type,
db: db,
schema: schema
- })
- return cache;
- }, {});
+ });
+ // Static resources have some data defined by configuration and
+ // are a special case
+ if (config.static) {
+ resource = resource.instance({static: config.static});
+ }
+ return resource;
+ });
+ return ResourceCache(resources);
};
OKCMS.prototype._createViews = function(viewConfig, db,
@@ -142,7 +172,37 @@ OKCMS.prototype._createViews = function(viewConfig, db,
else
return '404';
}
-}
+};
+
+OKCMS.prototype._createAdminViews = function(path, app, express,
+ resourceConfig, resourceCache, templateProvider, meta) {
+ var views = {};
+ var withTrail = withTrailingSlash(path);
+ var withoutTrail = withoutTrailingSlash(path);
+ // Stoopid fix for a bug in Express. Need to do this
+ // to ensure strict routing is not broken for the nested
+ // admin router.
+ // See: https://github.com/strongloop/express/issues/2281
+ // TODO Get rid of this crap
+ views[withoutTrail] = {
+ mount: 'get',
+ middleware: function() {
+ return function(req, res) {
+ res.redirect(301, withTrail);
+ }
+ }
+ };
+ // Add real view at trailing slash route
+ views[withTrail] = OKAdminView({
+ app: app,
+ express: express,
+ resourceConfig: resourceConfig,
+ resourceCache: resourceCache,
+ templateProvider: templateProvider,
+ meta: meta
+ });
+ return views;
+};
OKCMS.prototype._createQueries = function(queryConfig, resourceCache) {
queryConfig = queryConfig || {};
@@ -150,7 +210,7 @@ OKCMS.prototype._createQueries = function(queryConfig, resourceCache) {
queryConfig = [queryConfig];
return queryConfig.map(function(config) {
var type = config.type;
- var resource = resourceCache[type];
+ var resource = resourceCache.get(type, config.query);
if (!resource)
throw new Error('Query configured with nonexistent resource');
// Default to "select all" query
@@ -162,16 +222,43 @@ OKCMS.prototype._createQueries = function(queryConfig, resourceCache) {
});
};
+/**
+ * Stupid lil cache to help deal with the fact that
+ * resources can be indexed by either type or a type + id combo.
+ */
+function ResourceCache(resources) {
+ if (!(this instanceof ResourceCache)) return new ResourceCache(resources);
+ resources = resources || [];
+ var cache = this._cache = {};
+ resources.forEach(function(resource) {
+ if (!resource)
+ throw new Error('Undefined resource given to ResourceCache');
+ if (resource.bound) {
+ cache[resource.type] = resource.parent;
+ cache[resource.type + ':' + resource.id] = resource;
+ } else {
+ cache[resource.type] = resource;
+ }
+ });
+}
+
+ResourceCache.prototype.get = function(type, id) {
+ if (!type) return;
+ if (id && this._cache[type + ':' + id]) {
+ return this._cache[type + ':' + id];
+ } else {
+ return this._cache[type];
+ }
+};
+
module.exports = {
createApp: function(options) {
- return new OKCMS(options);
+ return OKCMS(options);
},
OKResource: OKResource,
- OKView: OKView,
-
- OKRestEndpoint: OKRestEndpoint
+ OKView: OKView
};
diff --git a/app/node_modules/okadminview/index.js b/app/node_modules/okadminview/index.js
new file mode 100644
index 0000000..42e7f36
--- /dev/null
+++ b/app/node_modules/okadminview/index.js
@@ -0,0 +1,320 @@
+var bodyParser = require('body-parser');
+var methodOverride = require('method-override');
+var Q = require('q');
+var pluralize = require('pluralize');
+var OKQuery = require('okquery');
+
+/**
+ * OKAdminView!
+ */
+function OKAdminView(options) {
+ if (!(this instanceof OKAdminView)) return new OKAdminView(options);
+ if (!options.app)
+ throw new Error('No Express app provided to OKAdminView');
+ if (!options.express)
+ throw new Error('No Express provided to OKAdminView');
+ if (!options.resourceConfig)
+ throw new Error('No resourceConfig provided to OKAdminView');
+ if (!options.resourceCache)
+ throw new Error('No resourceCache provided to OKAdminView');
+ if (!options.templateProvider)
+ throw new Error('No templateProvider provided to OKAdminView');
+ if (!options.meta)
+ throw new Error('No meta query provided to OKAdminView');
+ var app = options.app;
+ var express = options.express;
+ var meta = options.meta;
+ var resourceCache = this._resourceCache = options.resourceCache;
+ var resourceConfig = this._resourceConfig = options.resourceConfig;
+ var provider = options.templateProvider;
+ // Load templates
+ var templates = this._templates =
+ ['index', 'resource', 'resource_new'].reduce(function(cache, name) {
+ var template = provider.getTemplate(name);
+ if (!template)
+ throw new Error('Admin theme does not have needed template: "' + name + '"');
+ cache[name] = template;
+ return cache;
+ }, {});
+ // OKAdmin middleware is a router, so mounts on 'use'
+ Object.defineProperty(this, 'mount', {
+ value: 'use',
+ writable: false,
+ enumerable: true
+ });
+
+ // Resources which apper on the index are a function of
+ // the resource configuration.
+ var indexQueries = this._indexQueries = Object.keys(resourceConfig)
+ .map(function(key) {
+ var config = resourceConfig[key];
+ var type = config.type;
+ var staticData = config.static || {};
+ var resource = resourceCache.get(config.type);
+ if (!resource)
+ throw new Error('Something weird is going on');
+ var id = staticData[resource.idField];
+ resource = resourceCache.get(type, id) || resource;
+ if (resource.bound) {
+ // Resource instances implement the query API
+ return OKQuery({resource: resource});;
+ } else {
+ return OKQuery({resource: resource, query: config.query})
+ }
+ });
+
+ this._middleware = createMiddleware(this);
+
+ function createMiddleware(view) {
+ var router = express.Router({
+ strict: app.get('strict routing')
+ });
+
+ // Parse form data
+ router.use(bodyParser.urlencoded({extended: true}));
+ // HTML forms only support POST and GET methods
+ // We extend this by adding hidden input fields to the forms which
+ // specify the actual method desired.
+ router.use(methodOverride(function(req, res) {
+ // Parse out the hidden field
+ if (req.body && typeof req.body === 'object' && '_method' in req.body) {
+ var method = req.body._method;
+ delete req.body._method
+ return method
+ }
+ }));
+
+ router.get('/', function readIndex(req, res, next) {
+ fetchIndexTemplateData(meta, indexQueries).then(function(data) {
+ view.renderIndex(req, res, data);
+ }).fail(errorHandler(req, res));
+ });
+
+ router.get('/:type/new/', function createResourceView(req, res, next) {
+ var type = req.params.type || '';
+ var resource = resourceCache.get(type);
+ if (!resource) {
+ errorHandler(req, res)(new Error('No such resource ' + type));
+ } else {
+ meta.get().then(function(metadata) {
+ view.renderResourceNew(req, res, {
+ meta: metadata,
+ resource: {
+ type: resource.type,
+ spec: resource.spec
+ }
+ });
+ }).fail(errorHandler(req, res));
+ }
+ });
+
+
+ router.get('/:type/:id/', function readResource(req, res, next) {
+ var type = req.params.type || '';
+ var id = req.params.id || '';
+ var resource = resourceCache.get(type, id);
+ if (!resource) {
+ errorHandler(req, res)(new Error('No such resource'));
+ } else {
+ var query = OKQuery({
+ resource: resource,
+ query: id
+ });
+ fetchResourceTemplateData(meta, query, transform).then(function(data) {
+ if (!data) {
+ resourceMissingHandler(req, res)()
+ } else {
+ view.renderResource(req, res, data);
+ }
+ }).fail(errorHandler(req, res));
+ }
+
+ function transform(meta, resource, data) {
+ meta = meta || {};
+ resource = resource || {};
+ data = data || {};
+ var spec = Object.keys(resource.spec).reduce(function(cache, prop) {
+ var propSpec = resource.spec[prop];
+ var value = data[prop];
+ cache[prop].id = data[resource.idField];
+ cache[prop].value = value;
+ return cache;
+ }, resource.spec);
+ return {
+ meta: meta,
+ resource: {
+ type: resource.type,
+ spec: spec
+ }
+ };
+ }
+ });
+
+ router.post('/:type/', function createResource(req, res, next) {
+ var type = req.params.type;
+ var resource = resourceCache.get(type);
+ var data = req.body;
+ if (!resource) {
+ errorHandler(req, res)(new Error('No such resource ' + type));
+ } else {
+ meta.get().then(function(metadata) {
+ var templateData = {
+ meta: metadata,
+ resource: {
+ type: resource.type,
+ spec: resource.spec,
+ data: data
+ }
+ };
+ try {
+ resource.assertValid(data);
+ resource.create(data).then(function(created) {
+ res.redirect(303, data[resource.idField]);
+ }).fail(errorHandler(req, res));
+ } catch (errors) {
+ view.renderResource(req, res, templateData);
+ }
+ }).fail(errorHandler(req, res));;
+ }
+ });
+
+ router.put('/:type/:id/', function updateResource(req, res, next) {
+ var type = req.params.type;
+ var id = req.params.id;
+ var data = req.body;
+ var resource = resourceCache.get(type, id);
+ if (!resource) {
+ errorHandler(req, res)(new Error('No such resource ' + type));
+ } else {
+ // TODO Maybe should make metadata synchronous...
+ meta.get().then(function(metadata) {
+ var templateData = {
+ meta: metadata,
+ resource: {
+ spec: resource.spec,
+ data: data
+ }
+ };
+ try {
+ resource.assertValid(data);
+ resource.update(id, data).then(function(updated) {
+ res.redirect(303, '../' + updated[resource.idField]);
+ }).fail(errorHandler(req, res));
+ } catch (errors) {
+ view.renderResource(req, res, templateData);
+ }
+ }).fail(errorHandler(req, res));
+ }
+ });
+
+ return router;
+ }
+}
+
+OKAdminView.prototype.middleware = function() {
+ return this._middleware;
+};
+
+OKAdminView.prototype.renderIndex = function(req, res, data) {
+ data = data || {};
+ this._templates['index'].render(data).then(function(rendered) {
+ res.send(rendered);
+ }).fail(errorHandler(req, res));
+};
+
+OKAdminView.prototype.renderResource = function(req, res, data) {
+ data = data || {};
+ this._templates['resource'].render(data).then(function(rendered) {
+ res.send(rendered);
+ }).fail(errorHandler(req, res));
+};
+
+OKAdminView.prototype.renderResourceNew = function(req, res, data) {
+ data = data || {meta: {}, resource: {}};
+ this._templates['resource_new'].render(data).then(function(rendered) {
+ res.send(rendered);
+ }).fail(errorHandler(req, res));
+};
+
+/**
+ * Annotate template data with schema info
+ */
+function fetchIndexTemplateData(meta, queries) {
+ return Q.Promise(function(resolve, reject) {
+ Q.all([meta.get()].concat(queries.map(function(query) {
+ return query.get();
+ }))).then(function(results) {
+ var meta = results.shift();
+ var resources = results.reduce(function(cache, result, i) {
+ if (!result)
+ return cache;
+ var resource = queries[i].resource;
+ // We want the raw object spec
+ var spec = resource.spec;
+ var key = pluralize(resource.type);
+ if (!cache[key]) {
+ cache[key] = {
+ type: resource.type,
+ spec: spec,
+ data: []
+ };
+ }
+
+ if (result.length) {
+ result.forEach(addToCache)
+ } else {
+ addToCache(result);
+ }
+
+ function addToCache(data) {
+ // Report id to template under standard name
+ data.id = data[resource.idField];
+ cache[key].data.push(data);
+ }
+
+ return cache;
+ }, {});
+
+ resolve({
+ meta: meta,
+ resources: resources
+ });
+ }).fail(reject);
+ });
+}
+
+/**
+ * Annotate template data with schema info
+ */
+function fetchResourceTemplateData(meta, query, fn) {
+ fn = fn || function(m, r, d) { return {meta: m, resource: d}; };
+ return Q.Promise(function(resolve, reject) {
+ meta.get().then(function(metadata) {
+ query.get().then(function(data) {
+ var resource = query.resource;
+ resolve(fn(metadata, resource, data));
+ }).fail(reject);
+ }).fail(reject)
+ });
+}
+
+/**
+ * TODO Real error handling
+ */
+function errorHandler(req, res) {
+ return function(err) {
+ res.send(err.stack);
+ };
+}
+
+/**
+ * TODO Real 404 handling
+ */
+function resourceMissingHandler(req, res) {
+ return function() {
+ res.status(404);
+ res.send('404');
+ }
+}
+
+module.exports = OKAdminView;
diff --git a/app/node_modules/okadminview/package.json b/app/node_modules/okadminview/package.json
new file mode 100644
index 0000000..b07cb1a
--- /dev/null
+++ b/app/node_modules/okadminview/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "okadminview",
+ "version": "1.0.0",
+ "description": "administrate!",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "OKFocus",
+ "license": "None",
+ "dependencies": {
+ "body-parser": "^1.12.2",
+ "method-override": "^2.3.2",
+ "pluralize": "^1.1.2",
+ "q": "^1.2.0"
+ }
+}
diff --git a/app/node_modules/okdb/index.js b/app/node_modules/okdb/index.js
index 3089411..5368e4a 100644
--- a/app/node_modules/okdb/index.js
+++ b/app/node_modules/okdb/index.js
@@ -72,8 +72,13 @@ FSDB.prototype.put = function(collection, query, data) {
}
};
-FSDB.prototype.create = function(collection, id, data) {
- throw new Error('Not implemented!');
+FSDB.prototype.create = function(collection, data) {
+ var created = this._db(collection)
+ .chain()
+ .push(data)
+ .last()
+ .value();
+ return this._resolve(created);
};
FSDB.prototype.remove = function(collection, id, data) {
diff --git a/app/node_modules/okquery/index.js b/app/node_modules/okquery/index.js
index a64f0f2..9748067 100644
--- a/app/node_modules/okquery/index.js
+++ b/app/node_modules/okquery/index.js
@@ -14,14 +14,19 @@ function OKQuery(options) {
var resource = options.resource;
var type = resource.type;
var query = options.query || '*';
+
Object.defineProperty(this, 'resource', {
value: resource,
- writable: false
+ writable: false,
+ enumerable: true
});
+
Object.defineProperty(this, 'type', {
value: resource.type,
- writable: false
+ writable: false,
+ enumerable: true
});
+
this.get = createQuery(resource, query, {
default: options.default
});
@@ -29,8 +34,9 @@ function OKQuery(options) {
function createQuery(resource, query, options) {
options = options || {};
- var query;
- if (isDynamic(query)) {
+ if (resource.bound) {
+ query = queryBound(resource);
+ } else if (isDynamic(query)) {
query = queryDynamic(resource);
} else if (isSet(query)) {
query = queryAll(resource);
@@ -46,19 +52,25 @@ function createQuery(resource, query, options) {
function queryDynamic(resource) {
return function(id) {
return resource.get(id);
- }
+ };
}
function queryAll(resource) {
return function() {
return resource.all();
- }
+ };
}
function querySingle(resource, id) {
return function() {
return resource.get(id);
- }
+ };
+}
+
+function queryBound(resource) {
+ return function() {
+ return resource.get();
+ };
}
function withDefault(queryFn, resultDefault) {
diff --git a/app/node_modules/okresource/index.js b/app/node_modules/okresource/index.js
index 7cd51c7..80279dc 100644
--- a/app/node_modules/okresource/index.js
+++ b/app/node_modules/okresource/index.js
@@ -1,3 +1,4 @@
+var assign = require('object-assign');
var Q = require('q');
/**
@@ -25,21 +26,16 @@ function OKResource(options) {
idField = prop;
return idField;
// If schema has a prop called 'id', default to that one
- }, schema.id && 'id');
+ }, schema.spec.id && 'id');
if (!idField)
throw new Error('Bad schema: no ID field');
var type = options.type;
this._db = options.db;
+ this._schema = schema;
// Define properties which are part of the API
- Object.defineProperty(this, 'schema', {
- value: schema,
- writable: false,
- enumerable: true
- });
-
Object.defineProperty(this, 'spec', {
value: schema.spec,
writable: false,
@@ -57,13 +53,21 @@ function OKResource(options) {
writable: false,
enumerable: true
});
+
+ // Whether this resource represents a specific data point
+ // or a whole class of data
+ Object.defineProperty(this, 'bound', {
+ value: false,
+ writable: false,
+ enumerable: true
+ });
}
/**
* Throws an error if data does not conform to schema
*/
OKResource.prototype.assertValid = function(data) {
- this.schema.assertValid(data);
+ this._schema.assertValid(data);
};
OKResource.prototype.all = function() {
@@ -72,12 +76,14 @@ OKResource.prototype.all = function() {
OKResource.prototype.create = function(data) {
data = data || {};
+ var type = this.type;
+ var db = this._db;
var id = data[this.idField];
return Q.promise(function(resolve, reject) {
if (!id) {
reject(new Error('Data does not contain ID property'));
} else {
- this._db.create(this.type, data).then(resolve, reject);
+ db.create(type, data).then(resolve).fail(reject);
}
});
};
@@ -89,7 +95,7 @@ OKResource.prototype.destroy = function(data) {
if (!id) {
reject(new Error('Data does not contain ID property'));
} else {
- this._db.remove(this.type, data.id, data).then(resolve, reject);
+ this._db.remove(this.type, data.id, data).then(resolve).fail(reject);
}
});
};
@@ -99,7 +105,7 @@ OKResource.prototype.find = function(query) {
if (!query) {
throw new Error('No query given');
} else {
- this._db.find(this.type, query).then(resolve, reject);
+ this._db.find(this.type, query).then(resolve).fail(reject);
}
});
};
@@ -117,48 +123,195 @@ OKResource.prototype.get = function(id) {
// to match
var query = {};
query[idField] = id;
- db.get(type, query).then(resolve, reject);
+ db.get(type, query).then(resolve).fail(reject);
}
});
};
-OKResource.prototype.update = function(data) {
+OKResource.prototype.update = function(id, data) {
data = data || {};
- var id = data[this.idField];
var db = this._db;
var type = this.type;
var idField = this.idField;
return Q.promise(function(resolve, reject) {
if (!id) {
- reject(new Error('Data does not contain ID property'));
+ reject(new Error('No resource ID provided'));
} else {
var query = {};
- query[idField] = data[idField];
- db.put(type, query, data).then(resolve, reject);;
+ query[idField] = id;
+ db.put(type, query, data).then(resolve).fail(reject);;
}
});
};
-OKResource.prototype.updateOrCreate = function(data) {
+OKResource.prototype.updateOrCreate = function(id, data) {
data = data || {};
- var id = data[this.idField];
var type = this.type;
var db = this._db;
var idField = this.idField;
+ var query = {};
+ query[idField] = id;
return Q.promise(function(resolve, reject) {
if (!id) {
- reject(new Error('Cannot updateOrCreate without ID'));
+ reject(new Error('No resource ID provided'));
} else {
- db.get(type, data.id).then(function(cached) {
- var query = {};
- query[idField] = id;
- if (cached)
- db.put(type, query, data).then(resolve, reject);
- else
- db.create(type, data).then(resolve, reject);
- }, reject);
+ db.get(type, query).then(function(persisted) {
+ if (persisted) {
+ db.put(type, query, data).then(resolve).fail(reject);
+ } else {
+ db.create(type, data).then(resolve).fail(reject);
+ }
+ }).fail(reject);
}
});
};
+/**
+ * Create special resource which is bound to particular data point
+ * and has custom validation and query properties.
+ */
+OKResource.prototype.instance = function(options) {
+ return new OKResourceInstance(this, {
+ static: options.static
+ });
+};
+
+function OKResourceInstance(resource, options) {
+ if (!(this instanceof OKResourceInstance)) return new OKResourceInstance(options);
+ // Only support static data instances for now
+ if (!options.static)
+ throw new Error(
+ 'Cannot create OKResourceInstance without static data');
+
+ // Resources with static data are a special case. They exist
+ // conceptually at all times since they are derived from app
+ // configuration, but may not actually be present
+ // in the database and need custom logic to handle this.
+ var staticData = assign({}, options.static);
+ var id = staticData[resource.idField];
+ if (!id)
+ throw new Error(
+ 'Cannot create static OKResourceInstance without an ID field');
+
+ /**
+ * Ensure that static data is provided on get
+ */
+ this.get = function() {
+ return Q.promise(function(resolve, reject) {
+ resource.get(id).then(function(data) {
+ // Note the assign call. Don't expose private references!
+ if (data) {
+ resolve(assign({}, data, staticData));
+ } else {
+ resolve(assign({}, staticData));
+ }
+ }).fail(reject);
+ });
+ };
+
+ this.update = function(data) {
+ return Q.promise(function(resolve, reject) {
+ var valid = Object.keys(staticData).every(function(prop) {
+ return staticData[prop] === data[prop];
+ });
+ if (!valid) {
+ reject(new Error('Cannot update resource\'s static data'));
+ } else {
+ // When updating static resources, we create them if
+ // they don't actually exist in the DB
+ resource.updateOrCreate(id, data).then(resolve).fail(reject);
+ }
+ });
+ };
+
+ this.updateOrCreate = function(data) {
+ return Q.promise(function(resolve, reject) {
+ reject(new Error('Cannot updateOrCreate static resource'));
+ });
+ };
+
+ this.destroy = function(id) {
+ return Q.promise(function(resolve, reject) {
+ reject(new Error('Cannot destroy static resource'));
+ });
+ };
+
+ this.all = function(id) {
+ return Q.promise(function(resolve, reject) {
+ reject(new Error('Cannot get all for static resource'));
+ });
+ };
+
+ this.create = function(id) {
+ return Q.promise(function(resolve, reject) {
+ reject(new Error('Cannot create static resource'));
+ });
+ };
+
+ this.find = function(id) {
+ return Q.promise(function(resolve, reject) {
+ reject(new Error('Cannot perform find on static resource'));
+ });
+ };
+
+ this.assertValid = function(data) {
+ data = data || {};
+ Object.keys(staticData).forEach(function(prop) {
+ if (staticData[prop] !== data[prop]) {
+ // Validation error is in mschema error format
+ throw [{
+ property: prop,
+ constraint: 'static',
+ expected: staticData[prop],
+ actual: data[prop],
+ message: 'Data does not match static data'
+ }];
+ }
+ });
+ resource.assertValid(data);
+ };
+
+ Object.defineProperty(this, 'parent', {
+ value: resource,
+ writable: false,
+ enumerable: true
+ });
+
+ Object.defineProperty(this, 'spec', {
+ value: resource.spec,
+ writable: false,
+ enumerable: true
+ });
+
+ Object.defineProperty(this, 'id', {
+ value: id,
+ writable: false,
+ enumerable: true
+ });
+
+ Object.defineProperty(this, 'type', {
+ value: resource.type,
+ writable: false,
+ enumerable: true
+ });
+
+ Object.defineProperty(this, 'idField', {
+ value: resource.idField,
+ writable: false,
+ enumerable: true
+ });
+
+ Object.defineProperty(this, 'bound', {
+ value: true,
+ writable: false,
+ enumerable: true
+ });
+
+ Object.defineProperty(this, 'class', {
+ value: resource,
+ writable: false,
+ enumerable: true
+ });
+}
+
module.exports = OKResource;
diff --git a/app/node_modules/okresource/package.json b/app/node_modules/okresource/package.json
index da9dfe0..7f19c9b 100644
--- a/app/node_modules/okresource/package.json
+++ b/app/node_modules/okresource/package.json
@@ -9,6 +9,7 @@
"author": "OKFocus",
"license": "None",
"dependencies": {
+ "object-assign": "^2.0.0",
"q": "^1.2.0"
}
}
diff --git a/app/node_modules/okrest/index.js b/app/node_modules/okrest/index.js
deleted file mode 100644
index 169626d..0000000
--- a/app/node_modules/okrest/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-var OKView = require('okview');
-
-/**
- * OKRestEndpoint!
- * Takes a resources and creates a CRUD endpoint.
- */
-function OKRestEndpoint(resource, options) {
- if (!(this instanceof OKRestEndpoint)) return new OKRestEndpoint(resource, options);
- options = options || {};
- this._resource = resource;
-}
-
-OKRestEndpoint.prototype.middleware = function() {
- var self = this;
- return function handleREST(req, res, next) {
- res.send(self._resource.name);
- };
-}
-
-module.exports = OKRestEndpoint;
diff --git a/app/node_modules/okrest/package.json b/app/node_modules/okrest/package.json
deleted file mode 100644
index 462c890..0000000
--- a/app/node_modules/okrest/package.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "name": "okrest",
- "version": "1.0.0",
- "description": "nice",
- "main": "index.js",
- "scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
- },
- "author": "OKFocus",
- "license": "None"
-}
diff --git a/app/node_modules/okserver/index.js b/app/node_modules/okserver/index.js
index 952602d..e6145ee 100644
--- a/app/node_modules/okserver/index.js
+++ b/app/node_modules/okserver/index.js
@@ -45,9 +45,14 @@ function OKServer(options) {
* Order is important here! Requests go down the middleware
* chain until they are handled with a response, which could
* happen anywhere in the chain. Watch out for middleware shadowing
- * ither middleware.
+ * other middleware.
*/
+ // Intercept favicon requests and 404 for now
+ app.use('/favicon.ico', function(req, res) {
+ res.status(404)
+ return res.send('');
+ });
// Serve user static files
app.use(express.static(root));
// Serve admin interface static files
diff --git a/app/node_modules/oktemplate/index.js b/app/node_modules/oktemplate/index.js
index 700020c..dafe5e6 100644
--- a/app/node_modules/oktemplate/index.js
+++ b/app/node_modules/oktemplate/index.js
@@ -1,3 +1,4 @@
+var Q = require('q');
var fs = require('fs');
var path = require('path');
var glob = require('glob');
@@ -56,8 +57,12 @@ OKTemplateRepo.prototype._populateCache = function _populateCache(engine, cache,
name: name,
templateString: templateString,
render: function(data) {
+ return Q.promise(function(resolve, reject) {
// TODO Not sure if this caches parsed templates behind the scenes?
- return engine.parseAndRender(templateString, data);
+ engine.parseAndRender(templateString, data)
+ .then(resolve)
+ .catch(reject);
+ });
}
}
});
diff --git a/app/node_modules/oktemplate/package.json b/app/node_modules/oktemplate/package.json
index 3e92ccb..70e94e3 100644
--- a/app/node_modules/oktemplate/package.json
+++ b/app/node_modules/oktemplate/package.json
@@ -12,6 +12,7 @@
"bluebird": "^2.9.21",
"glob": "^5.0.3",
"json-to-html": "^0.1.2",
- "liquid-node": "^2.5.0"
+ "liquid-node": "^2.5.0",
+ "q": "^1.2.0"
}
}
diff --git a/app/node_modules/okutil/index.js b/app/node_modules/okutil/index.js
index 738c6a4..3142ae1 100644
--- a/app/node_modules/okutil/index.js
+++ b/app/node_modules/okutil/index.js
@@ -10,35 +10,6 @@ var Q = require('q');
module.exports = {
/**
- * Takes a meta data query and an array of resource queries
- * and returns a promise for an object merging all queried
- * data, pluralizing keys where necessary.
- *
- * Lil bit convoluted, sorry.
- */
- fetchTemplateData: function fetchTemplateData(meta, queries, options) {
- return Q.promise(function(resolve, reject) {
- return Q.all(
- [meta.get()].concat(queries.map(function(query) {
- return query.get(options);
- })))
- .then(function(results) {
- var metadata = results.shift();
- var normalized = results.reduce(function(data, result, i) {
- var type = queries[i].type;
- if (isarray(result)) {
- data[pluralize(type)] = result;
- } else {
- data[type] = result;
- }
- return data;
- }, {meta: metadata});
- resolve(normalized);
- }, reject);
- });
- },
-
- /**
* Return a copy of the route with a trailing slash
*/
withTrailingSlash: function withTrailingSlash(route) {
diff --git a/app/node_modules/okview/index.js b/app/node_modules/okview/index.js
index 1ceac03..c245b0c 100644
--- a/app/node_modules/okview/index.js
+++ b/app/node_modules/okview/index.js
@@ -1,4 +1,8 @@
-var fetchTemplateData = require('okutil').fetchTemplateData;
+var assign = require('object-assign');
+var pluralize = require('pluralize');
+var isarray = require('lodash.isarray');
+var Q = require('q');
+var OKQuery = require('okquery');
var OKResource = require('okresource');
// Routes for views over collections have a special pattern
@@ -15,11 +19,11 @@ function OKView(options) {
if (!(this instanceof OKView)) return new OKView(options);
options = options || {};
if (!options.template)
- throw new Error('No template provided to view.');
+ throw new Error('No template provided to OKView.');
if (!options.meta)
- throw new Error('No meta resource provided to view');
+ throw new Error('No meta resource provided to OKView');
if (!options.route)
- throw new Error('No route provided to view');
+ throw new Error('No route provided to OKView');
var route = options.route;
var mount = options.mount || 'get';
this._template = options.template;
@@ -29,28 +33,32 @@ function OKView(options) {
// resource will be resolved later
// TODO This bound / unbound thing can probably be expressed in a
// less convoluted way.
- var unbound = this.unbound = !!UNBOUND_ROUTE_PATTERN.exec(this.route);
+ var unbound = this.unbound = !!UNBOUND_ROUTE_PATTERN.exec(route);
+
Object.defineProperty(this, 'mount', {
value: mount,
writable: false,
enumerable: true
});
+
Object.defineProperty(this, 'route', {
value: route,
writable: false,
enumerable: true
});
+
this._middleware = createMiddleware(this);
- this._fetchTemplateData = unbound ?
- fetchResourceTemplateData : fetchCollectionTemplateData;
+ this._fetchTemplateData = unbound ? fetchUnbound : fetchBound;
- function fetchResourceTemplateData(id) {
- // Bound views only have a single query
- // TODO This is super convoluted
- return fetchTemplateData(meta, [queries[0].get(id)]);
+ function fetchUnbound(id) {
+ var resource = queries[0].resource;
+ return fetchTemplateData(meta, [OKQuery({
+ resource: resource,
+ query: id
+ })]);
}
- function fetchCollectionTemplateData() {
+ function fetchBound() {
return fetchTemplateData(meta, queries);
}
}
@@ -62,7 +70,7 @@ OKView.prototype.middleware = function() {
OKView.prototype.render = function(req, res, data) {
this._template.render(data).then(function(html) {
res.send(html);
- }, errorHandler(req, res, data));
+ }).fail(errorHandler(req, res, data));
};
OKView.prototype.fetchTemplateData = function() {
@@ -94,7 +102,7 @@ function unboundMiddleware(view) {
var id = req.params[paramName];
view.fetchTemplateData(id).then(function(data) {
view.render(req, res, data);
- }, errorHandler(req, res, next));
+ }).fail(errorHandler(req, res, next));
};
}
@@ -106,7 +114,7 @@ function boundMiddleware(view) {
return function(req, res, next) {
view.fetchTemplateData().then(function(data) {
view.render(req, res, data);
- }, errorHandler(req, res, next));
+ }).fail(errorHandler(req, res, next));
};
}
@@ -129,4 +137,56 @@ function getParamName(route) {
return matches[1];
}
+/**
+ * Takes a meta data query and an array of resource queries
+ * and returns a promise for an object merging all queried
+ * data, pluralizing keys where necessary.
+ *
+ * Lil bit convoluted, sorry.
+ */
+function fetchTemplateData(meta, queries) {
+ return Q.promise(function(resolve, reject) {
+ return Q.all(
+ [meta.get()].concat(queries.map(function(query) {
+ return query.get();
+ })))
+ .then(function(results) {
+ var metadata = results.shift();
+ var normalized = results.reduce(function(cache, result, i) {
+ // Could be just some rogue request
+ if (!result) {
+ return cache;
+ }
+ var resource = queries[i].resource;
+ var type = queries[i].type;
+ var manyResult = isarray(result);
+ // Inform template of ID in generic field
+ if (manyResult) {
+ result = result.map(function(data) {
+ return assign({}, data, {id: data[resource.idField]})
+ });
+ } else {
+ result = assign({}, result, {id: result[resource.idField]});
+ }
+ // If we have a lot of results for a certain type,
+ // we pluralize the key and yield an array of results
+ if (cache[type] || manyResult) {
+ var plural = pluralize(type);
+ delete cache[type];
+ cache[plural] = [];
+ if (manyResult) {
+ cache[plural] = cache[plural].concat(result);
+ } else {
+ cache[plural].push(result);
+ }
+ } else {
+ cache[type] = result;
+ }
+ return cache;
+ }, {meta: metadata});
+ resolve(normalized);
+ }).fail(reject);
+ });
+}
+
module.exports = OKView;
diff --git a/app/node_modules/okview/package.json b/app/node_modules/okview/package.json
index 8d95b40..bbf4e40 100644
--- a/app/node_modules/okview/package.json
+++ b/app/node_modules/okview/package.json
@@ -8,5 +8,10 @@
},
"author": "OKFocus",
"license": "None",
- "dependencies": {}
+ "dependencies": {
+ "lodash.isarray": "^3.0.1",
+ "object-assign": "^2.0.0",
+ "pluralize": "^1.1.2",
+ "q": "^1.2.0"
+ }
}
diff --git a/examples/db.json b/examples/db.json
index bfce252..1d0cba4 100644
--- a/examples/db.json
+++ b/examples/db.json
@@ -1,22 +1,32 @@
{
- "meta": [
+ "meta": [],
+ "bread": [
{
- "project": "great project"
- }
- ],
- "project": [
+ "type": "rye",
+ "description": "really a very tasty bread!",
+ "id": "rye"
+ },
{
- "id": "cool"
+ "type": "bagel",
+ "description": "very good and dense bread",
+ "id": "bagel"
},
{
- "id": "lame"
+ "type": "pumpernickel",
+ "description": "grandma's recipe",
+ "id": "pumpernickel"
}
],
"page": [
{
- "id": "about",
- "title": "so page",
- "body": "we did it"
+ "title": "About Us",
+ "body": "Just a small bakery",
+ "id": "about"
+ },
+ {
+ "title": "contact",
+ "body": "2406 Old Rd, San Juan Bautista",
+ "id": "contact"
}
]
} \ No newline at end of file
diff --git a/examples/index.js b/examples/index.js
index c4fd3be..e55b717 100644
--- a/examples/index.js
+++ b/examples/index.js
@@ -6,29 +6,26 @@ var app = okcms.createApp({
schemas: {
page: {
- title: {type: 'string', id: true},
+ id: {type: 'string'},
+ title: {type: 'string'},
body: {type: 'string'}
},
- project: {
- title: {type: 'string', id: true},
- index: {type: 'integer'},
- category: {type: 'enum'},
- body: {type: 'string'},
- videos: [{type: 'uri'}],
- images: [{index: {type: 'string'}, uri: {type: 'uri'}}]
+ bread: {
+ type: {type: 'string', id: true},
+ description: {type: 'string'}
}
},
resources: [
- { type: 'page', data: {title: 'about'}},
- { type: 'page', data: {title: 'contact'}},
- { type: 'project' },
+ { type: 'page', static: {id: 'about'}},
+ { type: 'page', static: {id: 'contact'}},
+ { type: 'bread' },
],
views: {
'/': {
data: [
- {type: 'project', query: '*'},
+ {type: 'bread', query: '*'},
{type: 'page', query: '*'}
]
},
@@ -39,7 +36,7 @@ var app = okcms.createApp({
data: {type: 'page', query: 'contact'}
},
'/:id': {
- data: {type: 'project', query: ':id'}
+ data: {type: 'bread', query: ':id'}
}
}
diff --git a/examples/public/css/main.css b/examples/public/css/main.css
index 55f8e3b..92b2ee7 100644
--- a/examples/public/css/main.css
+++ b/examples/public/css/main.css
@@ -1 +1,18 @@
-/** woo doggy! **/
+html, body {
+ margin: 0;
+ passing: 0;
+ font-family: "Georgia", sans-serif;
+}
+
+body {
+ background-image: url(../images/brown046.jpg);
+}
+
+h1, h2, h3 {
+ color: rgb(12, 145, 14);
+}
+
+.container {
+ padding: 2em;
+ background-color: rgba(255, 255, 255, 0.5);
+}
diff --git a/examples/public/images/brown046.jpg b/examples/public/images/brown046.jpg
new file mode 100644
index 0000000..4b86003
--- /dev/null
+++ b/examples/public/images/brown046.jpg
Binary files differ
diff --git a/examples/templates/bread.liquid b/examples/templates/bread.liquid
new file mode 100644
index 0000000..da36cce
--- /dev/null
+++ b/examples/templates/bread.liquid
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <link rel="stylesheet" href="../css/main.css">
+ </head>
+ <body>
+ <div class="container">
+ <h1>{{bread.type | capitalize}}</h1>
+ <p>
+ {{bread.description}}
+ </p>
+ </div>
+ </body>
+</html>
diff --git a/examples/templates/index.liquid b/examples/templates/index.liquid
index 21d8764..7c12a86 100644
--- a/examples/templates/index.liquid
+++ b/examples/templates/index.liquid
@@ -4,11 +4,27 @@
<link rel="stylesheet" href="css/main.css">
</head>
<body>
- {{meta.project}}
- <ul>
- {% for project in projects %}
- <li>{{project.id}}</li>
- {% endfor %}
- </ul>
+ <div class="container">
+ <header>
+ <h1>Cheryl's Deli and Bakery</h1>
+ </header>
+ <section class="main">
+ <nav>
+ <ul>
+ {% for page in pages %}
+ <li><a href="{{page.id}}">{{page.id | capitalize}}</a></li>
+ {% endfor %}
+ </ul>
+ <nav>
+ <div class="breads">
+ <h2>Great breads:</h2>
+ <ul>
+ {% for bread in breads %}
+ <li><a href="{{bread.id}}">{{bread.id | capitalize}}</a></li>
+ {% endfor %}
+ </ul>
+ </div>
+ </section>
+ </div>
</body>
</html>
diff --git a/examples/templates/page.liquid b/examples/templates/page.liquid
index f7d899f..e90f7ce 100644
--- a/examples/templates/page.liquid
+++ b/examples/templates/page.liquid
@@ -1,5 +1,14 @@
-woop
-<h1>{{page.title}}</h1>
-<p>
- {{page.body}}
-</p>
+<!DOCTYPE html>
+<html>
+ <head>
+ <link rel="stylesheet" href="../css/main.css">
+ </head>
+ <body>
+ <div class="container">
+ <h1>{{page.title | capitalize}}</h1>
+ <p>
+ {{page.body}}
+ </p>
+ </div>
+ </body>
+</html>
diff --git a/examples/templates/project.liquid b/examples/templates/project.liquid
deleted file mode 100644
index dac1d8d..0000000
--- a/examples/templates/project.liquid
+++ /dev/null
@@ -1,2 +0,0 @@
-babaganuj
-{{project.id}}
diff --git a/package.json b/package.json
index 6388c89..1e7c1cd 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"license": "None",
"dependencies": {
"express": "^4.12.3",
- "object-assign": ""
+ "object-assign": "^2.0.0",
+ "q": "^1.2.0"
}
}
diff --git a/themes/okadmin/public/css/main.css b/themes/okadmin/public/css/main.css
new file mode 100644
index 0000000..88628e3
--- /dev/null
+++ b/themes/okadmin/public/css/main.css
@@ -0,0 +1,103 @@
+html, body {
+ margin: 0;
+ padding: 0;
+ font-family: "Helvetica", sans-serif;
+ font-size: 16px;
+}
+
+ul {
+ padding: 0;
+ list-style: none;
+}
+
+a {
+ color: #0000ff;
+ text-decoration: none;
+}
+
+a:hover {
+ border-bottom: 3px solid #0000ff;
+}
+
+a:visited {
+ color: #0000ff;
+}
+
+.admin-header {
+ height: 50px;
+ background-color: rgb(233, 233, 233);
+}
+
+.admin-header .breadcrumb {
+ margin-left: 2em;
+ font-size: 2em;
+ color: rgba(0, 0, 0, 0.25);
+ line-height: 50px;
+}
+
+.admin-header .site-link {
+ font-size: 1.5em;
+ float: right;
+ margin-right: 10%;
+ line-height: 50px;
+}
+
+.main.index .resource-category {
+ float: left;
+ min-width: 200px;
+ margin: 1em;
+ padding: 1em;
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+.main.index .resource-category a.add-new {
+ border-bottom: 3px solid rgba(0, 0, 0, 0);
+ float: right;
+ font-size: 1.5em;
+ color: rgba(0, 0, 0, 0.25);
+}
+
+.main.index .resource-category li {
+ margin: 1em 0;
+}
+
+.main.index .resource-category a.add-new:hover {
+ border-bottom: 3px solid rgba(0, 0, 0, 0.25);
+}
+
+.main.resource > * {
+ margin: 1em 1em;
+}
+
+.main.resource form {
+ background-color: rgba(0, 0, 0, 0.1);
+ max-width: 500px;
+ padding: 1em;
+ font-size: 1.25em;
+}
+
+.main.resource form label {
+ display: block;
+ margin-bottom: 0.25em;
+ color: rgba(0, 0, 0, 0.75);
+}
+
+.main.resource form .property {
+ margin: 1em 0;
+}
+
+.main.resource form input {
+ display: block;
+ font-size: 1.25em;
+ min-height: 2em;
+ padding: 0 0.5em;
+}
+
+.main.resource form button {
+ font-size: 1.25em;
+ float: right;
+}
+
+.clear {
+ clear: both;
+}
diff --git a/themes/okadmin/templates/index.liquid b/themes/okadmin/templates/index.liquid
new file mode 100644
index 0000000..95c64dd
--- /dev/null
+++ b/themes/okadmin/templates/index.liquid
@@ -0,0 +1,26 @@
+{% include 'partials/head' %}
+
+<section class="index main">
+ {% for pair in resources %}
+ {% assign name = pair[0] %}
+ {% assign resource = pair[1] %}
+ {% assign spec = resource.spec %}
+
+ <section class="resource-category {{name}}">
+ <header>
+ <h2>{{name | capitalize}}</h2>
+ </header>
+ <ul class="resource-list">
+ {% for data in resource.data %}
+ <li><a href="{{resource.type}}/{{data.id}}/">{{data.id}}</a></li>
+ {% endfor %}
+ </ul>
+ <footer>
+ <a class="add-new" href="{{resource.type}}/new/">+</a>
+ </footer>
+ </section>
+
+ {% endfor %}
+</section>
+
+{% include 'partials/tail' %}
diff --git a/themes/okadmin/templates/partials/head.liquid b/themes/okadmin/templates/partials/head.liquid
new file mode 100644
index 0000000..86915a4
--- /dev/null
+++ b/themes/okadmin/templates/partials/head.liquid
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf8">
+ <title>{{meta.title}}</title>
+ <link rel="stylesheet" href="{{meta.static}}/css/main.css">
+ </head>
+ <body>
+ <header class="admin-header">
+ <span class="breadcrumb">Admin</span>
+ <a class="site-link" href="/">View Site</a>
+ </header>
+ <div class="container">
diff --git a/themes/okadmin/templates/partials/inputs.liquid b/themes/okadmin/templates/partials/inputs.liquid
new file mode 100644
index 0000000..4dd600d
--- /dev/null
+++ b/themes/okadmin/templates/partials/inputs.liquid
@@ -0,0 +1,19 @@
+{% for pair in resource.spec %}
+ {% assign name = pair[0] %}
+ {% assign spec = pair[1] %}
+ {% assign type = spec.type %}
+
+ <div class="property">
+ {% if type == 'string' %}
+ <label for="{{name}}">{{name | capitalize}}</label>
+ <input
+ {% if spec.disabled %}
+ disabled="true"
+ {% endif %}
+ name="{{name}}" type="text" value="{{spec.value}}">
+ {% else %}
+ <p><pre style="color: red">Admin template doesn't support '{{type}}' properties!</pre></p>
+ {% endif %}
+ </div>
+
+{% endfor %}
diff --git a/themes/okadmin/templates/partials/tail.liquid b/themes/okadmin/templates/partials/tail.liquid
new file mode 100644
index 0000000..773c8d4
--- /dev/null
+++ b/themes/okadmin/templates/partials/tail.liquid
@@ -0,0 +1,3 @@
+ </div> {% comment %} closes container tag {% endcomment %}
+ </body>
+</html>
diff --git a/themes/okadmin/templates/resource.liquid b/themes/okadmin/templates/resource.liquid
new file mode 100644
index 0000000..9c1b71c
--- /dev/null
+++ b/themes/okadmin/templates/resource.liquid
@@ -0,0 +1,15 @@
+{% include 'partials/head' %}
+
+<section class="resource main">
+ <nav>
+ <a href="../..">Back</a>
+ </nav>
+ <form action="." method="POST">
+ <input type="hidden" name="_method" value="PUT">
+ {% include 'partials/inputs' %}
+ <button type="submit">Save</button>
+ <div class="clear"></div>
+ </form>
+</section>
+
+{% include 'partials/tail' %}
diff --git a/themes/okadmin/templates/resource_new.liquid b/themes/okadmin/templates/resource_new.liquid
new file mode 100644
index 0000000..1e414be
--- /dev/null
+++ b/themes/okadmin/templates/resource_new.liquid
@@ -0,0 +1,15 @@
+{% include 'partials/head' %}
+
+<section class="main resource resource-new">
+ <nav>
+ <a href="../..">Back</a>
+ </nav>
+ <form action=".." method="POST">
+ <input type="hidden" name="_method" value="POST">
+ {% include 'partials/inputs' %}
+ <button type="submit">Create</button>
+ <div class="clear"></div>
+ </form>
+</section>
+
+{% include 'partials/tail' %}