diff --git a/make.go b/make.go
index 756e4f05a..7b281d20d 100644
--- a/make.go
+++ b/make.go
@@ -345,7 +345,8 @@ func genEmbeds() error {
uiEmbeds := buildSrcPath("server/camlistored/ui")
serverEmbeds := buildSrcPath("pkg/server")
reactEmbeds := buildSrcPath("third_party/react")
- for _, embeds := range []string{uiEmbeds, serverEmbeds, reactEmbeds} {
+ glitchEmbeds := buildSrcPath("third_party/glitch")
+ for _, embeds := range []string{uiEmbeds, serverEmbeds, reactEmbeds, glitchEmbeds} {
args := []string{embeds}
cmd := exec.Command(cmdName, args...)
cmd.Env = append(cleanGoEnv(),
diff --git a/pkg/server/ui.go b/pkg/server/ui.go
index c191f1f0b..4b1579ce4 100644
--- a/pkg/server/ui.go
+++ b/pkg/server/ui.go
@@ -40,6 +40,7 @@ import (
"camlistore.org/pkg/sorted"
uistatic "camlistore.org/server/camlistored/ui"
closurestatic "camlistore.org/server/camlistored/ui/closure"
+ glitchstatic "camlistore.org/third_party/glitch"
reactstatic "camlistore.org/third_party/react"
)
@@ -57,6 +58,7 @@ var (
treePattern = regexp.MustCompile(`^tree/([^/]+)(/.*)?$`)
closurePattern = regexp.MustCompile(`^closure/(([^/]+)(/.*)?)$`)
reactPattern = regexp.MustCompile(`^react/(.+)$`)
+ glitchPattern = regexp.MustCompile(`^glitch/(.+)$`)
disableThumbCache, _ = strconv.ParseBool(os.Getenv("CAMLI_DISABLE_THUMB_CACHE"))
)
@@ -91,8 +93,9 @@ type UIHandler struct {
uiDir string // if sourceRoot != "", this is sourceRoot+"/server/camlistored/ui"
- closureHandler http.Handler
- fileReactHandler http.Handler
+ closureHandler http.Handler
+ fileReactHandler http.Handler
+ fileGlitchHandler http.Handler
}
func init() {
@@ -213,10 +216,14 @@ func uiFromConfig(ld blobserver.Loader, conf jsonconfig.Obj) (h http.Handler, er
}
if ui.sourceRoot != "" {
- ui.fileReactHandler, err = ui.makeEmbeddedReactHandler(ui.sourceRoot)
+ ui.fileReactHandler, err = makeFileServer(ui.sourceRoot, filepath.Join("third_party", "react"), "react.js")
if err != nil {
return nil, fmt.Errorf("Could not make react handler: %s", err)
}
+ ui.fileGlitchHandler, err = makeFileServer(ui.sourceRoot, filepath.Join("third_party", "glitch"), "npc_piggy__x1_walk_png_1354829432.png")
+ if err != nil {
+ return nil, fmt.Errorf("Could not make glitch handler: %s", err)
+ }
}
rootPrefix, _, err := ld.FindHandlerByType("root")
@@ -237,15 +244,6 @@ func (ui *UIHandler) makeClosureHandler(root string) (http.Handler, error) {
return makeClosureHandler(root, "ui")
}
-func (ui *UIHandler) makeEmbeddedReactHandler(root string) (http.Handler, error) {
- path := filepath.Join("third_party", "react")
- h, err := makeFileServer(root, path, "react.js")
- if h != nil {
- log.Printf("Serving React from %s", path)
- }
- return h, err
-}
-
// makeClosureHandler returns a handler to serve Closure files.
// root is either:
// 1) empty: use the Closure files compiled in to the binary (if
@@ -277,11 +275,7 @@ func makeClosureHandler(root, handlerName string) (http.Handler, error) {
}
path := filepath.Join("third_party", "closure", "lib", "closure")
- fs, err := makeFileServer(root, path, filepath.Join("goog", "base.js"))
- if fs != nil {
- log.Printf("%v: serving Closure from disk: %s", handlerName, filepath.Join(root, path))
- }
- return fs, err
+ return makeFileServer(root, path, filepath.Join("goog", "base.js"))
}
func makeFileServer(sourceRoot string, pathToServe string, expectedContentPath string) (http.Handler, error) {
@@ -344,18 +338,10 @@ func wantsDetailPage(req *http.Request) bool {
return httputil.IsGet(req) && httputil.PathSuffix(req) == "detail.html"
}
-func wantsClosure(req *http.Request) bool {
+func getSuffixMatches(req *http.Request, pattern *regexp.Regexp) bool {
if httputil.IsGet(req) {
suffix := httputil.PathSuffix(req)
- return closurePattern.MatchString(suffix)
- }
- return false
-}
-
-func wantsReact(req *http.Request) bool {
- if httputil.IsGet(req) {
- suffix := httputil.PathSuffix(req)
- return reactPattern.MatchString(suffix)
+ return pattern.MatchString(suffix)
}
return false
}
@@ -375,10 +361,12 @@ func (ui *UIHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
ui.serveThumbnail(rw, req)
case strings.HasPrefix(suffix, "tree/"):
ui.serveFileTree(rw, req)
- case wantsClosure(req):
+ case getSuffixMatches(req, closurePattern):
ui.serveClosure(rw, req)
- case wantsReact(req):
- ui.serveReact(rw, req)
+ case getSuffixMatches(req, reactPattern):
+ ui.serveFromDiskOrStatic(rw, req, reactPattern, ui.fileReactHandler, reactstatic.Files)
+ case getSuffixMatches(req, glitchPattern):
+ ui.serveFromDiskOrStatic(rw, req, glitchPattern, ui.fileGlitchHandler, glitchstatic.Files)
default:
file := ""
if m := staticFilePattern.FindStringSubmatch(suffix); m != nil {
@@ -570,20 +558,21 @@ func (ui *UIHandler) serveClosure(rw http.ResponseWriter, req *http.Request) {
ui.closureHandler.ServeHTTP(rw, req)
}
-func (ui *UIHandler) serveReact(rw http.ResponseWriter, req *http.Request) {
+// serveFromDiskOrStatic matches rx against req's path and serves the match either from disk (if non-nil) or from static (embedded in the binary).
+func (ui *UIHandler) serveFromDiskOrStatic(rw http.ResponseWriter, req *http.Request, rx *regexp.Regexp, disk http.Handler, static *fileembed.Files) {
suffix := httputil.PathSuffix(req)
- m := reactPattern.FindStringSubmatch(suffix)
+ m := rx.FindStringSubmatch(suffix)
if m == nil {
- httputil.ErrorRouting(rw, req)
- return
+ panic("Caller should verify that rx matches")
}
file := m[1]
- if ui.fileReactHandler != nil {
+ if disk != nil {
req.URL.Path = "/" + file
- ui.fileReactHandler.ServeHTTP(rw, req)
+ disk.ServeHTTP(rw, req)
} else {
- serveStaticFile(rw, req, reactstatic.Files, file)
+ serveStaticFile(rw, req, static, file)
}
+
}
// serveDepsJS serves an auto-generated Closure deps.js file.
diff --git a/server/camlistored/ui/detail.css b/server/camlistored/ui/detail.css
index 4363ba948..a4c14d28e 100644
--- a/server/camlistored/ui/detail.css
+++ b/server/camlistored/ui/detail.css
@@ -9,10 +9,17 @@ body {
overflow: hidden;
}
-.detail-view-preview {
+.detail-view-img {
position: absolute;
- width: auto;
- height: auto;
+}
+
+.detail-img-enter {
+ transition: opacity 200ms linear;
+ opacity: 0;
+}
+
+.detail-img-enter.detail-img-enter-active {
+ opacity: 1;
}
.detail-view-sidebar {
@@ -24,6 +31,19 @@ body {
top: 0;
}
+.detail-view-piggy {
+ position: absolute;
+}
+
+.detail-piggy-leave {
+ transition: opacity 200ms linear;
+ opacity: 1;
+}
+
+.detail-piggy-leave.detail-piggy-leave-active {
+ opacity: 0;
+}
+
/* http://www.paulirish.com/2012/box-sizing-border-box-ftw/ */
*, *:before, *:after {
-moz-box-sizing: border-box;
diff --git a/server/camlistored/ui/detail.html b/server/camlistored/ui/detail.html
index 90a3f5e1c..3051fbe51 100644
--- a/server/camlistored/ui/detail.html
+++ b/server/camlistored/ui/detail.html
@@ -10,7 +10,7 @@
-
+
diff --git a/server/camlistored/ui/detail.js b/server/camlistored/ui/detail.js
index c4e848af5..648ef028d 100644
--- a/server/camlistored/ui/detail.js
+++ b/server/camlistored/ui/detail.js
@@ -16,72 +16,92 @@ limitations under the License.
goog.require('camlistore.AnimationLoop');
goog.require('camlistore.ServerConnection');
+goog.require('SpritedAnimation');
goog.require('goog.math.Size');
-
-function cached(fn) {
- var lastProps;
- var lastState;
- var lastVal;
- return function() {
- if (lastState == this.state && lastProps == this.props) {
- return lastVal;
- }
- lastProps = this.props;
- lastState = this.state;
- lastVal = fn.apply(this, arguments);
- return lastVal;
- }
-}
+goog.require('goog.object');
var DetailView = React.createClass({
- PREVIEW_MARGIN: 20,
+ IMG_MARGIN: 20,
+ PIGGY_WIDTH: 88,
+ PIGGY_HEIGHT: 62,
+
+ getInitialState: function() {
+ return {
+ description: null,
+ };
+ },
componentDidMount: function(root) {
var imageSize = 100; // We won't use this exact value; we only care about the aspect ratio.
var connection = new camlistore.ServerConnection(this.props.config);
connection.describeWithThumbnails(this.props.blobref, imageSize, function(description) {
- this.setState({
+ this.setState({
description: description
});
}.bind(this));
},
- getPreviewSize_: cached(function() {
- var meta = this.getPermanodeMeta_();
- if (!meta) {
- return;
- }
-
- var aspect = new goog.math.Size(meta.thumbnailWidth, meta.thumbnailHeight);
- var available = new goog.math.Size(
- this.props.width - this.getSidebarWidth_() - this.PREVIEW_MARGIN * 2,
- this.props.height - this.PREVIEW_MARGIN * 2);
- return aspect.scaleToFit(available);
- }),
-
render: function() {
- var description = this.state ? this.state.description : '';
+ this.imgSize_ = this.getImgSize_();
return (
React.DOM.div({className:'detail-view', style: this.getStyle_()},
- React.DOM.img({className:'detail-view-preview', key:'preview', src: this.getSrc_(), style: this.getPreviewStyle_()}),
+ this.getImg_(),
+ this.getPiggy_(),
React.DOM.div({className:'detail-view-sidebar', key:'sidebar', style: this.getSidebarStyle_()},
- React.DOM.pre({key:'sidebar-pre'}, JSON.stringify(description, null, 2)))));
+ React.DOM.pre({key:'sidebar-pre'}, JSON.stringify(this.state.description || '', null, 2)))));
+ },
+
+ getImg_: function() {
+ var transition = React.addons.TransitionGroup({transitionName: 'detail-img'}, []);
+ if (this.state.description) {
+ transition.props.children.push(
+ React.DOM.img({
+ className: 'detail-view-img',
+ key: 'img',
+ src: this.getSrc_(),
+ style: this.getCenteredProps_(this.imgSize_.width, this.imgSize_.height)
+ })
+ );
+ }
+ return transition;
+ },
+
+ getPiggy_: function() {
+ var transition = React.addons.TransitionGroup({transitionName: 'detail-piggy'}, []);
+ if (!this.state.description) {
+ transition.props.children.push(
+ SpritedAnimation({
+ src: 'glitch/npc_piggy__x1_walk_png_1354829432.png',
+ className: 'detail-view-piggy',
+ spriteWidth: this.PIGGY_WIDTH,
+ spriteHeight: this.PIGGY_HEIGHT,
+ sheetWidth: 8,
+ sheetHeight: 3,
+ interval: 30,
+ style: this.getCenteredProps_(this.PIGGY_WIDTH, this.PIGGY_HEIGHT)
+ }));
+ }
+ return transition;
+ },
+
+ getCenteredProps_: function(w, h) {
+ var avail = new goog.math.Size(this.props.width - this.getSidebarWidth_(), this.props.height);
+ return {
+ top: (avail.height - h) / 2,
+ left: (avail.width - w) / 2,
+ width: w,
+ height: h
+ }
},
getSrc_: function() {
- if (!this.state) {
- // TODO(aa): Loading animation
- return '';
- }
-
- var previewSize = this.getPreviewSize_();
// Only re-request the image if we're increasing in size. Otherwise, let the browser resample.
- if (previewSize.height < (this.lastImageHeight || 0)) {
+ if (this.imgSize_.height < (this.lastImageHeight || 0)) {
console.log('Not re-requesting image becasue new size is smaller than existing...');
} else {
// If we re-request, ask for one bigger than we need right now, so that we're not constantly re-requesting as the browser resizes.
- this.lastImageHeight = previewSize.height * 1.25;
+ this.lastImageHeight = this.imgSize_.height * 1.25;
console.log('Requesting new image with size: ' + this.lastImageHeight);
}
@@ -90,6 +110,19 @@ var DetailView = React.createClass({
return uri.toString();
},
+ getImgSize_: function() {
+ if (!this.state.description) {
+ return null;
+ }
+
+ var meta = this.getPermanodeMeta_();
+ var aspect = new goog.math.Size(meta.thumbnailWidth, meta.thumbnailHeight);
+ var available = new goog.math.Size(
+ this.props.width - this.getSidebarWidth_() - this.IMG_MARGIN * 2,
+ this.props.height - this.IMG_MARGIN * 2);
+ return aspect.scaleToFit(available);
+ },
+
getStyle_: function() {
return {
width: this.props.width,
@@ -97,22 +130,6 @@ var DetailView = React.createClass({
}
},
- getPreviewStyle_: function() {
- if (!this.state || !this.getPreviewSize_().height) {
- return {
- visibility: 'hidden'
- }
- }
-
- var avail = new goog.math.Size(this.props.width - this.getSidebarWidth_(), this.props.height);
- return {
- top: (avail.height - this.getPreviewSize_().height) / 2,
- left: (avail.width - this.getPreviewSize_().width) / 2,
- width: this.getPreviewSize_().width,
- height: this.getPreviewSize_().height
- }
- },
-
getSidebarStyle_: function() {
return {
width: this.getSidebarWidth_()
@@ -124,9 +141,6 @@ var DetailView = React.createClass({
},
getPermanodeMeta_: function() {
- if (!this.state) {
- return null;
- }
return this.state.description.meta[this.props.blobref];
- }
+ },
});
diff --git a/server/camlistored/ui/object.js b/server/camlistored/ui/object.js
new file mode 100644
index 000000000..bf4281ff1
--- /dev/null
+++ b/server/camlistored/ui/object.js
@@ -0,0 +1,11 @@
+/**
+ * Object related utilities beyond what exist in Closure.
+ */
+goog.provide('object');
+
+function extend(o, n) {
+ var obj = {};
+ goog.mixin(obj, o);
+ goog.mixin(obj, n);
+ return obj;
+}
diff --git a/server/camlistored/ui/sprited_animation.js b/server/camlistored/ui/sprited_animation.js
new file mode 100644
index 000000000..5bb3be71c
--- /dev/null
+++ b/server/camlistored/ui/sprited_animation.js
@@ -0,0 +1,28 @@
+goog.provide('SpritedAnimation');
+
+goog.require('SpritedImage');
+goog.require('object');
+
+var SpritedAnimation = React.createClass({
+ getInitialState: function() {
+ return {
+ index: 0
+ }
+ },
+
+ componentDidMount: function(root) {
+ this.timerId_ = window.setInterval(function() {
+ this.setState({
+ index: ++this.state.index % (this.props.sheetWidth * this.props.sheetHeight)
+ })
+ }.bind(this), this.props.interval);
+ },
+
+ componentWillUnmount: function() {
+ window.clearInterval(this.timerId_);
+ },
+
+ render: function() {
+ return SpritedImage(extend(this.props, {index: this.state.index}));
+ }
+});
diff --git a/server/camlistored/ui/sprited_image.js b/server/camlistored/ui/sprited_image.js
new file mode 100644
index 000000000..228fe8712
--- /dev/null
+++ b/server/camlistored/ui/sprited_image.js
@@ -0,0 +1,27 @@
+goog.provide('SpritedImage');
+
+goog.require('goog.object');
+goog.require('goog.string');
+
+goog.require('object');
+
+var SpritedImage = React.createClass({
+ render: function() {
+ return (
+ React.DOM.div({className: this.props.className, style: extend(this.props.style, {overflow: 'hidden'})},
+ React.DOM.img({src: this.props.src, style: this.getImgStyle_()})));
+ },
+
+ getImgStyle_: function() {
+ var x = this.props.index % this.props.sheetWidth;
+ var y = Math.floor(this.props.index / this.props.sheetWidth);
+ if (y >= this.props.sheetHeight) {
+ throw new Error(goog.string.subs('Index %s out of range', this.props.index));
+ }
+ return {
+ position: 'absolute',
+ left: -x * this.props.spriteWidth,
+ top: -y * this.props.spriteHeight
+ };
+ }
+});
diff --git a/third_party/glitch/LICENSE b/third_party/glitch/LICENSE
new file mode 100644
index 000000000..7efba3b1c
--- /dev/null
+++ b/third_party/glitch/LICENSE
@@ -0,0 +1,19 @@
+The files in here come from www.glitchthegame.com.
+
+License here: http://www.glitchthegame.com/public-domain-game-art/#licensing
+
+All files are provided by Tiny Speck under the Creative Commons CC0 1.0
+Universal License. This is a broadly permissive "No Rights Reserved" license —
+you may do what you please with what we've provided. Our intention is to
+dedicate these works to the public domain and make them freely available to all,
+without restriction. All files are provided AS-IS. Tiny Speck cannot provide any
+support to help you bring these assets into your own projects.
+
+Note: the Glitch logo and trademark are not among the things we are making
+available under this license. Only items in the files explicitly included herein
+are covered.
+
+There is no obligation to link or credit the works, but if you do, please link
+to glitchthegame.com, our permanent "retirement" site for the game and these
+assets. Of course, links/shoutouts to Tiny Speck (tinyspeck.com) and/or Slack
+(slack.com) are appreciated.
diff --git a/third_party/glitch/fileembed.go b/third_party/glitch/fileembed.go
new file mode 100644
index 000000000..4eb1bfa53
--- /dev/null
+++ b/third_party/glitch/fileembed.go
@@ -0,0 +1,24 @@
+/*
+Copyright 2013 Google Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+#fileembed pattern .*\.png
+*/
+package glitch
+
+import "camlistore.org/pkg/fileembed"
+
+var Files = &fileembed.Files{}
diff --git a/third_party/glitch/npc_piggy__x1_walk_png_1354829432.png b/third_party/glitch/npc_piggy__x1_walk_png_1354829432.png
new file mode 100644
index 000000000..20af4ac88
Binary files /dev/null and b/third_party/glitch/npc_piggy__x1_walk_png_1354829432.png differ
diff --git a/third_party/react/fileembed.go b/third_party/react/fileembed.go
index a3c413e75..780885ede 100644
--- a/third_party/react/fileembed.go
+++ b/third_party/react/fileembed.go
@@ -15,7 +15,7 @@ limitations under the License.
*/
/*
-#fileembed pattern .*react(\.min)?\.js$
+#fileembed pattern .*\.js$
*/
package react