From 58f6570bc581d58110e7f95ffcefaa0e0e4ef161 Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Thu, 19 Nov 2020 17:32:40 +0100
Subject: [PATCH] Started node listing implementation

---
 .../inaf/ia2/vospace/ui/SecurityConfig.java   |  41 ++++++
 .../inaf/ia2/vospace/ui/VOSpaceException.java |  12 ++
 ...ication.java => VOSpaceUiApplication.java} |   4 +-
 .../ia2/vospace/ui/client/VOSpaceClient.java  | 121 ++++++++++++++++++
 .../ui/controller/IndexController.java        |  16 +++
 .../ui/controller/NodesController.java        |  32 +++++
 .../ia2/vospace/ui/service/NodesService.java  |  60 +++++++++
 .../src/main/resources/application.properties |   7 +
 vospace-ui-frontend/.env.production           |   2 +
 vospace-ui-frontend/package-lock.json         |  11 +-
 vospace-ui-frontend/package.json              |   1 +
 vospace-ui-frontend/src/App.vue               |  75 ++++++++++-
 vospace-ui-frontend/src/api/server/index.js   |  66 ++++++++++
 .../src/assets/ia2-logo-footer.png            | Bin 0 -> 1446 bytes
 vospace-ui-frontend/src/components/Main.vue   |  26 +++-
 .../src/components/TopMenu.vue                |  14 ++
 vospace-ui-frontend/src/main.js               |   1 +
 .../src/plugins/bootstrap-vue.js              |   7 +
 vospace-ui-frontend/src/store.js              |   8 +-
 vospace-ui-frontend/vue.config.js             |  12 ++
 20 files changed, 508 insertions(+), 8 deletions(-)
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/SecurityConfig.java
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceException.java
 rename vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/{VospaceUiApplication.java => VOSpaceUiApplication.java} (69%)
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/IndexController.java
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java
 create mode 100644 vospace-ui-frontend/.env.production
 create mode 100644 vospace-ui-frontend/src/api/server/index.js
 create mode 100644 vospace-ui-frontend/src/assets/ia2-logo-footer.png
 create mode 100644 vospace-ui-frontend/src/components/TopMenu.vue
 create mode 100644 vospace-ui-frontend/src/plugins/bootstrap-vue.js
 create mode 100644 vospace-ui-frontend/vue.config.js

diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/SecurityConfig.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/SecurityConfig.java
new file mode 100644
index 0000000..fa12343
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/SecurityConfig.java
@@ -0,0 +1,41 @@
+package it.inaf.ia2.vospace.ui;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class SecurityConfig {
+
+    private static final Logger LOG = LoggerFactory.getLogger(SecurityConfig.class);
+
+    @Value("${cors.allowed.origin}")
+    private String corsAllowedOrigin;
+
+    /**
+     * CORS are necessary only for development (API access from npm server).
+     */
+    @Bean
+    @Profile("dev")
+    public WebMvcConfigurer corsConfigurer() {
+
+        return new WebMvcConfigurer() {
+
+            @Override
+            public void addCorsMappings(CorsRegistry registry) {
+
+                LOG.warn("Development profile active: CORS filter enabled");
+
+                registry.addMapping("/**")
+                        .allowedOrigins(corsAllowedOrigin)
+                        .allowedMethods("*")
+                        .allowCredentials(true);
+            }
+        };
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceException.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceException.java
new file mode 100644
index 0000000..2b227f5
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceException.java
@@ -0,0 +1,12 @@
+package it.inaf.ia2.vospace.ui;
+
+public class VOSpaceException extends RuntimeException {
+
+    public VOSpaceException(String message) {
+        super(message);
+    }
+
+    public VOSpaceException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VospaceUiApplication.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java
similarity index 69%
rename from vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VospaceUiApplication.java
rename to vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java
index ad9aac9..010de2f 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VospaceUiApplication.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java
@@ -4,9 +4,9 @@ import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 
 @SpringBootApplication
-public class VospaceUiApplication {
+public class VOSpaceUiApplication {
 
     public static void main(String[] args) {
-        SpringApplication.run(VospaceUiApplication.class, args);
+        SpringApplication.run(VOSpaceUiApplication.class, args);
     }
 }
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
new file mode 100644
index 0000000..9a0aebb
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
@@ -0,0 +1,121 @@
+package it.inaf.ia2.vospace.ui.client;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import it.inaf.ia2.vospace.ui.VOSpaceException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.ConnectException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.util.Scanner;
+import java.util.concurrent.CompletionException;
+import java.util.function.Function;
+import net.ivoa.xml.vospace.v2.Node;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+@Component
+public class VOSpaceClient {
+
+    private static final Logger LOG = LoggerFactory.getLogger(VOSpaceClient.class);
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private final HttpClient httpClient;
+    private final String baseUrl;
+
+    public VOSpaceClient(@Value("${vospace-backend-url}") String backendUrl) {
+        if (backendUrl.endsWith("/")) {
+            // Remove final slash from configured URL
+            backendUrl = backendUrl.substring(0, backendUrl.length() - 1);
+        }
+        baseUrl = backendUrl;
+
+        httpClient = HttpClient.newBuilder()
+                .followRedirects(HttpClient.Redirect.ALWAYS)
+                .version(HttpClient.Version.HTTP_1_1)
+                .build();
+    }
+
+    public Node getNode(String path) {
+
+        HttpRequest request = getRequest("/nodes/" + path)
+                .header("Accept", "application/json")
+                .build();
+
+        return call(request, BodyHandlers.ofInputStream(), 200, res -> parseJson(res, Node.class));
+    }
+
+    private <T, U> U call(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, int expectedStatusCode, Function<T, U> responseHandler) {
+        try {
+            return httpClient.sendAsync(request, responseBodyHandler)
+                    .thenApply(response -> {
+                        if (response.statusCode() == expectedStatusCode) {
+                            return response.body();
+                        }
+                        logServerError(request, response);
+                        throw new VOSpaceException("Error calling " + request.uri().toString() + ". Server response code is " + response.statusCode());
+                    })
+                    .thenApply(response -> responseHandler.apply(response))
+                    .join();
+        } catch (CompletionException ex) {
+            if (ex.getCause() != null) {
+                if (ex.getCause() instanceof ConnectException) {
+                    throw new VOSpaceException("Cannot connect to " + request.uri().getHost() + " on port " + request.uri().getPort());
+                }
+                if (ex.getCause() instanceof VOSpaceException) {
+                    throw (VOSpaceException) ex.getCause();
+                }
+                LOG.error("Error calling " + request.uri().toString(), ex.getCause());
+                throw new VOSpaceException("Error calling " + request.uri().toString(), ex.getCause());
+            }
+            LOG.error("Error calling " + request.uri().toString(), ex);
+            throw new VOSpaceException("Error calling " + request.uri().toString(), ex);
+        }
+    }
+
+    private HttpRequest.Builder getRequest(String path) {
+        return HttpRequest.newBuilder(URI.create(baseUrl + path));
+    }
+
+    private static <T> T parseJson(InputStream in, Class<T> type) {
+        try {
+            return MAPPER.readValue(in, type);
+        } catch (IOException ex) {
+            LOG.error("Invalid JSON for class {}", type.getCanonicalName());
+            throw new UncheckedIOException(ex);
+        }
+    }
+
+    private static <T> void logServerError(HttpRequest request, HttpResponse<T> response) {
+        if (response.body() instanceof String) {
+            logServerErrorString(request, (HttpResponse<String>) response);
+        } else if (response.body() instanceof InputStream) {
+            logServerErrorInputStream(request, (HttpResponse<InputStream>) response);
+        } else {
+            throw new UnsupportedOperationException("Unable to log error for response body type " + response.body().getClass().getCanonicalName());
+        }
+    }
+
+    private static void logServerErrorString(HttpRequest request, HttpResponse<String> response) {
+
+        LOG.error("Error while reading " + request.uri()
+                + "\nServer response status code is " + response.statusCode()
+                + "\nServer response text is " + response.body());
+    }
+
+    private static void logServerErrorInputStream(HttpRequest request, HttpResponse<InputStream> response) {
+        Scanner s = new Scanner(response.body()).useDelimiter("\\A");
+        String responseBody = s.hasNext() ? s.next() : "";
+        String error = "Error while reading " + request.uri()
+                + "\nServer response status code is " + response.statusCode()
+                + "\nServer response text is " + responseBody;
+        LOG.error(error);
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/IndexController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/IndexController.java
new file mode 100644
index 0000000..a0c8265
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/IndexController.java
@@ -0,0 +1,16 @@
+package it.inaf.ia2.vospace.ui.controller;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@Controller
+public class IndexController {
+
+    @GetMapping(value = "/", produces = MediaType.TEXT_HTML_VALUE)
+    public String index(HttpServletRequest request, HttpServletResponse response) {
+        return "index.html";
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
new file mode 100644
index 0000000..9f406d1
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
@@ -0,0 +1,32 @@
+package it.inaf.ia2.vospace.ui.controller;
+
+import it.inaf.ia2.vospace.ui.service.NodesService;
+import javax.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class NodesController {
+
+    @Autowired
+    private NodesService nodesService;
+
+    /**
+     * This is the only API endpoint that returns HTML code instead of JSON. The
+     * reason is that JavaScript frameworks are not very efficient in handling
+     * very long lists and tables, so this part of the code is generated
+     * server-side.
+     */
+    @GetMapping(value = {"/nodes", "/nodes/{path}"}, produces = MediaType.TEXT_HTML_VALUE)
+    public void listNodes(@PathVariable(value = "path", required = false) String path, HttpServletResponse response) throws Exception {
+
+        if (path == null || path.isBlank()) {
+            path = "/";
+        }
+
+        nodesService.generateNodesHtml(path, response.getOutputStream());
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java
new file mode 100644
index 0000000..96c21b8
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java
@@ -0,0 +1,60 @@
+package it.inaf.ia2.vospace.ui.service;
+
+import it.inaf.ia2.vospace.ui.VOSpaceException;
+import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import net.ivoa.xml.vospace.v2.ContainerNode;
+import net.ivoa.xml.vospace.v2.Node;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+@Service
+public class NodesService {
+
+    @Autowired
+    private VOSpaceClient client;
+
+    @Value("${vospace-authority}")
+    private String authority;
+
+    public void generateNodesHtml(String path, OutputStream out) {
+
+        Node node = client.getNode(path);
+
+        try ( PrintWriter pw = new PrintWriter(out)) {
+
+            if (node instanceof ContainerNode) {
+                ContainerNode folder = (ContainerNode) node;
+                pw.println("<tbody id=\"nodes\">");
+                for (Node child : folder.getNodes().getNode()) {
+                    pw.println(getNodeHtml(child));
+                }
+                pw.println("</tbody>");
+            }
+        }
+    }
+
+    private String getNodeHtml(Node node) {
+        String html = "<tr>";
+        html += "<td><a href=\"#\">";
+        html += getName(node);
+        html += "</a></td>";
+        html += "</tr>";
+        return html;
+    }
+
+    private String getName(Node node) {
+
+        String uri = node.getUri();
+
+        String prefix = "vos://" + authority;
+
+        if (!uri.startsWith(prefix)) {
+            throw new VOSpaceException("Node authority is different from configured one! Configured is " + authority + ", but node URI is " + uri);
+        }
+
+        return uri.substring(prefix.length() + 1);
+    }
+}
diff --git a/vospace-ui-backend/src/main/resources/application.properties b/vospace-ui-backend/src/main/resources/application.properties
index 8b13789..934e284 100644
--- a/vospace-ui-backend/src/main/resources/application.properties
+++ b/vospace-ui-backend/src/main/resources/application.properties
@@ -1 +1,8 @@
+server.port=8085
 
+vospace-backend-url=http://localhost:8083/vospace
+vospace-authority=example.com!vospace
+
+# For development only:
+spring.profiles.active=dev
+cors.allowed.origin=http://localhost:8080
diff --git a/vospace-ui-frontend/.env.production b/vospace-ui-frontend/.env.production
new file mode 100644
index 0000000..6010f55
--- /dev/null
+++ b/vospace-ui-frontend/.env.production
@@ -0,0 +1,2 @@
+VUE_APP_API_CLIENT = 'server'
+VUE_APP_API_BASE_URL = ''
diff --git a/vospace-ui-frontend/package-lock.json b/vospace-ui-frontend/package-lock.json
index 178aac5..80ecd14 100644
--- a/vospace-ui-frontend/package-lock.json
+++ b/vospace-ui-frontend/package-lock.json
@@ -2538,6 +2538,14 @@
       "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==",
       "dev": true
     },
+    "axios": {
+      "version": "0.21.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz",
+      "integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==",
+      "requires": {
+        "follow-redirects": "^1.10.0"
+      }
+    },
     "babel-eslint": {
       "version": "10.1.0",
       "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz",
@@ -5523,8 +5531,7 @@
     "follow-redirects": {
       "version": "1.13.0",
       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz",
-      "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==",
-      "dev": true
+      "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA=="
     },
     "for-in": {
       "version": "1.0.2",
diff --git a/vospace-ui-frontend/package.json b/vospace-ui-frontend/package.json
index 1864acb..c452bb8 100644
--- a/vospace-ui-frontend/package.json
+++ b/vospace-ui-frontend/package.json
@@ -8,6 +8,7 @@
     "lint": "vue-cli-service lint"
   },
   "dependencies": {
+    "axios": "^0.21.0",
     "bootstrap-vue": "^2.19.0",
     "core-js": "^3.6.5",
     "vue": "^2.6.11",
diff --git a/vospace-ui-frontend/src/App.vue b/vospace-ui-frontend/src/App.vue
index 2363d6b..938c672 100644
--- a/vospace-ui-frontend/src/App.vue
+++ b/vospace-ui-frontend/src/App.vue
@@ -1,9 +1,48 @@
 <template>
   <div id="app">
+    <TopMenu />
+    <div class="container">
+      <router-view></router-view>
+    </div>
+    <div id="footer-fix"></div>
+    <footer class="text-center" id="site-footer">
+        —&nbsp;Powered by <img alt="IA2 logo" src="./assets/ia2-logo-footer.png">
+        <strong class="text-primary"><a href="http://www.ia2.inaf.it/" target="blank_">IA2</a></strong>&nbsp;—
+    </footer>
+    <div id="loading" v-if="loading">
+      <div class="spinner-wrapper">
+        <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner>
+      </div>
+    </div>
   </div>
 </template>
 
 <script>
+import { mapState } from 'vuex'
+import TopMenu from './components/TopMenu.vue'
+
+export default {
+  name: 'App',
+  computed: mapState({
+    loading: state => state.loading
+  }),
+  components: {
+    TopMenu
+  },
+  mounted: function() {
+    var self = this;
+    document.addEventListener('apiError', function(event) {
+      self.$bvToast.toast(event.message.body, {
+        title: event.message.title,
+        variant: 'danger',
+        solid: true
+      });
+    });
+    document.addEventListener('loading', function(event) {
+      self.$store.commit('setLoading', event.value);
+    });
+  }
+}
 </script>
 
 <style>
@@ -13,6 +52,40 @@
   -moz-osx-font-smoothing: grayscale;
   text-align: center;
   color: #2c3e50;
-  margin-top: 60px;
+}
+
+#footer-fix {
+    display: block;
+    width: 100%;
+    height: 100px;
+}
+
+#site-footer {
+    color: #666;
+    border-top: 1px #e7e7e7 solid;
+    background-color: #f8f8f8;
+    padding: 5px 0;
+    position: fixed;
+    bottom: 0;
+    right: 0;
+    left: 0;
+}
+
+#loading {
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  right: 0;
+  left: 0;
+  background-color: rgba(255, 255, 255, 0.7);
+  z-index: 1000;
+}
+
+.spinner-wrapper {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100%;
+  width: 100%;
 }
 </style>
diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js
new file mode 100644
index 0000000..31c8eb7
--- /dev/null
+++ b/vospace-ui-frontend/src/api/server/index.js
@@ -0,0 +1,66 @@
+const BASE_API_URL = process.env.VUE_APP_API_BASE_URL;
+
+import axios from 'axios';
+
+function apiRequest(options, showLoading = true, handleValidationErrors = false) {
+  if (showLoading) {
+    loading(true);
+  }
+  return new Promise((resolve, reject) => {
+    axios(options)
+      .then(response => {
+        if (response.status === 204) {
+          resolve({});
+        } else {
+          resolve(response.data);
+        }
+        loading(false);
+      })
+      .catch(error => {
+        loading(false);
+        if (handleValidationErrors && error.response && error.response.status === 400) {
+          reject(error.response.data);
+        } else {
+          dispatchApiErrorEvent(error);
+        }
+      });
+  });
+}
+
+function dispatchApiErrorEvent(error) {
+  let event = new CustomEvent('apiError');
+  let errorMessage;
+  if (error.response && error.response.data && error.response.data.message) {
+    errorMessage = error.response.data.message;
+  } else if (error.message) {
+    errorMessage = error.message;
+  } else {
+    errorMessage = 'Unknown error';
+  }
+  event.message = {
+    title: error.error || 'Error',
+    body: errorMessage
+  };
+  document.dispatchEvent(event);
+}
+
+/* For loading animation */
+function loading(value) {
+  let event = new CustomEvent('loading');
+  event.value = value;
+  document.dispatchEvent(event);
+}
+
+export default {
+  getNode(path) {
+    let url = BASE_API_URL + 'nodes' + path;
+    return apiRequest({
+      method: 'GET',
+      url: url,
+      withCredentials: true,
+      headers: {
+        'Cache-Control': 'no-cache'
+      }
+    }, true, true);
+  }
+}
diff --git a/vospace-ui-frontend/src/assets/ia2-logo-footer.png b/vospace-ui-frontend/src/assets/ia2-logo-footer.png
new file mode 100644
index 0000000000000000000000000000000000000000..cb913c9d1bfb1815de802febddfdec6dd2b56552
GIT binary patch
literal 1446
zcmeAS@N?(olHy`uVBq!ia0y~yU{GgZU@+ofV_;yozvGZB0|NtFlDE4H!+#K5uy^@n
z1_lPs0*}aI1_s{iAk65bF}s3+fq}im)7O>#0jD^x2#a;7O%?+KtFfnxV@Sl|w=<)2
zLQ(~e*MEMixqG?hiVn3dj}Jme)NXN<Zr_sReS}5sNJXdHhAn0dj+&0lTq2vk9a`$9
z$`Q6WVtbpAp;Rqnvth=flI<bRCRrvM*-VaUOKIKBD?e{;*U)l$o5Sw6LXW4<Y4?3L
z=Wpt>GiU0b9r*uT$KSPBAz<U)FZbs^t4ZSS*d6F%_aS`CIfjCB>K4a0+zc)fwv3hk
z_g{+l*@+GRX0Ni)-59hws^>!P+I{{{ujp^$-=6G0@5_$$okia}cW|7)eeOZfp(Ovk
zsW&!VJ#v?Kd4p_eUr?^v7JaYk^BX4Y-BfO}VZ;0i`#H8d1dDVZygvS@>f4*bP0Y_X
z+s{Av$p6%-pqq`egKHcfJgz%*Yu4TkO!KNl5A<iAa&0fI4olg8ZfjYO;mMS#hj;}1
zTVk(u@ApWJ^bdRR$My9(!)w(urx@%1dpVi?{K`K(nm5n*)+O&XZ-`bse%k)|EsH5R
z?-s7(J>a|Ge3^UrTdVvBMa`v~9acNq_5Odm)+S$Se{_hw*y%cjgsVP&Th{+6bNHqA
zIU})oDXYZdLw8(q5)zz+uHBiC&ub9dvex$EtlVZHZppcfT(6g}^y-bX`Q)(Z(X?%j
z+B1C`d$qXw#pdthQ<=Q|`TphW>i-&=PRg~Iy5wx8+fy&W+B4FU5o{`FIO1!sJ-ice
zIOu|P!}eW^SI=Id^-^f!(-nFuo4!9=bMPr!w))~V%ylOWSF#?LuC3e<`p{WEjXTlw
zFn_qzx!^Nq2NHJtFNpqe!Smp^jS&ys?uTAnmDpbVc%NFrqCT!Qz1y$8u|1oR_d<J$
z@2XAnuJsunW)tTyvtcp19#?wu-@TnjH(x%P!|;0PJf=r#GgrxrS=4WM7v*Tqw7)7@
zKZ?cZqD+arXLd~JB~`7{4L`SRVQt^G{oI`=<@~aJOy&_KX-eCwwr6%;TNrXXBugk%
z)n2euRB}eG*Um~8m;K+YoEMfDXSvxt|9A1LOaOz4TGHxr<{J&M+<y{f(*JI3zo+~n
zS3SOFu@-BZ)9*iuALmG~+RZ(6yTDEH_g>RJr0)3sqv31S8^5i;wr#bVduq{^XeHrJ
zGp5jMulM$~FZ(;q@cho!ua5&NC4YFY-TU6ffZ_eY2i96|Ox(;q2f6#*dh(+1ki+zk
zy%{HBBD@dtxou}&94Pj6%ldzIxx$<82|dVqyzG+t@k5$-o9q}vF3Rwxvgnz=Tg|HJ
zwp8<8+~&Rhhr1rVZ&)6fb@SK9r<v|YDqZteoXAtoNj%PXf58K$g}Z`Wr)k$ne&5+Y
z|Kz=0WwIZ37tZ0WJ;ma`TJWU#-N#})7K)#iwdsGgNw+VU=-A|*wA#)-_p(6exv&ho
zryF-JP+oJx=+yGMDR!1guN}oB)|V&6pG^EU*>PU!Hm2z-UoC!lpwI8qGylMOHa{<A
ziP{<6iZA++)p+>WlQq&EJXMEyudS9|b>DEozin?Pe^e8)O;XD3|GM*jX6M~(rNI7a
z+dR|X?!0s8mQzS}<2i>*Gwl_2SRQ3}n9s&3eAC)&?e){m`HQYxko@&*&Apx6+=s-~
z{+vrcAbMa+#TqrMgqxc(m*0;u_*Hy?&w72vxdqbohu-gD@L`BF)G%mx70Ia_#QUk?
zve0b16$`DtKl{}lni2GL@4RoJn;lGqjpCFpmb>merZ$I3%+GV)=I5WdvOkAbu>Cl?
zeB~*hM%lB~>4t|Y9{cNBe%~;yjd`BoS_g-<XDm1A|1q9v^E8-G$wX<*OKr<vE!;9E
zHQCfSZ(Z1&f0f;Nqy5IXJ%T@usy{o%xJFPaZ;^@37Wr3kx$?*BpZ;M^oXGfCeG+RX
Q0|Nttr>mdKI;Vst0CEAgYXATM

literal 0
HcmV?d00001

diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue
index 7b8b46c..482dffd 100644
--- a/vospace-ui-frontend/src/components/Main.vue
+++ b/vospace-ui-frontend/src/components/Main.vue
@@ -1,3 +1,27 @@
 <template>
-  <div></div>
+<div class="container">
+  <b-card>
+    <table class="table b-table table-striped table-hover">
+      <thead>
+        <tr>
+          <th>File</th>
+        </tr>
+      </thead>
+      <tbody id="nodes"></tbody>
+    </table>
+  </b-card>
+</div>
 </template>
+
+<script>
+import client from 'api-client';
+
+export default {
+  mounted() {
+    client.getNode(this.$store.state.path)
+      .then(res => {
+        document.getElementById('nodes').outerHTML = res;
+      });
+  }
+}
+</script>
diff --git a/vospace-ui-frontend/src/components/TopMenu.vue b/vospace-ui-frontend/src/components/TopMenu.vue
new file mode 100644
index 0000000..1115343
--- /dev/null
+++ b/vospace-ui-frontend/src/components/TopMenu.vue
@@ -0,0 +1,14 @@
+<template>
+<div>
+  <b-navbar toggleable="lg" type="dark" id="top-menu">
+    <b-navbar-brand href="#" class="d-none d-md-block">VOSpace</b-navbar-brand>
+  </b-navbar>
+</div>
+</template>
+
+<style>
+#top-menu {
+  background-color: #093784;
+  margin-bottom: 20px;
+}
+</style>
diff --git a/vospace-ui-frontend/src/main.js b/vospace-ui-frontend/src/main.js
index 8761db3..9324cf3 100644
--- a/vospace-ui-frontend/src/main.js
+++ b/vospace-ui-frontend/src/main.js
@@ -1,6 +1,7 @@
 import Vue from 'vue'
 import App from './App.vue'
 import store from './store.js'
+import './plugins/bootstrap-vue'
 
 import { BootstrapVue } from 'bootstrap-vue'
 
diff --git a/vospace-ui-frontend/src/plugins/bootstrap-vue.js b/vospace-ui-frontend/src/plugins/bootstrap-vue.js
new file mode 100644
index 0000000..b9d414f
--- /dev/null
+++ b/vospace-ui-frontend/src/plugins/bootstrap-vue.js
@@ -0,0 +1,7 @@
+import Vue from 'vue';
+
+import { BootstrapVue } from 'bootstrap-vue';
+import 'bootstrap/dist/css/bootstrap.min.css';
+import 'bootstrap-vue/dist/bootstrap-vue.css';
+
+Vue.use(BootstrapVue);
diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js
index 4eadaf1..464672b 100644
--- a/vospace-ui-frontend/src/store.js
+++ b/vospace-ui-frontend/src/store.js
@@ -7,9 +7,13 @@ Vue.use(Vuex);
 
 export default new Vuex.Store({
   state: {
+    path: '/',
+    loading: true
   },
   mutations: {
+    setLoading(state, loading) {
+      state.loading = loading;
+    }
   },
-  actions: {
-  }
+  actions: {}
 });
diff --git a/vospace-ui-frontend/vue.config.js b/vospace-ui-frontend/vue.config.js
new file mode 100644
index 0000000..93ef4cb
--- /dev/null
+++ b/vospace-ui-frontend/vue.config.js
@@ -0,0 +1,12 @@
+const path = require('path');
+
+module.exports = {
+  publicPath: '',
+  chainWebpack: config => {
+    const apiClient = process.env.VUE_APP_API_CLIENT // mock or server
+    config.resolve.alias.set(
+      'api-client',
+      path.resolve(__dirname, `src/api/${apiClient}`)
+    )
+  }
+}
-- 
GitLab