UI Foundation

Added foundational Vue application for UI.

The application is embedded and has foundational level functionality for
setup, user authentication, bookmarks, applications, and categories.
This commit is contained in:
Joe Bellus 2022-02-07 23:04:45 -05:00
parent 6c9ea1f774
commit 2156529b1c
48 changed files with 29971 additions and 1153 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
/target
data.db*
node_modules/
dist/

1155
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,8 @@ actix-web = "4.0.0-rc.1"
actix-rt = "2.6.0"
tracing-test = "0.2.1"
tracing-actix-web = "0.5.0-rc.1"
cargo-embed = "0.12.0"
rust-embed= { version="5.9.0", features = ["actix"] }
mime_guess = "2.0.3"
bcrypt = "0.10.1"
actix-web-httpauth = "0.6.0-beta.7"
jsonwebtoken = "8.0.1"

View File

@ -16,7 +16,3 @@ args = ["migrate", "run"]
[tasks.resetdb]
run_task = { name = ["dropdb", "createdb", "migratedb"] }
[tasks.entity]
command = "sea-orm-cli"
args = [ "generate", "entity", "-o", "src/entity", "--with-serde", "both" ]

5
babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

9
build.rs Normal file
View File

@ -0,0 +1,9 @@
use std::process::Command;
fn main() {
Command::new("npm")
.args(&["run", "build-dev"])
.current_dir("./vade-ui")
.status()
.unwrap();
}

26
jsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"checkJs": false,
"resolveJsonModule": true,
"moduleResolution": "node",
"target": "es2020",
"module": "es2015"
},
"exclude": [
"dist",
"node_modules",
"build",
".vscode",
".nuxt",
"coverage",
"jspm_packages",
"tmp",
"temp",
"bower_components",
".npm",
".yarn"
],
"typeAcquisition": {
"enable": true
}
}

View File

@ -19,6 +19,7 @@ CREATE TABLE bookmark (
bookmark_name TEXT NOT NULL,
url TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT 1,
bookmark_category_id INTEGER
);
CREATE TABLE bookmark_category (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
@ -26,7 +27,8 @@ CREATE TABLE bookmark_category (
active BOOLEAN NOT NULL DEFAULT 1,
glyph TEXT
);
CREATE TABLE settings (
CREATE TABLE setting (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
setting_name TEXT NOT NULL,
setting_value TEXT NOT NULL
);

27194
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "vade-ui",
"version": "0.1.0",
"private": true,
"main": "ui.js",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"build-dev": "vue-cli-service build --mode development"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^3.0.0-5",
"axios": "^0.25.0",
"core-js": "^3.6.5",
"vue": "^3.0.0",
"vue-icon-picker": "^1.0.0",
"vue-router": "^4.0.0-0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

19
public/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Vade Mecum - Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;900&display=swap" rel="stylesheet">
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@ -76,8 +76,25 @@ pub async fn delete_application_category(
state: web::Data<AppState>,
id: web::Path<i32>,
) -> Result<HttpResponse> {
let cat_id = id.into_inner();
let recs: Vec<application::Model> = Application::find()
.filter(application::Column::ApplicationCategoryId.eq(cat_id))
.all(&state.db)
.await
.unwrap_or_default();
for rec in recs {
application::ActiveModel {
id: Set(rec.id),
application_category_id: Set(None),
..Default::default()
}
.save(&state.db)
.await?;
}
ApplicationCategory::delete_many()
.filter(application_category::Column::Id.eq(id.into_inner()))
.filter(application_category::Column::Id.eq(cat_id))
.exec(&state.db)
.await?;
@ -135,7 +152,7 @@ mod tests {
.insert(&state.db)
.await?;
let mut req = actix_web::test::TestRequest::with_uri("/application_categories")
let mut req = actix_web::test::TestRequest::with_uri("/api/application_categories")
.method(Method::GET)
.to_request();
let resp = call_endpoint!(req, state);
@ -160,7 +177,7 @@ mod tests {
.await?;
let mut req = actix_web::test::TestRequest::with_uri(&format!(
"/application_categories/{}",
"/api/application_categories/{}",
model.id
))
.method(Method::GET)
@ -185,7 +202,7 @@ mod tests {
let state = setup_state().await?;
let mut req = actix_web::test::TestRequest::with_uri("/application_categories")
let mut req = actix_web::test::TestRequest::with_uri("/api/application_categories")
.method(Method::POST)
.set_json(model.clone())
.to_request();
@ -225,7 +242,7 @@ mod tests {
model.category_name = "Another name".into();
let mut req = actix_web::test::TestRequest::with_uri(&format!(
"/application_categories/{}",
"/api/application_categories/{}",
model.id
))
.method(Method::PUT)
@ -258,8 +275,18 @@ mod tests {
.insert(&state.db)
.await?;
let app = application::ActiveModel {
app_name: Set("Application 1".into()),
url: Set("http://somewhere/".into()),
active: Set(true),
application_category_id: Set(Some(model.id)),
..Default::default()
}
.insert(&state.db)
.await?;
let mut req = actix_web::test::TestRequest::with_uri(&format!(
"/application_categories/{}",
"/api/application_categories/{}",
model.id
))
.method(Method::DELETE)
@ -275,6 +302,13 @@ mod tests {
.await?,
0
);
let app = application::Entity::find_by_id(app.id)
.one(&state.db)
.await?
.unwrap();
assert_eq!(app.application_category_id, None);
Ok(())
}
@ -316,7 +350,7 @@ mod tests {
.await?;
let mut req = actix_web::test::TestRequest::with_uri(&format!(
"/application_categories/{}/applications",
"/api/application_categories/{}/applications",
category.id
))
.method(Method::GET)

View File

@ -123,7 +123,7 @@ mod tests {
.insert(&state.db)
.await?;
let mut req = actix_web::test::TestRequest::with_uri("/applications")
let mut req = actix_web::test::TestRequest::with_uri("/api/applications")
.method(Method::GET)
.to_request();
let resp = call_endpoint!(req, state);
@ -146,7 +146,7 @@ mod tests {
.await?;
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id))
actix_web::test::TestRequest::with_uri(&format!("/api/applications/{}", model.id))
.method(Method::GET)
.to_request();
let resp = call_endpoint!(req, state);
@ -172,7 +172,7 @@ mod tests {
let state = setup_state().await?;
let mut req = actix_web::test::TestRequest::with_uri("/applications")
let mut req = actix_web::test::TestRequest::with_uri("/api/applications")
.method(Method::POST)
.set_json(model.clone())
.to_request();
@ -209,7 +209,7 @@ mod tests {
model.url = "http://updated.com".into();
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id))
actix_web::test::TestRequest::with_uri(&format!("/api/applications/{}", model.id))
.method(Method::PUT)
.set_json(model.clone())
.to_request();
@ -243,7 +243,7 @@ mod tests {
.await?;
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id))
actix_web::test::TestRequest::with_uri(&format!("/api/applications/{}", model.id))
.method(Method::DELETE)
.to_request();
let resp = call_endpoint!(req, state);

View File

@ -4,7 +4,7 @@ use jsonwebtoken::{encode, EncodingKey, Header};
use tracing::instrument;
use crate::api::api_prelude::*;
use crate::auth::{get_secret, set_password, verify_password};
use crate::auth::{generate_secret, get_secret, set_password, verify_password};
use crate::error::{Error, Result};
#[derive(Debug, Serialize, Deserialize)]
@ -76,6 +76,7 @@ pub async fn initial_setup(
.await?;
if count == 0 {
set_password(&state.db, &req.password).await?;
generate_secret(&state.db).await?;
Ok(HttpResponse::Ok().body(""))
} else {
Err(Error::new(
@ -109,7 +110,7 @@ mod tests {
.insert(&state.db)
.await?;
let mut req = actix_web::test::TestRequest::with_uri("/authorize")
let mut req = actix_web::test::TestRequest::with_uri("/api/authorize")
.method(Method::POST)
.set_json(super::AuthRequest {
password: test_password,

View File

@ -135,7 +135,7 @@ mod tests {
.insert(&state.db)
.await?;
let mut req = actix_web::test::TestRequest::with_uri("/bookmark_categories")
let mut req = actix_web::test::TestRequest::with_uri("/api/bookmark_categories")
.method(Method::GET)
.to_request();
let resp = call_endpoint!(req, state);
@ -156,10 +156,12 @@ mod tests {
.insert(&state.db)
.await?;
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/bookmark_categories/{}", model.id))
.method(Method::GET)
.to_request();
let mut req = actix_web::test::TestRequest::with_uri(&format!(
"/api/bookmark_categories/{}",
model.id
))
.method(Method::GET)
.to_request();
let resp = call_endpoint!(req, state);
let status = resp.status();
let mut data = get_response!(resp, crate::entity::bookmark_category::Model);
@ -180,7 +182,7 @@ mod tests {
let state = setup_state().await?;
let mut req = actix_web::test::TestRequest::with_uri("/bookmark_categories")
let mut req = actix_web::test::TestRequest::with_uri("/api/bookmark_categories")
.method(Method::POST)
.set_json(model.clone())
.to_request();
@ -214,11 +216,13 @@ mod tests {
model.category_name = "Another name".into();
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/bookmark_categories/{}", model.id))
.method(Method::PUT)
.set_json(model.clone())
.to_request();
let mut req = actix_web::test::TestRequest::with_uri(&format!(
"/api/bookmark_categories/{}",
model.id
))
.method(Method::PUT)
.set_json(model.clone())
.to_request();
let resp = call_endpoint!(req, state);
assert_eq!(resp.status(), 200);
let mut data = get_response!(resp, crate::entity::bookmark_category::Model);
@ -246,10 +250,12 @@ mod tests {
.insert(&state.db)
.await?;
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/bookmark_categories/{}", model.id))
.method(Method::DELETE)
.to_request();
let mut req = actix_web::test::TestRequest::with_uri(&format!(
"/api/bookmark_categories/{}",
model.id
))
.method(Method::DELETE)
.to_request();
let resp = call_endpoint!(req, state);
assert_eq!(resp.status(), 200);
assert_eq!(bookmark_category::Entity::find().count(&state.db).await?, 0);
@ -294,7 +300,7 @@ mod tests {
.await?;
let mut req = actix_web::test::TestRequest::with_uri(&format!(
"/bookmark_categories/{}/bookmarks",
"/api/bookmark_categories/{}/bookmarks",
category.id
))
.method(Method::GET)

View File

@ -116,7 +116,7 @@ mod tests {
.insert(&state.db)
.await?;
let mut req = actix_web::test::TestRequest::with_uri("/bookmarks")
let mut req = actix_web::test::TestRequest::with_uri("/api/bookmarks")
.method(Method::GET)
.to_request();
let resp = call_endpoint!(req, state);
@ -138,9 +138,10 @@ mod tests {
.insert(&state.db)
.await?;
let mut req = actix_web::test::TestRequest::with_uri(&format!("/bookmarks/{}", model.id))
.method(Method::GET)
.to_request();
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/api/bookmarks/{}", model.id))
.method(Method::GET)
.to_request();
let resp = call_endpoint!(req, state);
let status = resp.status();
let mut data = get_response!(resp, crate::entity::bookmark::Model);
@ -162,7 +163,7 @@ mod tests {
let state = setup_state().await?;
let mut req = actix_web::test::TestRequest::with_uri("/bookmarks")
let mut req = actix_web::test::TestRequest::with_uri("/api/bookmarks")
.method(Method::POST)
.set_json(model.clone())
.to_request();
@ -198,10 +199,11 @@ mod tests {
model.url = "http://updated.com".into();
let mut req = actix_web::test::TestRequest::with_uri(&format!("/bookmarks/{}", model.id))
.method(Method::PUT)
.set_json(model.clone())
.to_request();
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/api/bookmarks/{}", model.id))
.method(Method::PUT)
.set_json(model.clone())
.to_request();
let resp = call_endpoint!(req, state);
assert_eq!(resp.status(), 200);
let mut data = get_response!(resp, crate::entity::bookmark::Model);
@ -230,9 +232,10 @@ mod tests {
.insert(&state.db)
.await?;
let mut req = actix_web::test::TestRequest::with_uri(&format!("/bookmarks/{}", model.id))
.method(Method::DELETE)
.to_request();
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/api/bookmarks/{}", model.id))
.method(Method::DELETE)
.to_request();
let resp = call_endpoint!(req, state);
assert_eq!(resp.status(), 200);
assert_eq!(bookmark::Entity::find().count(&state.db).await?, 0);

View File

@ -148,7 +148,7 @@ pub fn routes() -> Scope {
.service(api::bookmark_categories::routes())
.service(api::authorization::update_password);
Scope::new("")
Scope::new("api")
.service(api::authorization::authorize)
.service(api::authorization::initial_setup)
.service(protected_routes)

View File

@ -112,7 +112,7 @@ pub async fn get_secret(db: &DatabaseConnection) -> crate::error::Result<Vec<u8>
Err(Error::new(ErrorCode::Internal, "Could not decode secret"))
}
} else {
Err(Error::new(ErrorCode::Internal, "No secret found"))
Err(Error::new(ErrorCode::NoSetup, "Not setup"))
}
}

View File

@ -1,5 +1,3 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.5.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

View File

@ -1,3 +1,4 @@
use actix_web::http::StatusCode;
use serde::{Deserialize, Serialize};
pub type Result<T> = std::result::Result<T, Error>;
@ -8,6 +9,7 @@ pub enum ErrorCode {
DatabaseError,
UnAuthorized,
Internal,
NoSetup,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
@ -47,7 +49,15 @@ impl From<&str> for Error {
}
}
impl actix_web::error::ResponseError for Error {}
impl actix_web::error::ResponseError for Error {
fn status_code(&self) -> actix_web::http::StatusCode {
match self.code {
ErrorCode::UnAuthorized => StatusCode::UNAUTHORIZED,
ErrorCode::NoSetup => StatusCode::UPGRADE_REQUIRED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl From<sea_orm::DbErr> for Error {
fn from(e: sea_orm::DbErr) -> Self {

36
src/main.js Normal file
View File

@ -0,0 +1,36 @@
import { createApp } from 'vue'
import App from './ui/App.vue'
import router from './ui/router'
import axios from "axios"
import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
axios.interceptors.request.use(function (config) {
const token = localStorage.getItem("token");
config.headers.Authorization = `Bearer ${token}`;
return config;
});
axios.interceptors.response.use(function (response) {
return response;
}, function (error) {
console.log(error);
if (error.response.status === 426) {
router.push("/setup");
}
if (error.response.status === 401) {
router.push("/login");
}
return error;
});
library.add(fas)
createApp(App)
.use(router)
.component("font-awesome-icon", FontAwesomeIcon)
.mount('#app')

View File

@ -1,4 +1,5 @@
use actix_web::{web, App, HttpServer};
use actix_web::{get, web, App, HttpResponse, HttpServer};
use rust_embed::RustEmbed;
use sea_orm::{Database, DatabaseConnection};
use tracing::{info, instrument};
use tracing_subscriber::prelude::*;
@ -29,12 +30,38 @@ async fn main() {
info!("Starting http server on 8080");
HttpServer::new(move || App::new().app_data(state.clone()).service(api::routes()))
.bind("127.0.0.1:8080")
.unwrap()
.run()
.await
.expect("Couldnt launch server");
HttpServer::new(move || {
App::new()
.app_data(state.clone())
.service(api::routes())
.service(dist)
})
.bind("127.0.0.1:8080")
.unwrap()
.run()
.await
.expect("Couldnt launch server");
}
#[derive(RustEmbed)]
#[folder = "dist"]
struct UIAssets;
#[get("/{filename:.*}")]
async fn dist(path: web::Path<String>) -> HttpResponse {
let path = if UIAssets::get(&*path).is_some() {
&*path
} else {
"index.html"
};
let content = UIAssets::get(path).unwrap();
let body: actix_web::body::BoxBody = match content {
std::borrow::Cow::Borrowed(bytes) => actix_web::body::BoxBody::new(bytes),
std::borrow::Cow::Owned(bytes) => actix_web::body::BoxBody::new(bytes),
};
HttpResponse::Ok()
.content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref())
.body(body)
}
#[instrument]

51
src/ui/App.vue Normal file
View File

@ -0,0 +1,51 @@
<template>
<div>
<router-view />
</div>
</template>
<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font-family: "Roboto", sans-serif;
font-size: 35px;
background-color: #222;
background-repeat: none;
background-size: cover;
}
a,
a:hover,
a:active,
label {
color: #fff;
text-decoration: none;
}
#app {
color: #fff;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
* {
font-size: 15px;
font-weight: 300;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 20px;
font-weight: 500;
color: #fff;
}
</style>

BIN
src/ui/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,77 @@
<template>
<modal :open="open">
<template #body>
<form @submit.prevent="save">
<text-field v-model="category.categoryName" label="Name" />
<icon-picker v-model="category.glyph" />
</form>
</template>
<template #actions>
<btn @click="delCategory" label="Delete" danger v-if="!!this.category.id" />
<btn @click="close" label="Cancel" />
<btn @click="submit" :label="saveLabel" />
</template>
</modal>
</template>
<script>
import TextField from "./TextField.vue";
import Modal from "./Modal.vue";
import IconPicker from "./IconPicker.vue";
import axios from "axios";
import Btn from "./Button.vue";
export default {
components: { TextField, Modal, IconPicker, Btn },
props: ["open", "mode", "data"],
watch: {
data: function (next) {
if (next) {
this.category = JSON.parse(JSON.stringify(next));
}
},
},
data() {
return {
category: {},
};
},
computed: {
saveLabel() {
return this.category.id ? "Update" : "Save";
},
},
methods: {
close() {
this.$emit("close");
},
async delCategory() {
let resp = await axios.delete(
`/api/application_categories/${this.category.id}`,
this.category
);
if (resp.status == 200) {
this.$emit("update");
}
},
async submit() {
if (this.category.id) {
let resp = await axios.put(
`/api/application_categories/${this.category.id}`,
this.category
);
if (resp.status == 200) {
this.$emit("update");
}
} else {
let resp = await axios.post("/api/application_categories", {
active: true,
...this.category,
});
if (resp.status == 200) {
this.$emit("update");
}
}
},
},
};
</script>

View File

@ -0,0 +1,90 @@
<template>
<modal :open="open">
<template #body>
<form @submit.prevent="">
<select-field
v-model="app.applicationCategoryId"
label="Category"
:items="selectCategories"
emptyLabel="No Category"
/>
<text-field v-model="app.appName" label="Name" />
<text-field v-model="app.description" label="Description" />
<text-field v-model="app.url" label="URL" />
<icon-picker v-model="app.glyph" />
</form>
</template>
<template #actions>
<btn @click="delApp" label="Delete" danger v-if="!!this.app.id" />
<btn @click="close" label="Cancel" />
<btn :label="saveLabel" type="button" @click="save" />
</template>
</modal>
</template>
<script>
import TextField from "./TextField.vue";
import Modal from "./Modal.vue";
import IconPicker from "./IconPicker.vue";
import axios from "axios";
import Btn from "./Button.vue";
import SelectField from "./SelectField.vue";
export default {
components: { TextField, Modal, IconPicker, Btn, SelectField },
props: ["open", "mode", "data", "categories"],
watch: {
data: function (next) {
if (next) {
this.app = JSON.parse(JSON.stringify(next));
}
},
},
data() {
return {
app: {},
};
},
computed: {
saveLabel() {
return this.app.id ? "Update" : "Save";
},
selectCategories() {
return this.categories.map((i) => ({ label: i.categoryName, value: i.id }));
},
},
methods: {
close() {
this.$emit("close");
},
async delApp() {
let resp = await axios.delete(
`/api/applications/${this.app.id}`,
this.app
);
if (resp.status == 200) {
this.$emit("update");
}
},
async save() {
if (this.app.id) {
let resp = await axios.put(
`/api/applications/${this.app.id}`,
this.app
);
if (resp.status == 200) {
this.$emit("update");
}
} else {
let resp = await axios.post("/api/applications", {
active: true,
...this.app,
});
if (resp.status == 200) {
this.$emit("update");
}
}
},
},
};
</script>

View File

@ -0,0 +1,57 @@
<template>
<div class="tile" @click="click">
<font-awesome-icon :icon="appData.glyph" size="2x" />
<div class="label">
<div class="title">{{appData.appName}}</div>
<div class="description">{{appData.description}}</div>
</div>
</div>
</template>
<script>
export default {
props: ["appData"],
methods: {
click(e) {
this.$emit("clicked", e, this.appData);
},
},
};
</script>
<style>
.tile {
transition: all 0.5s;
padding: 16px 25px;
border-radius: 5px;
margin: 5px;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: flex-start;
background: transparent;
}
.tile:hover {
transition: all 0.1s;
background-color: #333;
box-shadow: rgba(0, 0, 0, 0.5) 0px 20px 30px -10px;
}
.label {
flex: 1;
text-align: left;
}
svg {
color: #fff;
margin-right: 20px;
}
.title {
color: #fff;
font-weight: bold;
text-transform: uppercase;
line-height: 1;
}
.description {
color: #ccc;
font-size: 0.8em;
line-height: 1;
}
</style>

View File

@ -0,0 +1,77 @@
<template>
<modal :open="open">
<template #body>
<form @submit.prevent="save">
<text-field v-model="category.categoryName" label="Name" />
<icon-picker v-model="category.glyph" />
</form>
</template>
<template #actions>
<btn @click="delCategory" label="Delete" danger v-if="!!this.category.id" />
<btn @click="close" label="Cancel" />
<btn @click="submit" :label="saveLabel" />
</template>
</modal>
</template>
<script>
import TextField from "./TextField.vue";
import Modal from "./Modal.vue";
import IconPicker from "./IconPicker.vue";
import axios from "axios";
import Btn from "./Button.vue";
export default {
components: { TextField, Modal, IconPicker, Btn },
props: ["open", "mode", "data"],
watch: {
data: function (next) {
if (next) {
this.category = JSON.parse(JSON.stringify(next));
}
},
},
data() {
return {
category: {},
};
},
computed: {
saveLabel() {
return this.category.id ? "Update" : "Save";
},
},
methods: {
close() {
this.$emit("close");
},
async delCategory() {
let resp = await axios.delete(
`/api/bookmark_categories/${this.category.id}`,
this.category
);
if (resp.status == 200) {
this.$emit("update");
}
},
async submit() {
if (this.category.id) {
let resp = await axios.put(
`/api/bookmark_categories/${this.category.id}`,
this.category
);
if (resp.status == 200) {
this.$emit("update");
}
} else {
let resp = await axios.post("/api/bookmark_categories", {
active: true,
...this.category,
});
if (resp.status == 200) {
this.$emit("update");
}
}
},
},
};
</script>

View File

@ -0,0 +1,86 @@
<template>
<modal :open="open">
<template #body>
<form @submit.prevent="">
<select-field
v-model="bookmark.bookmarkCategoryId"
label="Category"
:items="selectCategories"
emptyLabel="No Category"
/>
<text-field v-model="bookmark.bookmarkName" label="Name" />
<text-field v-model="bookmark.url" label="URL" />
</form>
</template>
<template #actions>
<btn @click="delBookmark" label="Delete" danger v-if="!!this.bookmark.id" />
<btn @click="close" label="Cancel" />
<btn :label="saveLabel" type="button" @click="save" />
</template>
</modal>
</template>
<script>
import TextField from "./TextField.vue";
import Modal from "./Modal.vue";
import axios from "axios";
import Btn from "./Button.vue";
import SelectField from "./SelectField.vue";
export default {
components: { TextField, Modal, Btn, SelectField },
props: ["open", "mode", "data", "categories"],
watch: {
data: function (next) {
if (next) {
this.bookmark = JSON.parse(JSON.stringify(next));
}
},
},
data() {
return {
bookmark: {},
};
},
computed: {
saveLabel() {
return this.bookmark.id ? "Update" : "Save";
},
selectCategories() {
return this.categories.map((i) => ({ label: i.categoryName, value: i.id }));
},
},
methods: {
close() {
this.$emit("close");
},
async delBookmark() {
let resp = await axios.delete(
`/api/bookmarks/${this.bookmark.id}`,
this.bookmark
);
if (resp.status == 200) {
this.$emit("update");
}
},
async save() {
if (this.bookmark.id) {
let resp = await axios.put(
`/api/bookmarks/${this.bookmark.id}`,
this.bookmark
);
if (resp.status == 200) {
this.$emit("update");
}
} else {
let resp = await axios.post("/api/bookmarks", {
active: true,
...this.bookmark,
});
if (resp.status == 200) {
this.$emit("update");
}
}
},
},
};
</script>

View File

@ -0,0 +1,35 @@
<template>
<div class="bookmark-tile" @click="click">
<font-awesome-icon icon="external-link-alt"/>
{{bookmark.bookmarkName}}
</div>
</template>
<script>
export default {
props: ["bookmark"],
methods: {
click() {
this.$emit("clicked", this.bookmark);
},
},
};
</script>
<style>
.bookmark-tile {
padding: 5px 25px;
}
.bookmark-tile, .bookmark-tile svg {
cursor: pointer;
font-size: 16px;
color: #ccc;
}
.bookmark-tile:hover, .bookmark-tile:hover svg {
color: #fff;
}
</style>

View File

@ -0,0 +1,41 @@
<template>
<button :class="{danger: danger}">{{label}}</button>
</template>
<script>
export default {
props: {
label: String,
danger: Boolean,
},
};
</script>
<style scoped>
button {
cursor: pointer;
transition: all 0.2s;
padding: 10px 18px;
background: #333;
color: #fff;
font-weight: 500;
border: 2px solid #222;
background: #333;
box-shadow: none;
}
button.danger {
color: #cc0000;
background-color: #200;
border: 2px solid #200;
}
button:hover {
transition: all 0.2s;
background: #444;
}
button.danger:hover {
background: #400;
}
</style>

View File

@ -0,0 +1,41 @@
<template>
<div :class="{btn: true, active: editMode}">
<font-awesome-icon icon="cogs" size="2x" />
</div>
</template>
<script>
export default {
props: ["editMode"],
};
</script>
<style scoped>
.btn {
transition: all 0.2s;
position: fixed;
bottom: 20px;
right: 40px;
text-align: center;
width: 50px;
height: 50px;
opacity: 0.5;
}
.btn svg {
margin: auto auto;
color: #777;
}
.btn:hover {
transition: all 0.2s;
opacity: 1;
}
.btn:hover svg {
color: #ccc;
}
.active {
opacity: 1;
}
.btn.active svg {
color: #8844cc;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="form-field">
<slot></slot>
</div>
</template>
<style>
.form-field {
display: flex;
flex-direction: row;
align-items: stretch;
text-align: left;
}
label {
margin-right: 15px;
font-weight: bold;
text-transform: uppercase;
width: 150px;
}
.form-field + .form-field {
margin-top: 15px;
}
.form-field + button {
margin-top: 15px;
}
</style>

File diff suppressed because it is too large Load Diff

113
src/ui/components/Modal.vue Normal file
View File

@ -0,0 +1,113 @@
<template>
<div v-bind:class="{modal: true, open: open}">
<div class="content">
<div class="body">
<slot name="body"></slot>
</div>
<div class="actions">
<slot name="actions"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
props: ["open"],
};
</script>
<style scoped>
@keyframes open {
0% {
transform: scale(1.2);
opacity: 0;
}
25% {
transform: scale(0.9);
opacity: 0.5;
}
50% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.modal-backdrop {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 100; /* Sit on top */
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgb(0, 0, 0); /* Fallback color */
background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */
}
.modal-backdrop .open {
display: block;
}
.modal {
background: transparent;
position: fixed; /* Stay in place */
top: 5%;
left: 50%;
transform: translate(-50%, 0%);
display: none;
}
.modal.open {
display: block;
}
.modal.open .content {
width: 600px;
margin: auto auto;
animation: open 0.5s;
animation-timing-function: cubic-bezier(0.455, 0.03, 0.515, 0.955);
}
.modal .content {
transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1),
height 300ms cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1), 0 6px 6px rgba(0, 0, 0, 0.16);
background: #333;
display: flex;
flex-direction: column;
align-items: stretch;
overflow: hidden;
overflow-y: auto;
max-width: 80%;
min-width: 300px;
}
.modal .content .body {
padding: 25px;
}
.modal .actions {
background: #3c3c3c;
border-top: 1px solid #2c2c2c;
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 15px;
padding: 5px;
}
</style>
<style>
.modal .actions button + button {
margin-left: 15px;
}
</style>

View File

@ -0,0 +1,20 @@
<template>
<div class="new tile">
<font-awesome-icon icon="plus" />
<div class="label">
<div class="title">{{title}}</div>
</div>
</div>
</template>
<script>
export default {
props: ["title"],
};
</script>
<style scoped>
.tile.new {
background: #444;
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="panel">
<slot></slot>
</div>
</template>
<style>
.panel {
border-radius: 5px;
background: #333;
padding: 25px;
box-shadow: rgb(0, 0, 0) 0px 20px 30px -10px;
display: flex;
flex-direction: column;
align-items: stretch;
}
</style>
<script>
export default {
name: "Panel",
setup() {},
};
</script>>

View File

@ -0,0 +1,59 @@
<template>
<form-field>
<label>{{label}}</label>
<select @change="handleChange">
<option :selected="!modelValue" v-if="emptyLabel" value>{{emptyLabel}}</option>
<option
:selected="modelValue === item.value"
v-for="item in items"
:key="item"
:value="item.value"
>{{item.label}}</option>
</select>
</form-field>
</template>
<script>
import FormField from "./FormField.vue";
export default {
components: { FormField },
props: ["items", "modelValue", "label", "emptyLabel"],
methods: {
handleChange(e) {
this.$emit("update:modelValue", parseInt(e.target.value));
},
},
};
</script>
<style scoped>
select {
flex: 1;
display: block;
color: #ccc;
line-height: 1.3;
padding: 0.6em 1.4em 0.5em 0.8em;
width: 100%;
max-width: 100%;
box-sizing: border-box;
margin: 0;
border: none;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background-color: #121212;
}
select::-ms-expand {
display: none;
}
select:hover {
border-color: #888;
}
select:focus {
color: #ccc;
outline: none;
}
select option {
font-weight: normal;
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<form-field>
<label v-if="label">{{label}}</label>
<input :value="modelValue" ref="field" @input="handleInput" :type="inputType" />
</form-field>
</template>
<script>
import FormField from "./FormField.vue";
export default {
props: {
label: String,
modelValue: String,
password: Boolean,
},
computed: {
inputType() {
return this.password ? "password" : "text";
},
},
components: { FormField },
methods: {
handleInput() {
this.$emit("update:modelValue", this.$refs.field.value);
},
},
};
</script>
<style scoped>
input {
color: #fff;
background-color: #121212;
outline: none;
border: 1px solid #000;
flex: 1;
padding: 8px 15px;
}
</style>

37
src/ui/router/index.js Normal file
View File

@ -0,0 +1,37 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from "../views/Dashboard.vue"
const routes = [
{
path: '/logout',
name: 'Logout',
component: () => import(/* webpackChunkName: "login" */ '../views/Logout.vue')
},
{
path: '/login',
name: 'Login',
component: () => import(/* webpackChunkName: "login" */ '../views/Login.vue')
},
{
path: '/setup',
name: 'Setup',
component: () => import(/* webpackChunkName: "login" */ '../views/Setup.vue')
},
{
path: '/',
name: 'Dashboard',
component: Dashboard,
},
{
path: '/admin/applications/new',
name: 'NewApplication',
component: () => import(/* webpackChunkName: "login" */ '../views/CreateApplication.vue')
}
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router

View File

@ -0,0 +1,53 @@
<template>
<div class="container">
<form @submit.prevent="save">
<panel>
<text-field v-model="appName" label="Name" />
<text-field v-model="description" label="Description" />
<text-field v-model="url" label="URL" />
<text-field v-model="glyph" label="Glyph" />
<btn @click="save" label="Save" />
</panel>
</form>
</div>
</template>
<script>
import Panel from "../components/Panel.vue";
import TextField from "../components/TextField.vue";
import Btn from "../components/Button.vue";
import axios from "axios";
export default {
data() {
return {
appName: "app name",
description: "test description",
url: "http://example.com",
glyph: "information",
};
},
components: {
Panel,
TextField,
Btn,
},
methods: {
save() {
axios.post("/api/applications", {
appName: this.appName,
description: this.description,
glyph: this.glyph,
url: this.url,
active: true,
});
},
},
};
</script>
<style>
.container {
width: 600px;
margin: auto auto;
}
</style>

267
src/ui/views/Dashboard.vue Normal file
View File

@ -0,0 +1,267 @@
<template>
<div class="dashboard">
<div class="editmode-tiles">
<new-item-tile title="New Application" v-if="editMode" @click="openNewApp" />
<new-item-tile title="New App Category" v-if="editMode" @click="openNewAppCat" />
<new-item-tile title="New Bookmark" v-if="editMode" @click="openNewBookmark" />
<new-item-tile title="New Bookmark Category" v-if="editMode" @click="openNewBookmarkCat" />
</div>
<div class="container" v-if="appsForCategory(null).length > 0 || editMode">
<h1>APPLICATIONS</h1>
<div class="app-tiles">
<application-tile
@clicked="appTileClicked"
v-for="app in appsForCategory(null)"
:appData="app"
:key="app.id"
/>
<application-modal
:open="appOpen"
:data="editApp"
:categories="applicationCategories"
@close="closeNewApp"
@update="reload"
/>
<application-category-modal
:open="appCatOpen"
:data="editAppCat"
@close="closeNewAppCat"
@update="reload"
/>
<bookmark-modal
:open="bookmarkOpen"
:data="editBookmark"
:categories="bookmarkCategories"
@close="closeBookmark"
@update="reload"
/>
<bookmark-category-modal
:open="bookmarkCatOpen"
:data="editBookmarkCat"
@close="closeNewBookmarkCat"
@update="reload"
/>
</div>
</div>
<div class="container" v-for="cat in applicationCategories" :key="cat.id">
<h1 @click="appCatClicked(cat)">
<font-awesome-icon v-if="cat.glyph" :icon="cat.glyph" />
{{cat.categoryName}}
</h1>
<div class="app-tiles">
<application-tile
@clicked="appTileClicked"
v-for="app in appsForCategory(cat.id)"
:appData="app"
:key="app.id"
/>
</div>
</div>
<div class="container">
<h1 class="bookmark">BOOKMARKS</h1>
<div class="bookmark-container">
<div class="bookmark-category">
<h2>UNCATEGORZED</h2>
<bookmark-tile
v-for="bm in bookmarksForCategory(null)"
:key="bm.id"
:bookmark="bm"
@clicked="bookmarkClicked"
/>
</div>
<div class="bookmark-category" v-for="cat in bookmarkCategories" :key="cat.id">
<h2 @click="bookmarkCatClicked(cat)">
<font-awesome-icon v-if="cat.glyph" :icon="cat.glyph" />
{{cat.categoryName}}
</h2>
<bookmark-tile
@clicked="bookmarkClicked"
v-for="bm in bookmarksForCategory(cat.id)"
:key="bm.id"
:bookmark="bm"
/>
</div>
</div>
</div>
<edit-mode-button :editMode="editMode" @click="toggleEdit" />
</div>
</template>
<script>
import axios from "axios";
import ApplicationTile from "../components/ApplicationTile.vue";
import ApplicationModal from "../components/ApplicationModal.vue";
import ApplicationCategoryModal from "../components/ApplicationCategoryModal.vue";
import EditModeButton from "../components/EditModeButton.vue";
import NewItemTile from "../components/NewItemTile.vue";
import BookmarkModal from "../components/BookmarkModal.vue";
import BookmarkTile from "../components/BookmarkTile.vue";
import BookmarkCategoryModal from "../components/BookmarkCategoryModal.vue";
export default {
name: "Dashboard",
methods: {
appsForCategory(catId) {
return this.applications.filter((i) => i.applicationCategoryId === catId);
},
bookmarksForCategory(catId) {
return this.bookmarks.filter((i) => i.bookmarkCategoryId === catId);
},
openNewApp() {
this.editApp = {};
this.appOpen = true;
},
openNewBookmark() {
this.bookmarkOpen = true;
},
closeNewApp() {
this.editApp = {};
this.appOpen = false;
},
closeBookmark() {
this.editBookmark = {};
this.bookmarkOpen = false;
},
openNewAppCat() {
this.appCatOpen = true;
this.editAppCat = {};
},
closeNewAppCat() {
this.editAppCat = {};
this.appCatOpen = false;
},
openNewBookmarkCat() {
this.bookmarkCatOpen = true;
},
closeNewBookmarkCat() {
this.editBookmarkCat = {};
this.bookmarkCatOpen = false;
},
toggleEdit() {
this.editMode = !this.editMode;
},
appTileClicked(e, app) {
if (this.editMode) {
this.editApp = app;
this.appOpen = true;
} else {
window.open(app.url);
}
},
appCatClicked(cat) {
if (this.editMode) {
this.editAppCat = cat;
this.appCatOpen = true;
}
},
bookmarkCatClicked(cat) {
if (this.editMode) {
this.editBookmarkCat = cat;
this.bookmarkCatOpen = true;
}
},
bookmarkClicked(bm) {
if (this.editMode) {
this.editBookmark = bm;
this.bookmarkOpen = true;
} else {
window.open(bm.url);
}
},
async reload() {
this.applications = (await axios.get("/api/applications")).data.items;
this.applicationCategories = (
await axios.get("/api/application_categories")
).data.items;
this.bookmarks = (await axios.get("/api/bookmarks")).data.items;
this.bookmarkCategories = (
await axios.get("/api/bookmark_categories")
).data.items;
this.editApp = {};
this.newAppOpen = false;
this.appCatOpen = false;
this.bookmarkCatOpen = false;
this.bookmarkOpen = false;
this.editAppCat = {};
},
},
components: {
ApplicationTile,
ApplicationModal,
ApplicationCategoryModal,
EditModeButton,
NewItemTile,
BookmarkModal,
BookmarkTile,
BookmarkCategoryModal,
},
data() {
return {
editMode: false,
applications: [],
applicationCategories: [],
appOpen: false,
appCatOpen: false,
bookmarkOpen: false,
bookmarkCatOpen: false,
editApp: {},
editAppCat: {},
editBookmark: {},
editBookmarkCat: {},
bookmarks: [],
bookmarkCategories: [],
};
},
async mounted() {
this.reload();
},
};
</script>
<style scope>
h1 {
margin-top: 0px;
font-weight: bold;
line-height: 1;
margin-bottom: 5px;
padding-left: 25px;
font-size: 24px;
}
h2 {
color: #fff;
font-size: 18px;
text-transform: uppercase;
padding-left: 25px;
font-weight: bold;
margin-bottom: 5px;
}
h2 svg {
margin-right: 5px;
}
.app-tiles {
display: grid;
grid-template-columns: repeat(4, 25%);
}
.container {
padding: 10px 50px;
width: 1000px;
margin: auto auto;
}
.dashboard {
margin-top: 150px;
}
.editmode-tiles {
display: flex;
padding: 10px 50px;
width: 1000px;
margin: 15px auto;
flex-direction: row;
justify-content: space-between;
}
.bookmark-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
</style>

74
src/ui/views/Login.vue Normal file
View File

@ -0,0 +1,74 @@
<template>
<div class="login-wrapper">
<form @submit.prevent="goLogin">
<panel>
<input v-model="password" type="password" autofocus />
<button @clicked="goLogin" type="submit">Authenticate</button>
</panel>
</form>
</div>
</template>
<script>
import axios from "axios";
import Panel from "../components/Panel.vue";
export default {
name: "Login",
components: {
Panel
},
data() {
return {
password: "",
};
},
methods: {
async goLogin() {
const response = await axios.post("/api/authorize", {
password: this.password,
});
localStorage.setItem("token", response.data.token);
this.$router.push("/");
},
},
};
</script>
<style scoped>
.login-wrapper {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 100vh;
align-items: center;
justify-items: center; /* adjusted */
}
input {
background: #333;
font-size: 40px;
color: #fff;
background: #121212;
font-weight: bold;
padding: 12px 22px;
border-radius: 4px;
border: 1px solid #777;
margin-bottom: 15px;
}
button {
transition: all 0.2s;
padding: 10px 18px;
background: #333;
color: #fff;
font-weight: bold;
border: 1px solid #000;
background: #121212;
box-shadow: none;
}
button:hover {
transition: all 0.2s;
box-shadow: rgba(0, 0, 0, 0.25) 0px 14px 28px,
rgba(0, 0, 0, 0.22) 0px 10px 10px;
}
</style>>

12
src/ui/views/Logout.vue Normal file
View File

@ -0,0 +1,12 @@
<template>
<div></div>
</template>
<script>
export default {
created() {
localStorage.removeItem("token");
this.$router.push("/login");
},
};
</script>

45
src/ui/views/Setup.vue Normal file
View File

@ -0,0 +1,45 @@
<template>
<div class="container">
<panel>
<h1>Setup a password</h1>
<text-field v-model="password" password />
<btn label="Setup" @click="setup" />
</panel>
</div>
</template>
<script>
import Panel from "../components/Panel.vue";
import TextField from "../components/TextField.vue";
import Btn from "../components/Button.vue";
import axios from "axios";
export default {
components: { Panel, TextField, Btn },
data() {
return {
password: "",
};
},
methods: {
async setup() {
await axios.post("/api/setup", { password: this.password });
this.$router.push("/");
},
},
};
</script>
<style scoped>
.container {
padding: 10px 50px;
width: 500px;
margin: 50px auto;
color: #fff;
}
h1 {
text-align: center;
margin-bottom: 25px;
font-weight: 300;
font-size: 24px;
text-transform: uppercase;
}
</style>

24
vade-ui/README.md Normal file
View File

@ -0,0 +1,24 @@
# vade-ui
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View File