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