Light & Dark Mode

This commit is contained in:
Joe Bellus 2022-02-15 00:46:49 +00:00
parent b1c8342253
commit 82704aaa35
15 changed files with 415 additions and 37 deletions

72
Cargo.lock generated
View File

@ -203,6 +203,24 @@ dependencies = [
"syn",
]
[[package]]
name = "actix-multipart"
version = "0.4.0-beta.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59b1f14a8b2bc14df9be544d173f5390da5b62d531e406fd0f0ce9b825fea5a"
dependencies = [
"actix-utils 3.0.0",
"actix-web 4.0.0-rc.2",
"bytes 1.1.0",
"derive_more",
"futures-core",
"httparse",
"local-waker",
"log",
"mime",
"twoway",
]
[[package]]
name = "actix-router"
version = "0.2.7"
@ -1132,9 +1150,9 @@ checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7"
[[package]]
name = "futures"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28560757fe2bb34e79f907794bb6b22ae8b0e5c669b638a1132f2592b19035b4"
checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e"
dependencies = [
"futures-channel",
"futures-core",
@ -1147,9 +1165,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3dda0b6588335f360afc675d0564c17a77a2bda81ca178a4b6081bd86c7f0b"
checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010"
dependencies = [
"futures-core",
"futures-sink",
@ -1157,15 +1175,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0c8ff0461b82559810cdccfde3215c3f373807f5e5232b71479bff7bb2583d7"
checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
[[package]]
name = "futures-executor"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29d6d2ff5bb10fb95c85b8ce46538a2e5f5e7fdc755623a7d4529ab8a4ed9d2a"
checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6"
dependencies = [
"futures-core",
"futures-task",
@ -1185,15 +1203,15 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9d34af5a1aac6fb380f735fe510746c38067c5bf16c7fd250280503c971b2"
checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
[[package]]
name = "futures-macro"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbd947adfffb0efc70599b3ddcf7b5597bb5fa9e245eb99f62b3a5f7bb8bd3c"
checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512"
dependencies = [
"proc-macro2",
"quote",
@ -1202,21 +1220,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3055baccb68d74ff6480350f8d6eb8fcfa3aa11bdc1a1ae3afdd0514617d508"
checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868"
[[package]]
name = "futures-task"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ee7c6485c30167ce4dfb83ac568a849fe53274c831081476ee13e0dce1aad72"
checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a"
[[package]]
name = "futures-util"
version = "0.3.19"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b5cf40b47a271f77a8b1bec03ca09044d99d2372c0de244e66430761127164"
checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a"
dependencies = [
"futures-channel",
"futures-core",
@ -3328,12 +3346,28 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642"
[[package]]
name = "twoway"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c57ffb460d7c24cd6eda43694110189030a3d1dfe418416d9468fd1c1d290b47"
dependencies = [
"memchr",
"unchecked-index",
]
[[package]]
name = "typenum"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
[[package]]
name = "unchecked-index"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c"
[[package]]
name = "unicase"
version = "2.6.0"
@ -3410,6 +3444,7 @@ version = "0.1.1"
dependencies = [
"actix",
"actix-cors",
"actix-multipart",
"actix-rt 2.6.0",
"actix-web 4.0.0-rc.2",
"actix-web-actors",
@ -3418,6 +3453,7 @@ dependencies = [
"bcrypt",
"chrono",
"clap",
"futures",
"jemallocator",
"jsonwebtoken",
"mime_guess",

View File

@ -31,6 +31,8 @@ base64 = "0.13.0"
sqlx = { version = "^0.5", features=["sqlite", "migrate"] }
reqwest = { version = "0.11.9", features = ["rustls-tls"], default-features=false }
clap = { version = "3.0.14", features=["cargo", "env"] }
actix-multipart = "0.4.0-beta.13"
futures = "0.3.21"
[target.'cfg(all(target_env = "musl", target_pointer_width = "64"))'.dependencies.jemallocator]

View File

@ -3,4 +3,4 @@ COPY target/x86_64-unknown-linux-musl/release/vade /app/vade
EXPOSE 8089
WORKDIR app
RUN mkdir data
CMD ["./vade", "--db data/"]
CMD ./vade --db data/

View File

@ -1,8 +1,9 @@
use actix_web::web::Data;
use actix_web::Scope;
use actix_web_httpauth::middleware::HttpAuthentication;
use serde::{Deserialize, Serialize};
use crate::{api, auth};
use crate::{api, auth, AppState};
#[macro_export]
#[cfg(test)]
@ -64,6 +65,7 @@ pub mod applications;
pub mod authorization;
pub mod bookmark_categories;
pub mod bookmarks;
pub mod settings;
mod api_prelude {
pub use super::ListObjects;
@ -107,6 +109,7 @@ pub mod test_prelude {
Ok(actix_web::web::Data::new(AppState {
db,
healthcheck_status: Mutex::new(HashMap::new()),
data_path: ".".to_string(),
}))
}
}
@ -127,6 +130,29 @@ impl<T: Serialize> ListObjects<T> {
}
}
use actix_multipart::Multipart;
use actix_web::{post, web, HttpResponse};
use futures::StreamExt;
use std::io::Write;
use std::path::Path;
#[post("/upload/bg")]
async fn bg_upload(
mut payload: Multipart,
state: Data<AppState>,
) -> crate::error::Result<HttpResponse> {
if let Some(Ok(mut field)) = payload.next().await {
let file_path = Path::new(&state.data_path).join("bg.jpg");
let mut f = web::block(|| std::fs::File::create(file_path)).await??;
while let Some(chunk) = field.next().await {
let data = chunk.unwrap();
f = web::block(move || f.write_all(&data).map(|_| f)).await??;
}
}
Ok(HttpResponse::Ok().body(""))
}
pub fn routes() -> Scope {
let auth_handler = HttpAuthentication::bearer(auth::validator);
let protected_routes = Scope::new("")
@ -135,7 +161,9 @@ pub fn routes() -> Scope {
.service(api::application_categories::routes())
.service(api::bookmarks::routes())
.service(api::bookmark_categories::routes())
.service(api::authorization::update_password);
.service(api::settings::routes())
.service(api::authorization::update_password)
.service(bg_upload);
Scope::new("api")
.service(api::authorization::authorize)

63
src/api/settings.rs Normal file
View File

@ -0,0 +1,63 @@
use crate::entity::setting;
use actix_web::{web, HttpResponse};
use serde::{Deserialize, Serialize};
use super::api_prelude::*;
#[derive(Debug, Serialize, Deserialize)]
struct ThemeModeResponse {
mode: String,
}
#[get("theme/mode")]
async fn theme_mode(state: web::Data<AppState>) -> crate::error::Result<HttpResponse> {
let mode = setting::Entity::find()
.filter(setting::Column::SettingName.eq("theme_mode".to_string()))
.one(&state.db)
.await?
.map(|r| r.setting_value)
.unwrap_or_else(|| "dark".to_string());
Ok(HttpResponse::Ok().json(ThemeModeResponse { mode }))
}
#[derive(Debug, Serialize, Deserialize)]
struct ThemeModeRequest {
mode: String,
}
#[put("theme/mode")]
async fn update_theme_mode(
data: web::Json<ThemeModeRequest>,
state: web::Data<AppState>,
) -> crate::error::Result<HttpResponse> {
let ThemeModeRequest { mode } = data.0;
let setting = setting::Entity::find()
.filter(setting::Column::SettingName.eq("theme_mode".to_string()))
.one(&state.db)
.await?;
if let Some(setting) = setting {
let rec = setting::ActiveModel {
id: Set(setting.id),
setting_value: Set(mode.clone()),
..Default::default()
};
rec.update(&state.db).await?;
} else {
let rec = setting::ActiveModel {
setting_name: Set("theme_mode".to_string()),
setting_value: Set(mode.clone()),
..Default::default()
};
rec.save(&state.db).await?;
}
Ok(HttpResponse::Ok().json(ThemeModeResponse { mode }))
}
pub fn routes() -> Scope {
web::scope("/settings")
.service(theme_mode)
.service(update_theme_mode)
}

View File

@ -77,6 +77,15 @@ impl From<sea_orm::DbErr> for Error {
}
}
impl From<actix_web::error::BlockingError> for Error {
fn from(source: actix_web::error::BlockingError) -> Self {
Self {
code: ErrorCode::Internal,
message: source.to_string(),
}
}
}
impl From<sqlx::Error> for Error {
fn from(e: sqlx::Error) -> Self {
Self {

View File

@ -13,10 +13,14 @@ axios.interceptors.request.use(function (config) {
return config;
});
function rootUrl() {
return process.env.NODE_ENV === 'development'
? 'http://localhost:8089'
: '';
}
axios.defaults.baseURL = process.env.NODE_ENV === 'development'
? 'http://localhost:8089'
: '';
axios.defaults.baseURL = rootUrl();
axios.interceptors.response.use(function (response) {
return response;
@ -34,8 +38,15 @@ axios.interceptors.response.use(function (response) {
library.add(fas)
const rootUrlPlugin = {
install: (app) => {
app.config.globalProperties.$rootUrl = rootUrl
}
};
createApp(App)
.use(router)
.component("font-awesome-icon", FontAwesomeIcon)
.use(rootUrlPlugin)
.mount('#app')

View File

@ -1,7 +1,11 @@
use std::{collections::HashMap, path::Path};
use actix::SystemService;
use actix_web::{get, web, App, HttpResponse, HttpServer};
use actix_web::{
get,
web::{self, Data},
App, HttpResponse, HttpServer,
};
use clap::crate_version;
use rust_embed::RustEmbed;
use sea_orm::{prelude::*, Database};
@ -24,6 +28,7 @@ static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
pub struct AppState {
pub db: DatabaseConnection,
pub healthcheck_status: Mutex<HashMap<i32, bool>>,
pub data_path: String,
}
#[actix_rt::main]
@ -41,9 +46,9 @@ async fn main() {
.takes_value(true),
)
.arg(
clap::Arg::new("db")
clap::Arg::new("data")
.env("VADE_DB")
.long("db")
.long("data")
.value_name("path")
.default_value("./")
.help("Sets the path to the database location")
@ -60,12 +65,13 @@ async fn main() {
);
tracing::subscriber::set_global_default(subscriber).expect("Unable to set a global collector");
let db = setup_database(opts.value_of("db").unwrap_or_default())
let db = setup_database(opts.value_of("data").unwrap_or_default())
.await
.unwrap();
let state = web::Data::new(AppState {
db,
healthcheck_status: Mutex::new(HashMap::new()),
data_path: opts.value_of("data").unwrap_or_default().to_string(),
});
info!(
@ -137,6 +143,7 @@ async fn main() {
.wrap(cors)
.app_data(state.clone())
.wrap(tracing_actix_web::TracingLogger::default())
.service(get_bg)
.service(web::resource("/events/{token}").to(socket_session::event_session_index))
.service(api::routes())
.service(dist)
@ -152,6 +159,32 @@ async fn main() {
#[folder = "dist"]
struct UIAssets;
#[instrument]
fn read_file(path: &Path) -> error::Result<Vec<u8>> {
let f = std::fs::File::open(path)?;
let mut reader = std::io::BufReader::new(f);
let mut buffer = Vec::new();
std::io::Read::read_to_end(&mut reader, &mut buffer)?;
tracing::info!("Read into buffer: {}", buffer.len());
Ok(buffer)
}
#[get("/bg.jpg")]
async fn get_bg(state: Data<AppState>) -> error::Result<HttpResponse> {
let file_path = Path::new(&state.data_path).join("bg.jpg");
if file_path.exists() {
let data = read_file(&file_path).unwrap();
Ok(HttpResponse::Ok().content_type("image/jpg").body(data))
} else {
let content = UIAssets::get("bg.jpg").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),
};
Ok(HttpResponse::Ok().content_type("image/jpg").body(body))
}
}
#[get("/{filename:.*}")]
async fn dist(path: web::Path<String>) -> HttpResponse {
let path = if UIAssets::get(&*path).is_some() {
@ -172,12 +205,13 @@ async fn dist(path: web::Path<String>) -> HttpResponse {
#[instrument]
async fn setup_database(db_path: &str) -> error::Result<DatabaseConnection> {
let db_fname = "data.db";
if !Path::new(db_path).join(db_fname).exists() {
let full_path = Path::new(db_path).join(db_fname);
if !full_path.exists() {
std::fs::File::create(db_fname)?;
}
let pool = sqlx::SqlitePool::connect("sqlite://data.db").await?;
let pool =
sqlx::SqlitePool::connect(&format!("sqlite://{}", full_path.to_str().unwrap())).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
tracing::info!("Database migrated");
Ok(Database::connect("sqlite://data.db").await?)

View File

@ -13,7 +13,6 @@ body {
height: 100%;
font-family: "Roboto", sans-serif;
font-size: 35px;
background-color: #222;
background-repeat: none;
background-size: cover;
}

View File

@ -45,6 +45,10 @@ svg {
margin-right: 20px;
}
.lightMode svg {
color: #444;
}
.tile.alive svg {
color: #009900;
}
@ -62,4 +66,16 @@ svg {
font-size: 0.8em;
line-height: 1;
}
.lightMode .title {
color: #000;
}
.lightMode .description {
color: #444;
}
.lightMode .tile:hover {
background-color: rgba(200,200,200, 0.5);
}
</style>

View File

@ -28,6 +28,9 @@ export default {
font-size: 16px;
color: #ccc;
}
.lightMode .bookmark-tile, .lightMode .bookmark-tile svg {
color: #222;
}
.bookmark-tile:hover, .bookmark-tile:hover svg {
transition: all 0.2s;
@ -35,5 +38,10 @@ export default {
color: #fff;
}
.lightMode .bookmark-tile:hover, .lightMode .bookmark-tile:hover svg {
background: rgba(255,255,255,0.25);
color: #222;
}
</style>

View File

@ -17,4 +17,7 @@ export default {
.tile.new {
background: rgba(0,0,0,0.8);
}
.lightMode .tile.new {
background: rgba(255,255,255,0.8);
}
</style>

View File

@ -2,6 +2,11 @@ import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from "../views/Dashboard.vue"
const routes = [
{
path: '/settings',
name: 'Settings',
component: () => import(/* webpackChunkName: "login" */ '../views/Settings.vue')
},
{
path: '/logout',
name: 'Logout',

View File

@ -1,11 +1,14 @@
<template>
<div :class="{dashboard: true, 'edit-mode': editMode}">
<div :class="{dashboard: true, 'edit-mode': editMode, lightMode: this.themeMode == 'light'}" :style="dashboardStyle">
<div @click="vade_click" class="vade">vade</div>
<div class="editmode-tiles">
<new-item-tile title="New Application" v-if="editMode" @click="openNewApp" />
<new-item-tile title="New App" 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 class="settings-tile" v-if="editMode" @click="settingsClick">
<font-awesome-icon icon="cog" />
</div>
</div>
<div class="container" v-if="appsForCategory(null).length > 0 || editMode">
<h1>APPLICATIONS</h1>
@ -111,7 +114,7 @@ export default {
},
connect_ws() {
const token = localStorage.getItem("token")
this.connection = new WebSocket("ws://localhost:8088/events/" + encodeURIComponent(token));
this.connection = new WebSocket("ws://localhost:8089/events/" + encodeURIComponent(token));
this.connection.onmessage = (event) => {
const data = JSON.parse(event.data);
const { app_id, alive } = data.HealthcheckChange;
@ -158,6 +161,9 @@ export default {
this.editBookmarkCat = {};
this.bookmarkCatOpen = false;
},
settingsClick() {
this.$router.push("/settings");
},
toggleEdit() {
this.editMode = !this.editMode;
},
@ -208,6 +214,10 @@ export default {
this.editBookmarkCat = {};
this.bookmarkCatOpen = false;
this.bookmarkOpen = false;
this.themeMode = (await axios.get("/api/settings/theme/mode")).data.mode || "dark";
localStorage.setItem("themeMode", this.themeMode);
},
},
components: {
@ -220,6 +230,13 @@ export default {
BookmarkTile,
BookmarkCategoryModal,
},
computed: {
dashboardStyle() {
return {
backgroundImage: `url('${this.$rootUrl()}/bg.jpg')`
};
}
},
data() {
return {
editMode: false,
@ -236,6 +253,7 @@ export default {
bookmarks: [],
bookmarkCategories: [],
connection: null,
themeMode: localStorage.getItem("themeMode"),
};
},
async mounted() {
@ -245,7 +263,7 @@ export default {
};
</script>
<style scope>
<style scoped>
h1 {
margin-top: 0px;
font-weight: bold;
@ -259,6 +277,11 @@ h1 {
text-align: right;
}
.lightMode h1 {
color: rgba(0,0,0,0.5);
text-shadow: none;
}
h2 {
font-size: 18px;
text-transform: uppercase;
@ -268,6 +291,12 @@ h2 {
color: rgba(255,255,255,0.25);
text-shadow: 1px 1px rgba(0,0,0,0.25);
}
.lightMode h2 {
color: rgba(0,0,0,0.5);
text-shadow: none;
}
.edit-mode .editable {
cursor: pointer;
}
@ -275,6 +304,7 @@ h2 {
h2 svg {
margin-right: 5px;
}
.app-tiles {
display: grid;
grid-template-columns: repeat(4, 25%);
@ -288,15 +318,21 @@ h2 svg {
background-color: rgba(0,0,0,0.4);
border-radius: 15px;
}
.lightMode .container {
background-color: rgba(255,255,255,0.4);
}
.container svg {
color: rgba(255,255,255,0.25);
}
.lightMode .container svg {
color: rgba(0,0,0,0.25);
}
.dashboard {
user-select: none;
min-height: calc(100vh - 150px);
min-width: 100vw;
padding-top: 150px;
background: url('/bg.jpg');
background-position: center center;
background-size: cover;
}
@ -308,6 +344,7 @@ h2 svg {
flex-direction: row;
justify-content: space-between;
}
.bookmark-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
@ -322,4 +359,32 @@ h2 svg {
bottom: 20px;
left: 40px;
}
.settings-tile {
background: rgba(0,0,0,0.8);
transition: all 0.5s;
padding: 16px 25px;
border-radius: 5px;
margin: 5px;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.settings-tile:hover {
transition: all 0.1s;
box-shadow: rgba(0, 0, 0, 0.5) 0px 20px 30px -10px;
}
.settings-tile svg {
color: #fff;
margin: auto auto;
}
.lightMode .settings-tile {
background: rgba(255,255,255,0.8);
}
.lightMode .settings-tile svg {
color: #222;
}
</style>

99
src/ui/views/Settings.vue Normal file
View File

@ -0,0 +1,99 @@
<template>
<div class="settings">
<div class="section">
<h1>Wallpaper</h1>
<h2>Upload a background wallpaper</h2>
<input ref="bg_file" type="file"/>
<div class="actions">
<btn label="Update" @click="uploadBg" />
</div>
</div>
<div class="section">
<h1>Theme</h1>
<h2>Select light or dark mode to match the wallpaper</h2>
<switch-field v-model="darkMode" label="Darkmode" />
<div class="actions">
<btn label="Update" @click="updateMode" />
</div>
</div>
<div class="control-buttons">
<btn primary label="Back to Dashboard" @click="backClicked" />
</div>
</div>
</template>
<script>
import Btn from "../components/Button.vue";
import SwitchField from "../components/Switch.vue";
import axios from "axios";
export default {
components: { Btn, SwitchField },
data() {
return {
file: "",
darkMode: true,
}
},
async mounted() {
this.darkMode = ((await axios.get("/api/settings/theme/mode")).data.mode || "dark") === "dark";
},
methods: {
async uploadBg() {
let formData = new FormData();
formData.append("file", this.$refs.bg_file.files[0]);
await axios.post("/api/upload/bg", formData, {
headers: {'content-type': 'multipart/form-data'}
});
},
async updateMode() {
await axios.put("/api/settings/theme/mode", {
mode: this.darkMode ? "dark" : "light"
});
},
backClicked() {
this.$router.push("/");
}
}
}
</script>
<style scoped>
.settings {
padding-top: 100px;
min-height: calc(100vh - 100px);
background: #222;
}
.section {
width: 800px;
margin: auto auto;
border-radius: 5px;
background: #121212;
padding: 15px;
}
.section + .section {
margin-top: 25px;
}
.section h1 {
font-size: 24px;
font-weight: 700;
line-height: 24px;
margin: 0;
}
.section h2 {
font-size: 18px;
font-weight: 500;
margin-top: 5px;
margin-bottom: 35px;
}
.actions {
margin-top: 15px;
text-align: right;
}
.control-buttons {
width: 800px;
margin: auto auto;
margin-top: 15px;
text-align: right;
}
</style>