diff --git a/pkg/blobserver/mongo/mongo_test.go b/pkg/blobserver/mongo/mongo_test.go index 86988019b..d50154508 100644 --- a/pkg/blobserver/mongo/mongo_test.go +++ b/pkg/blobserver/mongo/mongo_test.go @@ -30,7 +30,7 @@ func TestMongoStorage(t *testing.T) { // SetupMongoContainer may skip or fatal the test if docker isn't found or something goes wrong when setting up the container. // Thus, no error is returned containerID, ip := dockertest.SetupMongoContainer(t) - defer dockertest.KillContainer(containerID) + defer containerID.Kill() sto, err := newMongoStorage(config{ server: ip, diff --git a/pkg/index/mysql/mysql_test.go b/pkg/index/mysql/mysql_test.go index 8bef56aff..dc4c41f0a 100644 --- a/pkg/index/mysql/mysql_test.go +++ b/pkg/index/mysql/mysql_test.go @@ -19,7 +19,6 @@ package mysql_test import ( "database/sql" "errors" - "fmt" "log" "sync" "testing" @@ -81,16 +80,8 @@ func newSorted(t *testing.T) (kv sorted.KeyValue, clean func()) { do(rootdb, "DROP DATABASE IF EXISTS "+dbname) do(rootdb, "CREATE DATABASE "+dbname) - db, err := sql.Open("mymysql", dbname+"/root/root") - if err != nil { - t.Fatalf("opening test database: " + err.Error()) - } - for _, tableSql := range mysql.SQLCreateTables() { - do(db, tableSql) - } - do(db, fmt.Sprintf(`REPLACE INTO meta VALUES ('version', '%d')`, mysql.SchemaVersion())) - - kv, err = mysql.NewKeyValue(mysql.Config{ + kv, err := mysql.NewKeyValue(mysql.Config{ + Host: "localhost:3306", Database: dbname, User: "root", Password: "root", diff --git a/pkg/sorted/mongo/mongokv_test.go b/pkg/sorted/mongo/mongokv_test.go index 1a631433e..4e96d7dad 100644 --- a/pkg/sorted/mongo/mongokv_test.go +++ b/pkg/sorted/mongo/mongokv_test.go @@ -23,21 +23,19 @@ import ( "camlistore.org/pkg/test/dockertest" ) -const mongoImage = "robinvdvleuten/mongo" - // TestMongoKV tests against a real MongoDB instance, using a Docker container. -// Currently using https://index.docker.io/u/robinvdvleuten/mongo/ func TestMongoKV(t *testing.T) { // SetupMongoContainer may skip or fatal the test if docker isn't found or something goes wrong when setting up the container. // Thus, no error is returned containerID, ip := dockertest.SetupMongoContainer(t) - defer dockertest.KillContainer(containerID) + defer containerID.Kill() + kv, err := NewKeyValue(Config{ Server: ip, Database: "camlitest", }) if err != nil { - t.Fatalf("monogo.NewKeyValue = %v", err) + t.Fatalf("mongo.NewKeyValue = %v", err) } kvtest.TestSorted(t, kv) } diff --git a/pkg/sorted/mysql/dbschema.go b/pkg/sorted/mysql/dbschema.go index 3028a1f25..6d6680d9a 100644 --- a/pkg/sorted/mysql/dbschema.go +++ b/pkg/sorted/mysql/dbschema.go @@ -28,12 +28,12 @@ func SchemaVersion() int { // which is purely about bytes. func SQLCreateTables() []string { return []string{ - `CREATE TABLE rows ( + `CREATE TABLE IF NOT EXISTS rows ( k VARCHAR(255) NOT NULL PRIMARY KEY, v VARCHAR(255)) DEFAULT CHARACTER SET binary`, - `CREATE TABLE meta ( + `CREATE TABLE IF NOT EXISTS meta ( metakey VARCHAR(255) NOT NULL PRIMARY KEY, value VARCHAR(255) NOT NULL) DEFAULT CHARACTER SET binary`, diff --git a/pkg/sorted/mysql/mysqlkv.go b/pkg/sorted/mysql/mysqlkv.go index a155bf5a6..62faba691 100644 --- a/pkg/sorted/mysql/mysqlkv.go +++ b/pkg/sorted/mysql/mysqlkv.go @@ -22,6 +22,7 @@ import ( "database/sql" "fmt" "os" + "strings" "camlistore.org/pkg/jsonconfig" "camlistore.org/pkg/sorted" @@ -36,7 +37,11 @@ func init() { // Config holds the parameters used to connect to the MySQL db. type Config struct { - Host string // Optional. Defaults to "localhost" in ConfigFromJSON. + // Host optionally specifies the address on which mysqld listens. It should + // be of the form hostname:port, or addr:port. If empty, a local connection + // will be used. If the address does not have a colon, it is assumed the + // port is missing and the default MySQL (3306) one will be set in ConfigFromJSON. + Host string Database string // Required. User string // Required. Password string // Optional. @@ -45,8 +50,14 @@ type Config struct { // ConfigFromJSON populates Config from config, and validates // config. It returns an error if config fails to validate. func ConfigFromJSON(config jsonconfig.Obj) (Config, error) { + host := config.OptionalString("host", "") + if host != "" { + if !strings.Contains(host, ":") { + host = host + ":3306" + } + } conf := Config{ - Host: config.OptionalString("host", "localhost"), + Host: host, User: config.RequiredString("user"), Password: config.OptionalString("password", ""), Database: config.RequiredString("database"), @@ -67,14 +78,23 @@ func newKeyValueFromJSONConfig(cfg jsonconfig.Obj) (sorted.KeyValue, error) { // NewKeyValue returns a sorted.KeyValue implementation of the described MySQL database. func NewKeyValue(cfg Config) (sorted.KeyValue, error) { - // TODO(bradfitz,mpl): host is ignored for now. I think we can connect to host with: - // tcp:ADDR*DBNAME/USER/PASSWD (http://godoc.org/github.com/ziutek/mymysql/godrv#Driver.Open) - // I suppose we'll have to do a lookup first. dsn := cfg.Database + "/" + cfg.User + "/" + cfg.Password + if cfg.Host != "" { + dsn = "tcp:" + cfg.Host + "*" + dsn + } db, err := sql.Open("mymysql", dsn) if err != nil { return nil, err } + for _, tableSql := range SQLCreateTables() { + if _, err := db.Exec(tableSql); err != nil { + return nil, fmt.Errorf("error creating table with %q: %v", tableSql, err) + } + } + if _, err := db.Exec(fmt.Sprintf(`REPLACE INTO meta VALUES ('version', '%d')`, SchemaVersion())); err != nil { + return nil, fmt.Errorf("error setting schema version: %v", err) + } + kv := &keyValue{ db: db, KeyValue: &sqlkv.KeyValue{ diff --git a/pkg/sorted/mysql/mysqlkv_test.go b/pkg/sorted/mysql/mysqlkv_test.go new file mode 100644 index 000000000..c956670d1 --- /dev/null +++ b/pkg/sorted/mysql/mysqlkv_test.go @@ -0,0 +1,43 @@ +/* +Copyright 2014 The Camlistore Authors + +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. +*/ + +package mysql + +import ( + "testing" + + "camlistore.org/pkg/osutil" + "camlistore.org/pkg/sorted/kvtest" + "camlistore.org/pkg/test/dockertest" +) + +// TestMySQLKV tests against a real MySQL instance, using a Docker container. +func TestMySQLKV(t *testing.T) { + dbname := "camlitest_" + osutil.Username() + containerID, ip := dockertest.SetupMySQLContainer(t, dbname) + defer containerID.Kill() + + kv, err := NewKeyValue(Config{ + Host: ip + ":3306", + Database: dbname, + User: "root", + Password: "root", + }) + if err != nil { + t.Fatalf("mysql.NewKeyValue = %v", err) + } + kvtest.TestSorted(t, kv) +} diff --git a/pkg/test/dockertest/docker.go b/pkg/test/dockertest/docker.go index a50045243..76e4fc65a 100644 --- a/pkg/test/dockertest/docker.go +++ b/pkg/test/dockertest/docker.go @@ -24,11 +24,42 @@ import ( "encoding/json" "errors" "fmt" + "log" "os/exec" "strings" + "testing" + "time" + + "camlistore.org/pkg/netutil" ) -func HaveImage(name string) (ok bool, err error) { +/// runLongTest checks all the conditions for running a docker container +// based on image. +func runLongTest(t *testing.T, image string) { + if testing.Short() { + t.Skip("skipping in short mode") + } + if !haveDocker() { + t.Skip("skipping test; 'docker' command not found") + } + if ok, err := haveImage(image); !ok || err != nil { + if err != nil { + t.Skipf("Error running docker to check for %s: %v", image, err) + } + log.Printf("Pulling docker image %s ...", image) + if err := Pull(image); err != nil { + t.Skipf("Error pulling %s: %v", image, err) + } + } +} + +// haveDocker returns whether the "docker" command was found. +func haveDocker() bool { + _, err := exec.LookPath("docker") + return err == nil +} + +func haveImage(name string) (ok bool, err error) { out, err := exec.Command("docker", "images", "--no-trunc").Output() if err != nil { return @@ -36,12 +67,15 @@ func HaveImage(name string) (ok bool, err error) { return bytes.Contains(out, []byte(name)), nil } -func Run(args ...string) (containerID string, err error) { - runOut, err := exec.Command("docker", append([]string{"run"}, args...)...).Output() - if err != nil { +func run(args ...string) (containerID string, err error) { + cmd := exec.Command("docker", append([]string{"run"}, args...)...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err = cmd.Run(); err != nil { + err = fmt.Errorf("%v%v", stderr.String(), err) return } - containerID = strings.TrimSpace(string(runOut)) + containerID = strings.TrimSpace(stdout.String()) if containerID == "" { return "", errors.New("unexpected empty output from `docker run`") } @@ -52,14 +86,16 @@ func KillContainer(container string) error { return exec.Command("docker", "kill", container).Run() } -func Pull(name string) error { - out, err := exec.Command("docker", "pull", name).CombinedOutput() +// Pull retrieves the docker image with 'docker pull'. +func Pull(image string) error { + out, err := exec.Command("docker", "pull", image).CombinedOutput() if err != nil { err = fmt.Errorf("%v: %s", err, out) } return err } +// IP returns the IP address of the container. func IP(containerID string) (string, error) { out, err := exec.Command("docker", "inspect", containerID).Output() if err != nil { @@ -81,5 +117,75 @@ func IP(containerID string) (string, error) { if ip := c[0].NetworkSettings.IPAddress; ip != "" { return ip, nil } - return "", errors.New("no IP. Not running?") + return "", fmt.Errorf("could not find an IP for %v. Not running?", containerID) +} + +type ContainerID string + +func (c ContainerID) IP() (string, error) { + return IP(string(c)) +} + +func (c ContainerID) Kill() error { + return KillContainer(string(c)) +} + +// lookup retrieves the ip address of the container, and tries to reach +// before timeout the tcp address at this ip and given port. +func (c ContainerID) lookup(port int, timeout time.Duration) (ip string, err error) { + ip, err = c.IP() + if err != nil { + err = fmt.Errorf("Error getting container IP: %v", err) + return + } + addr := fmt.Sprintf("%s:%d", ip, port) + if err = netutil.AwaitReachable(addr, timeout); err != nil { + err = fmt.Errorf("timeout trying to reach %s for container %v: %v", addr, c, err) + } + return +} + +const ( + mongoImage = "robinvdvleuten/mongo" + mysqlImage = "orchardup/mysql" +) + +// SetupMongoContainer sets up a real MongoDB instance for testing purposes, +// using a Docker container. It returns the container ID and its IP address, +// or makes the test fail on error. +// Currently using https://index.docker.io/u/robinvdvleuten/mongo/ +func SetupMongoContainer(t *testing.T) (c ContainerID, ip string) { + runLongTest(t, mongoImage) + + containerID, err := run("-d", mongoImage, "--smallfiles") + if err != nil { + t.Fatalf("docker run: %v", err) + } + c = ContainerID(containerID) + ip, err = c.lookup(27017, 20*time.Second) + if err != nil { + c.Kill() + t.Fatalf("container lookup: %v", err) + } + return +} + +// SetupMySQLContainer sets up a real MySQL instance for testing purposes, +// using a Docker container. It returns the container ID and its IP address, +// or makes the test fail on error. +// Currently using https://index.docker.io/u/orchardup/mysql/ +func SetupMySQLContainer(t *testing.T, dbname string) (c ContainerID, ip string) { + runLongTest(t, mongoImage) + + containerID, err := run("-d", "-e", "MYSQL_ROOT_PASSWORD=root", "-e", "MYSQL_DATABASE="+dbname, mysqlImage) + if err != nil { + t.Fatalf("docker run: %v", err) + } + c = ContainerID(containerID) + ip, err = c.lookup(3306, 10*time.Second) + if err != nil { + c.Kill() + t.Fatalf("container lookup: %v", err) + } + return } diff --git a/pkg/test/dockertest/mongo.go b/pkg/test/dockertest/mongo.go deleted file mode 100644 index 1e8eb5c99..000000000 --- a/pkg/test/dockertest/mongo.go +++ /dev/null @@ -1,64 +0,0 @@ -/* -Copyright 2014 The Camlistore Authors - -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. -*/ - -package dockertest - -import ( - "log" - "os/exec" - "testing" - "time" - - "camlistore.org/pkg/netutil" -) - -const mongoImage = "robinvdvleuten/mongo" - -// SetupMongoContainer sets up a real MongoDB instance for testing purposes, using a Docker container. -// Currently using https://index.docker.io/u/robinvdvleuten/mongo/ -func SetupMongoContainer(t *testing.T) (containerID, ip string) { - if testing.Short() { - t.Skip("skipping in short mode") - } - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("skipping without docker available in path") - } - if ok, err := HaveImage(mongoImage); !ok || err != nil { - if err != nil { - t.Skipf("Error running docker to check for %s: %v", mongoImage, err) - } - log.Printf("Pulling docker image %s ...", mongoImage) - if err := Pull(mongoImage); err != nil { - t.Skipf("Error pulling %s: %v", mongoImage, err) - } - } - - var err error - containerID, err = Run("-d", mongoImage, "--smallfiles") - if err != nil { - t.Fatalf("docker run: %v", err) - } - - ip, err = IP(containerID) - if err != nil { - t.Fatalf("Error getting container IP: %v", err) - } - - if err := netutil.AwaitReachable(ip+":27017", 20*time.Second); err != nil { - t.Fatal("timeout waiting for port to become reachable") - } - return -} diff --git a/server/camlistored/ui/detail.css b/server/camlistored/ui/detail.css index 4d637d691..6757390ce 100644 --- a/server/camlistored/ui/detail.css +++ b/server/camlistored/ui/detail.css @@ -1,3 +1,4 @@ +/* TODO(aa): All this needs to get renamed to image-detail */ .detail-view { background: black; left: 0; diff --git a/server/camlistored/ui/detail.js b/server/camlistored/ui/detail.js index 0b4a7dab8..c10e52bdd 100644 --- a/server/camlistored/ui/detail.js +++ b/server/camlistored/ui/detail.js @@ -24,6 +24,7 @@ goog.require('goog.string'); goog.require('cam.AnimationLoop'); goog.require('cam.BlobItemReactData'); +goog.require('cam.ImageDetail'); goog.require('cam.imageUtil'); goog.require('cam.Navigator'); goog.require('cam.reactUtil'); @@ -34,10 +35,6 @@ goog.require('cam.SpritedAnimation'); cam.DetailView = React.createClass({ displayName: 'DetailView', - IMG_MARGIN: 20, - PIGGY_WIDTH: 88, - PIGGY_HEIGHT: 62, - propTypes: { blobref: React.PropTypes.string.isRequired, getDetailURL: React.PropTypes.func.isRequired, @@ -52,25 +49,15 @@ cam.DetailView = React.createClass({ }, getInitialState: function() { - this.imgSize_ = null; - this.lastImageHeight_ = 0; - this.pendingNavigation_ = 0; - this.navCount_ = 1; - this.eh_ = new goog.events.EventHandler(this); - return { - imgHasLoaded: false, - backwardPiggy: false, + lastNavigateWasBackward: false, }; }, - componentWillReceiveProps: function(nextProps) { - if (this.props.blobref != nextProps.blobref) { - this.blobItemData_ = null; - this.imgSize_ = null; - this.lastImageHeight_ = 0; - this.setState({imgHasLoaded: false}); - } + componentWillMount: function() { + this.pendingNavigation_ = 0; + this.navCount_ = 1; + this.eh_ = new goog.events.EventHandler(this); }, componentDidMount: function(root) { @@ -79,103 +66,21 @@ cam.DetailView = React.createClass({ this.searchUpdated_(); }, - componentDidUpdate: function(prevProps, prevState) { - var img = this.getImageRef_(); - if (img) { - // This function gets called multiple times, but the DOM de-dupes listeners for us. Thanks DOM. - img.getDOMNode().addEventListener('load', this.onImgLoad_); - img.getDOMNode().addEventListener('error', function() { - console.error('Could not load image: %s', img.props.src); - }) - } - }, - render: function() { - this.blobItemData_ = this.getBlobItemData_(); - this.imgSize_ = this.getImgSize_(); - return React.DOM.div({className:'detail-view', style: this.getStyle_()}, [ - this.getImg_(), - this.getPiggy_(), - this.getSidebar_(), - ]); - }, - - getSidebar_: function() { - var children = !this.blobItemData_ ? [] : [ - this.getGeneralProperties_(), - this.getFileishProperties_(), - this.getImageProperties_(), - this.getNavProperties_(), - ]; - return cam.PropertySheetContainer({className:'detail-view-sidebar', style:this.getSidebarStyle_()}, children); - }, - - getGeneralProperties_: function() { - if (this.blobItemData_.m.camliType != 'permanode') { - return null; - } - return cam.PropertySheet({key:'general', title:'Generalities'}, [ - React.DOM.h1({className:'detail-title'}, this.getSinglePermanodeAttr_('title') || ''), - React.DOM.p({className:'detail-description'}, this.getSinglePermanodeAttr_('description') || ''), - ]); - }, - - getFileishProperties_: function() { - var isFile = this.blobItemData_.rm.camliType == 'file'; - var isDir = this.blobItemData_.rm.camliType == 'directory'; - if (!isFile && !isDir) { - return null; - } - return cam.PropertySheet({className:'detail-fileish-properties', key:'file', title: isFile ? 'File' : 'Directory'}, [ - React.DOM.table({}, [ - React.DOM.tr({}, [ - React.DOM.td({}, 'filename'), - React.DOM.td({}, isFile ? this.blobItemData_.rm.file.fileName : this.blobItemData_.rm.dir.fileName), - ]), - React.DOM.tr({}, [ - React.DOM.td({}, 'size'), - React.DOM.td({}, this.blobItemData_.rm.file.size + ' bytes'), // TODO(aa): Humanize units - ]), - ]), - ]); - }, - - getImageProperties_: function() { - if (!this.blobItemData_.im) { - return null; + if (!this.dataIsLoaded_()) { + return React.DOM.div(); } - return cam.PropertySheet({className:'detail-image-properties', key:'image', title: 'Image'}, [ - React.DOM.table({}, [ - React.DOM.tr({}, [ - React.DOM.td({}, 'width'), - React.DOM.td({}, this.blobItemData_.im.width), - ]), - React.DOM.tr({}, [ - React.DOM.td({}, 'height'), - React.DOM.td({}, this.blobItemData_.im.height), - ]), - // TODO(aa): encoding type, exif data, etc. - ]), - ]); - }, - - getNavProperties_: function() { - return cam.PropertySheet({key:'nav', title:'Elsewhere'}, [ - React.DOM.a({key:'search-link', href:this.props.searchURL.toString(), onClick:this.handleEscape_}, 'Back to search'), - React.DOM.br(), - React.DOM.a({key:'old-link', href:this.props.oldURL.toString()}, 'Old (editable) UI'), - ]); - }, - - // TODO(aa): We need a Permanode utility class that wraps the JSON goop. - getSinglePermanodeAttr_: function(name) { - var m = this.blobItemData_.m; - if (m.camliType == 'permanode' && m.permanode.attr[name]) { - return goog.isArray(m.permanode.attr[name]) ? m.permanode.attr[name][0] : m.permanode.attr[name]; - } else { - return null; - } + // TODO(aa): Different types of detail views can go here based on what's in blobItemData. + return cam.ImageDetail({ + backwardPiggy: this.state.lastNavigateWasBackward, + blobItemData: new cam.BlobItemReactData(this.props.blobref, this.props.searchSession.getCurrentResults().description.meta), + height: this.props.height, + oldURL: this.props.oldURL, + onEscape: this.handleEscape_, + searchURL: this.props.searchURL, + width: this.props.width, + }); }, componentWillUnmount: function() { @@ -195,7 +100,7 @@ cam.DetailView = React.createClass({ navigate_: function(offset) { this.pendingNavigation_ = offset; ++this.navCount_; - this.setState({backwardPiggy: offset < 0}); + this.setState({lastNavigateWasBackward: offset < 0}); this.handlePendingNavigation_(); }, @@ -240,15 +145,10 @@ cam.DetailView = React.createClass({ this.props.navigator.navigate(this.props.getDetailURL(results.blobs[index].blob)); }, - onImgLoad_: function() { - this.setState({imgHasLoaded:true}); - }, - searchUpdated_: function() { this.handlePendingNavigation_(); - this.blobItemData_ = this.getBlobItemData_(); - if (this.blobItemData_) { + if (this.dataIsLoaded_()) { this.forceUpdate(); return; } @@ -265,113 +165,7 @@ cam.DetailView = React.createClass({ this.props.searchSession.loadMoreResults(); }, - getImg_: function() { - var transition = React.addons.TransitionGroup({transitionName: 'detail-img'}, []); - if (this.imgSize_) { - transition.props.children.push( - React.DOM.img({ - className: React.addons.classSet({ - 'detail-view-img': true, - 'detail-view-img-loaded': this.state.imgHasLoaded - }), - // We want each image to have its own node in the DOM so that during the crossfade, we don't see the image jump to the next image's size. - key: this.getImageId_(), - ref: this.getImageId_(), - src: this.getSrc_(), - style: this.getCenteredProps_(this.imgSize_.width, this.imgSize_.height) - }) - ); - } - return transition; + dataIsLoaded_: function() { + return Boolean(this.props.searchSession.getCurrentResults().description.meta[this.props.blobref]); }, - - getPiggy_: function() { - var transition = React.addons.TransitionGroup({transitionName: 'detail-piggy'}, []); - if (!this.state.imgHasLoaded) { - transition.props.children.push( - cam.SpritedAnimation({ - src: 'glitch/npc_piggy__x1_walk_png_1354829432.png', - className: React.addons.classSet({ - 'detail-view-piggy': true, - 'detail-view-piggy-backward': this.state.backwardPiggy - }), - 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() { - this.lastImageHeight_ = Math.min(this.blobItemData_.im.height, cam.imageUtil.getSizeToRequest(this.imgSize_.height, this.lastImageHeight_)); - return this.blobItemData_.getThumbSrc(this.lastImageHeight_); - }, - - getImgSize_: function() { - if (!this.blobItemData_ || !this.blobItemData_.im) { - return null; - } - var rawSize = new goog.math.Size(this.blobItemData_.im.width, this.blobItemData_.im.height); - var available = new goog.math.Size( - this.props.width - this.getSidebarWidth_() - this.IMG_MARGIN * 2, - this.props.height - this.IMG_MARGIN * 2); - if (rawSize.height <= available.height && rawSize.width <= available.width) { - return rawSize; - } - return rawSize.scaleToFit(available); - }, - - getStyle_: function() { - return { - width: this.props.width, - height: this.props.height - } - }, - - getSidebarStyle_: function() { - return { - width: this.getSidebarWidth_() - } - }, - - getSidebarWidth_: function() { - return Math.max(this.props.width * 0.2, 300); - }, - - getPermanodeMeta_: function() { - if (!this.blobItemData_) { - return null; - } - return this.blobItemData_.m; - }, - - getBlobItemData_: function() { - var metabag = this.props.searchSession.getCurrentResults().description.meta; - if (!metabag[this.props.blobref]) { - return null; - } - return new cam.BlobItemReactData(this.props.blobref, metabag); - }, - - getImageRef_: function() { - return this.refs && this.refs[this.getImageId_()]; - }, - - getImageId_: function() { - return 'img' + this.props.blobref; - } }); diff --git a/server/camlistored/ui/image_detail.js b/server/camlistored/ui/image_detail.js new file mode 100644 index 000000000..fd566c635 --- /dev/null +++ b/server/camlistored/ui/image_detail.js @@ -0,0 +1,259 @@ +/* +Copyright 2014 The Camlistore Authors + +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. +*/ + +goog.provide('cam.ImageDetail'); + +cam.ImageDetail = React.createClass({ + displayName: 'ImageDetail', + + IMG_MARGIN: 20, + PIGGY_WIDTH: 88, + PIGGY_HEIGHT: 62, + + propTypes: { + backwardPiggy: false, + blobItemData: React.PropTypes.instanceOf(cam.BlobItemReactData).isRequired, + height: React.PropTypes.number.isRequired, + oldURL: React.PropTypes.instanceOf(goog.Uri).isRequired, + onEscape: React.PropTypes.func.isRequired, + searchURL: React.PropTypes.instanceOf(goog.Uri).isRequired, + width: React.PropTypes.number.isRequired, + }, + + getInitialState: function() { + this.imgSize_ = null; + this.lastImageHeight_ = 0; + + return { + imgHasLoaded: false, + }; + }, + + componentWillReceiveProps: function(nextProps) { + if (this.props.blobItemData.blobref != nextProps.blobItemData.blobref) { + this.imgSize_ = null; + this.lastImageHeight_ = 0; + this.setState({imgHasLoaded: false}); + } + }, + + componentDidMount: function() { + this.componentDidUpdate(); + }, + + componentDidUpdate: function() { + var img = this.getImageRef_(); + if (img) { + // This function gets called multiple times, but the DOM de-dupes listeners for us. Thanks DOM. + img.getDOMNode().addEventListener('load', this.onImgLoad_); + img.getDOMNode().addEventListener('error', function() { + console.error('Could not load image: %s', img.props.src); + }) + } + }, + + render: function() { + this.imgSize_ = this.getImgSize_(); + return React.DOM.div({className:'detail-view', style: this.getStyle_()}, [ + this.getImg_(), + this.getPiggy_(), + this.getSidebar_(), + ]); + }, + + getSidebar_: function() { + return cam.PropertySheetContainer({className:'detail-view-sidebar', style:this.getSidebarStyle_()}, [ + this.getGeneralProperties_(), + this.getFileishProperties_(), + this.getImageProperties_(), + this.getNavProperties_(), + ]); + }, + + getGeneralProperties_: function() { + if (this.props.blobItemData.m.camliType != 'permanode') { + return null; + } + return cam.PropertySheet({key:'general', title:'Generalities'}, [ + React.DOM.h1({className:'detail-title'}, this.getSinglePermanodeAttr_('title') || ''), + React.DOM.p({className:'detail-description'}, this.getSinglePermanodeAttr_('description') || ''), + ]); + }, + + getFileishProperties_: function() { + var isFile = this.props.blobItemData.rm.camliType == 'file'; + var isDir = this.props.blobItemData.rm.camliType == 'directory'; + if (!isFile && !isDir) { + return null; + } + return cam.PropertySheet({className:'detail-fileish-properties', key:'file', title: isFile ? 'File' : 'Directory'}, [ + React.DOM.table({}, [ + React.DOM.tr({}, [ + React.DOM.td({}, 'filename'), + React.DOM.td({}, isFile ? this.props.blobItemData.rm.file.fileName : this.props.blobItemData.rm.dir.fileName), + ]), + React.DOM.tr({}, [ + React.DOM.td({}, 'size'), + React.DOM.td({}, this.props.blobItemData.rm.file.size + ' bytes'), // TODO(aa): Humanize units + ]), + ]), + ]); + }, + + getImageProperties_: function() { + if (!this.props.blobItemData.im) { + return null; + } + + return cam.PropertySheet({className:'detail-image-properties', key:'image', title: 'Image'}, [ + React.DOM.table({}, [ + React.DOM.tr({}, [ + React.DOM.td({}, 'width'), + React.DOM.td({}, this.props.blobItemData.im.width), + ]), + React.DOM.tr({}, [ + React.DOM.td({}, 'height'), + React.DOM.td({}, this.props.blobItemData.im.height), + ]), + // TODO(aa): encoding type, exif data, etc. + ]), + ]); + }, + + getNavProperties_: function() { + return cam.PropertySheet({key:'nav', title:'Elsewhere'}, [ + React.DOM.a({key:'search-link', href:this.props.searchURL.toString(), onClick:this.props.onEscape}, 'Back to search'), + React.DOM.br(), + React.DOM.a({key:'old-link', href:this.props.oldURL.toString()}, 'Old (editable) UI'), + ]); + }, + + // TODO(aa): We need a Permanode utility class that wraps the JSON goop. + getSinglePermanodeAttr_: function(name) { + var m = this.props.blobItemData.m; + if (m.camliType == 'permanode' && m.permanode.attr[name]) { + return goog.isArray(m.permanode.attr[name]) ? m.permanode.attr[name][0] : m.permanode.attr[name]; + } else { + return null; + } + }, + + onImgLoad_: function() { + this.setState({imgHasLoaded:true}); + }, + + getImg_: function() { + var transition = React.addons.TransitionGroup({transitionName: 'detail-img'}, []); + if (this.imgSize_) { + transition.props.children.push( + React.DOM.img({ + className: React.addons.classSet({ + 'detail-view-img': true, + 'detail-view-img-loaded': this.state.imgHasLoaded + }), + // We want each image to have its own node in the DOM so that during the crossfade, we don't see the image jump to the next image's size. + key: this.getImageId_(), + ref: this.getImageId_(), + 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.imgHasLoaded) { + transition.props.children.push( + cam.SpritedAnimation({ + src: 'glitch/npc_piggy__x1_walk_png_1354829432.png', + className: React.addons.classSet({ + 'detail-view-piggy': true, + 'detail-view-piggy-backward': this.props.backwardPiggy + }), + 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() { + this.lastImageHeight_ = Math.min(this.props.blobItemData.im.height, cam.imageUtil.getSizeToRequest(this.imgSize_.height, this.lastImageHeight_)); + return this.props.blobItemData.getThumbSrc(this.lastImageHeight_); + }, + + getImgSize_: function() { + if (!this.props.blobItemData || !this.props.blobItemData.im) { + return null; + } + var rawSize = new goog.math.Size(this.props.blobItemData.im.width, this.props.blobItemData.im.height); + var available = new goog.math.Size( + this.props.width - this.getSidebarWidth_() - this.IMG_MARGIN * 2, + this.props.height - this.IMG_MARGIN * 2); + if (rawSize.height <= available.height && rawSize.width <= available.width) { + return rawSize; + } + return rawSize.scaleToFit(available); + }, + + getStyle_: function() { + return { + width: this.props.width, + height: this.props.height + } + }, + + getSidebarStyle_: function() { + return { + width: this.getSidebarWidth_() + } + }, + + getSidebarWidth_: function() { + return Math.max(this.props.width * 0.2, 300); + }, + + getPermanodeMeta_: function() { + if (!this.props.blobItemData) { + return null; + } + return this.props.blobItemData.m; + }, + + getImageRef_: function() { + return this.refs && this.refs[this.getImageId_()]; + }, + + getImageId_: function() { + return 'img' + this.props.blobItemData.blobref; + } +}); diff --git a/website/content/docs/release/0.8 b/website/content/docs/release/0.8 index 82eb761d6..59b85ec9b 100644 --- a/website/content/docs/release/0.8 +++ b/website/content/docs/release/0.8 @@ -51,4 +51,5 @@ Or browse at Github: g

General

    +
  • kv: all the verifydb flags are on by default on dev, to help with detecting corruptions.