1
0
mirror of https://github.com/taigrr/homer synced 2025-01-18 04:53:12 -08:00

Build system integration using vue-cli.

This commit is contained in:
Bastien Wirtz
2020-05-25 15:07:03 -07:00
parent ab7ac44c19
commit b9c5fcf085
69 changed files with 10028 additions and 10347 deletions

214
src/App.vue Normal file
View File

@@ -0,0 +1,214 @@
<template>
<div
id="app"
v-if="config"
:class="[
`theme-${config.theme}`,
isDark ? 'is-dark' : 'is-light',
!config.footer ? 'no-footer' : ''
]"
>
<DynamicTheme :themes="config.colors" />
<div id="bighead">
<section v-if="config.header" class="first-line">
<div v-cloak class="container">
<div class="logo">
<img v-if="config.logo" :src="config.logo" />
<i v-if="config.icon" :class="config.icon"></i>
</div>
<div class="dashboard-title">
<span class="headline">{{ config.subtitle }}</span>
<h1>{{ config.title }}</h1>
</div>
</div>
</section>
<Navbar
:open="showMenu"
:links="config.links"
@navbar:toggle="showMenu = !showMenu"
>
<DarkMode @updated="isDark = $event" />
<SettingToggle
@updated="vlayout = $event"
name="vlayout"
icon="fa-list"
iconAlt="fa-columns"
/>
<SearchInput
class="navbar-item is-inline-block-mobile"
@input="filterServices"
@search:focus="showMenu = true"
@search:open="navigateToFirstService"
@search:cancel="filterServices"
/>
</Navbar>
</div>
<section id="main-section" class="section">
<div v-cloak class="container">
<ConnectivityChecker @network:status-update="offline = $event" />
<div v-if="!offline">
<!-- Optional messages -->
<Message :item="config.message" />
<!-- Horizontal layout -->
<div v-if="!vlayout || filter" class="columns is-multiline">
<template v-for="group in services">
<h2 v-if="group.name" class="column is-full group-title">
<i v-if="group.icon" :class="group.icon"></i>
{{ group.name }}
</h2>
<Service
v-for="item in group.items"
:key="item.url"
v-bind:item="item"
class="column is-one-third-widescreen"
/>
</template>
</div>
<!-- Vertical layout -->
<div
v-if="!filter && vlayout"
class="columns is-multiline layout-vertical"
>
<div
class="column is-one-third-widescreen"
v-for="group in services"
:key="group.name"
>
<h2 v-if="group.name" class="group-title">
<i v-if="group.icon" :class="group.icon"></i>
{{ group.name }}
</h2>
<Service
v-for="item in group.items"
v-bind:item="item"
:key="item.url"
/>
</div>
</div>
</div>
</div>
</section>
<footer class="footer">
<div class="container">
<div
class="content has-text-centered"
v-if="config.footer"
v-html="config.footer"
></div>
</div>
</footer>
</div>
</template>
<script>
const jsyaml = require("js-yaml");
const merge = require("lodash.merge");
import Navbar from "./components/Navbar.vue";
import ConnectivityChecker from "./components/ConnectivityChecker.vue";
import Service from "./components/Service.vue";
import Message from "./components/Message.vue";
import SearchInput from "./components/SearchInput.vue";
import SettingToggle from "./components/SettingToggle.vue";
import DarkMode from "./components/DarkMode.vue";
import DynamicTheme from "./components/DynamicTheme.vue";
import defaultConfig from "./assets/defaults.yml";
export default {
name: "App",
components: {
Navbar,
ConnectivityChecker,
Service,
Message,
SearchInput,
SettingToggle,
DarkMode,
DynamicTheme
},
data: function () {
return {
config: null,
services: null,
offline: false,
filter: "",
vlayout: true,
isDark: null,
showMenu: false
};
},
created: async function () {
try {
const defaults = jsyaml.load(defaultConfig);
let config = await this.getConfig();
this.config = merge(defaults, config);
console.log(this.config);
this.services = this.config.services;
document.title = `${this.config.title} | ${this.config.subtitle}`;
} catch (error) {
this.offline = true;
}
},
methods: {
getConfig: function () {
return fetch("config.yml").then(function (response) {
if (response.status != 200) {
return;
}
return response.text().then(function (body) {
return jsyaml.load(body);
});
});
},
matchesFilter: function (item) {
return (
item.name.toLowerCase().includes(this.filter) ||
(item.tag && item.tag.toLowerCase().includes(this.filter))
);
},
navigateToFirstService: function (target) {
try {
const service = this.services[0].items[0];
window.open(service.url, target || service.target || "_self");
} catch (error) {
console.warning("fail to open service");
}
},
filterServices: function (filter) {
this.filter = filter;
if (!filter) {
this.services = this.config.services;
return;
}
const searchResultItems = [];
for (const group of this.config.services) {
for (const item of group.items) {
if (this.matchesFilter(item)) {
searchResultItems.push(item);
}
}
}
this.services = [
{
name: filter,
icon: "fas fa-search",
items: searchResultItems
}
];
}
}
};
</script>

336
src/assets/app.scss Normal file
View File

@@ -0,0 +1,336 @@
@charset "utf-8";
@import url("//fonts.googleapis.com/css?family=Lato:400,700|Pacifico|Raleway&display=swap");
@import "bulma";
// Themes import
@import "./themes/sui.scss";
@mixin ellipsis() {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
html {
height: 100%;
}
body {
font-family: "Raleway", sans-serif;
height: 100%;
#app {
min-height: 100%;
background-color: var(--background);
color: var(--text);
transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
a {
&:hover {
color: var(--link-hover);
}
}
.title {
color: var(--text-title);
}
.subtitle {
color: var(--text-subtitle);
}
.card {
background-color: var(--card-background);
box-shadow: 0 2px 15px 0 var(--card-shadow);
&:hover {
background-color: var(--card-background);
}
}
.message {
background-color: var(--card-background);
.message-body {
color: var(--text);
}
}
.footer {
background-color: var(--card-background);
box-shadow: 0 2px 15px 0 var(--card-shadow);
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Lato", sans-serif;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.7rem;
margin-top: 2rem;
margin-bottom: 1rem;
.fas,
.fab,
.far {
margin-right: 10px;
}
span {
font-weight: bold;
color: var(--highlight-secondary);
}
}
[v-cloak] {
display: none;
}
#bighead {
color: var(--text-header);
.dashboard-title {
padding: 6px 0 0 80px;
}
.first-line {
height: 100px;
vertical-align: center;
background-color: var(--highlight-primary);
h1 {
margin-top: -12px;
font-size: 2rem;
}
.headline {
margin-top: 5px;
font-size: 0.9rem;
}
.container {
height: 80px;
padding: 10px 0;
}
.logo {
float: left;
i {
vertical-align: top;
padding: 8px 15px;
font-size: 50px;
}
img {
padding: 10px;
max-height: 70px;
max-width: 70px;
}
}
}
.navbar,
.navbar-menu {
background-color: var(--highlight-secondary);
a {
color: var(--text-header);
padding: 8px 12px;
&:hover,
&:focus {
color: var(--text-header);
background-color: var(--highlight-hover);
}
}
}
.navbar-end {
text-align: right;
}
}
#main-section {
margin-bottom: 2rem;
padding: 0;
h2 {
padding-bottom: 0px;
@include ellipsis();
}
.title {
font-size: 1.1em;
@include ellipsis();
}
.subtitle {
font-size: 0.9em;
@include ellipsis();
}
.container {
padding: 1.2rem 0.75rem;
}
.message {
margin-top: 45px;
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
.message-header {
font-weight: bold;
}
.message-body {
border: none;
}
}
}
.media-content {
overflow: hidden;
text-overflow: inherit;
}
.tag {
color: var(--highlight-secondary);
background-color: var(--highlight-secondary);
position: absolute;
top: 1rem;
right: -0.2rem;
width: 3px;
overflow: hidden;
transition: all 0.2s ease-out;
padding: 0;
.tag-text {
display: none;
}
}
.card {
border-radius: 5px;
border: none;
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
transition: cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
a {
outline: none;
}
}
.card:hover {
transform: translate(0, -3px);
.tag {
width: auto;
color: #ffffff;
padding: 0 0.75em;
.tag-text {
display: block;
}
}
}
.card-content {
height: 85px;
padding: 1.3rem;
}
.layout-vertical {
.card {
border-radius: 0;
}
.column div:first-of-type .card {
border-radius: 5px 5px 0 0;
}
.column div:last-child .card {
border-radius: 0 0 5px 5px;
}
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 0.5rem;
text-align: left;
color: #676767;
font-size: 0.85rem;
transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms;
}
.no-footer {
#main-section {
margin-bottom: 0;
}
.footer {
display: none;
}
}
.search-bar {
position: relative;
display: inline-block;
input {
border: none;
background-color: var(--highlight-hover);
border-radius: 5px;
margin-top: 2px;
padding: 2px 12px 2px 30px;
transition: all 100ms linear;
color: #ffffff;
height: 30px;
width: 100px;
&:focus {
color: #000000;
width: 250px;
background-color: #ffffff;
}
}
.search-label::before {
font-family: "Font Awesome 5 Free";
position: absolute;
top: 14px;
left: 16px;
content: "\f002";
font-weight: 900;
width: 20px;
height: 20px;
color: #ffffff;
}
&:focus-within .search-label::before {
color: #6e6e6e;
}
}
.offline-message {
text-align: center;
margin: 35px 0;
svg {
font-size: 2rem;
}
svg.fa-redo-alt {
font-size: 1.3rem;
line-height: 1rem;
vertical-align: middle;
cursor: pointer;
color: #3273dc;
}
}
}

39
src/assets/defaults.yml Normal file
View File

@@ -0,0 +1,39 @@
---
# Default configuration
title: "Dashboard"
subtitle: "Homer"
logo: "logo.png"
header: true
footer: '<p>Created with <span class="has-text-danger">❤️</span> with <a href="https://bulma.io/">bulma</a>, <a href="https://vuejs.org/">vuejs</a> & <a href="https://fontawesome.com/">font awesome</a> // Fork me on <a href="https://github.com/bastienwirtz/homer"><i class="fab fa-github-alt"></i></a></p>' # set false if you want to hide it.
theme: default
colors:
light:
highlight-primary: "#3367d6"
highlight-secondary: "#4285f4"
highlight-hover: "#5a95f5"
background: "#f5f5f5"
card-background: "#ffffff"
text: "#363636"
text-header: "#ffffff"
text-title: "#303030"
text-subtitle: "#424242"
card-shadow: rgba(0, 0, 0, 0.1)
link-hover: "#363636"
dark:
highlight-primary: "#3367d6"
highlight-secondary: "#4285f4"
highlight-hover: "#5a95f5"
background: "#131313"
card-background: "#2b2b2b"
text: "#eaeaea"
text-header: "#ffffff"
text-title: "#fafafa"
text-subtitle: "#f5f5f5"
card-shadow: rgba(0, 0, 0, 0.4)
link-hover: "#ffdd57"
links: []
services: []

View File

@@ -0,0 +1,34 @@
/*
* SUI theme
* Inpired by the great https://github.com/jeroenpardon/sui start page
* Author: @bastienwirtz
*/
body #app.theme-sui {
#bighead .dashboard-title {
padding: 65px 0 0 12px;
h1 {
margin-top: 0;
font-weight: bold;
font-size: 2.2rem;
}
}
.navbar .navbar-item:hover {
background-color: transparent;
}
.card,
.card:hover {
background-color: transparent;
box-shadow: none;
.title {
font-weight: bold;
}
.card-content {
padding: 0;
}
}
}

View File

@@ -0,0 +1,52 @@
<template>
<div v-if="offline" class="offline-message">
<i class="far fa-dizzy"></i>
<h1>
You're offline bro.
<span @click="checkOffline"> <i class="fas fa-redo-alt"></i></span>
</h1>
</div>
</template>
<script>
export default {
name: "ConnectivityChecker",
data: function () {
return {
offline: false,
};
},
created: function () {
let that = this;
this.checkOffline();
document.addEventListener(
"visibilitychange",
function () {
if (document.visibilityState == "visible") {
that.checkOffline();
}
},
false
);
},
methods: {
checkOffline: function () {
let that = this;
return fetch(window.location.href + "?alive", {
method: "HEAD",
cache: "no-store",
})
.then(function () {
that.offline = false;
})
.catch(function () {
that.offline = true;
})
.finally(function () {
that.$emit("network:status-update", that.offline);
});
},
},
};
</script>

View File

@@ -0,0 +1,34 @@
<template>
<a
v-on:click="toggleTheme()"
aria-label="Toggle dark mode"
class="navbar-item is-inline-block-mobile"
>
<i class="fas fa-adjust"></i>
</a>
</template>
<script>
export default {
name: "Darkmode",
data: function () {
return {
isDark: null,
};
},
created: function () {
this.isDark =
"overrideDark" in localStorage
? JSON.parse(localStorage.overrideDark)
: matchMedia("(prefers-color-scheme: dark)").matches;
this.$emit("updated", this.isDark);
},
methods: {
toggleTheme: function () {
this.isDark = !this.isDark;
localStorage.overrideDark = this.isDark;
this.$emit("updated", this.isDark);
},
},
};
</script>

View File

@@ -0,0 +1,34 @@
<template>
<DynamicStyle>
/* light / dark theme switch based on system pref if available */ body #app
{
{{ getVars(themes.light) }}
} @media (prefers-color-scheme: light), (prefers-color-scheme:
no-preference) { body #app {
{{ getVars(themes.light) }}
} } @media (prefers-color-scheme: dark) { body #app { } } /* light / dark
theme override base on user choice. */ body #app.is-dark {
{{ getVars(themes.dark) }}
} body #app.is-light {
{{ getVars(themes.light) }}
}
</DynamicStyle>
</template>
<script>
export default {
name: "DynamicTheme",
props: {
themes: Object,
},
methods: {
getVars: function (theme) {
let vars = [];
for (const themeVars in theme) {
vars.push(`--${themeVars}: ${theme[themeVars]}`);
}
return vars.join(";");
},
},
};
</script>

View File

@@ -0,0 +1,41 @@
<template>
<article v-if="item" class="message" :class="item.style">
<div v-if="item.title" class="message-header">
<p>{{ item.title }}</p>
</div>
<div v-if="item.content" class="message-body" v-html="item.content"></div>
</article>
</template>
<script>
export default {
name: "Message",
props: {
item: Object,
},
created: function () {
// Look for a new message if an endpoint is provided.
let that = this;
if (this.item && this.item.url) {
this.getMessage(this.item.url).then(function (message) {
// keep the original config value if no value is provided by the endpoint
for (const prop of ["title", "style", "content"]) {
if (prop in message && message[prop] !== null) {
that.item[prop] = message[prop];
}
}
});
}
},
methods: {
getMessage: function (url) {
return fetch(url).then(function (response) {
if (response.status != 200) {
return;
}
return response.json();
});
},
},
};
</script>

66
src/components/Navbar.vue Normal file
View File

@@ -0,0 +1,66 @@
<template>
<div v-cloak v-if="links" class="container-fluid">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a
role="button"
aria-label="menu"
aria-expanded="false"
class="navbar-burger"
:class="{ 'is-active': showMenu }"
v-on:click="$emit('navbar:toggle')"
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div class="navbar-menu" :class="{ 'is-active': showMenu }">
<div class="navbar-start">
<a
class="navbar-item"
v-for="link in links"
:key="link.url"
:href="link.url"
:target="link.target"
>
<i
v-if="link.icon"
style="margin-right: 6px;"
:class="link.icon"
></i>
{{ link.name }}
</a>
</div>
<div class="navbar-end">
<slot></slot>
</div>
</div>
</div>
</nav>
</div>
</template>
<script>
export default {
name: "Navbar",
props: {
open: {
type: Boolean,
default: false,
},
links: Array,
},
computed: {
showMenu: function () {
return this.open && this.isSmallScreen();
},
},
methods: {
isSmallScreen: function () {
return window.matchMedia("screen and (max-width: 1023px)").matches;
},
},
};
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div class="search-bar">
<label for="search" class="search-label"></label>
<input
type="text"
ref="search"
:value="value"
@input="$emit('input', $event.target.value.toLowerCase())"
@keyup.enter.exact="$emit('search:open')"
@keyup.alt.enter="$emit('search:open', '_blank')"
/>
</div>
</template>
<script>
export default {
name: "SearchInput",
props: ["value"],
mounted() {
this._keyListener = function (event) {
if (event.key === "/") {
event.preventDefault();
this.$emit("search:focus");
this.$nextTick(() => {
this.$refs.search.focus();
});
}
if (event.key === "Escape") {
this.$refs.search.value = "";
this.$refs.search.blur();
this.$emit("search:cancel");
}
};
document.addEventListener("keydown", this._keyListener.bind(this));
},
beforeDestroy() {
document.removeEventListener("keydown", this._keyListener);
},
};
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,40 @@
<template>
<div>
<div class="card">
<a :href="item.url" :target="item.target">
<div class="card-content">
<div class="media">
<div v-if="item.logo" class="media-left">
<figure class="image is-48x48">
<img :src="item.logo" />
</figure>
</div>
<div v-if="item.icon" class="media-left">
<figure class="image is-48x48">
<i style="font-size: 35px;" :class="item.icon"></i>
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{ item.name }}</p>
<p class="subtitle is-6">{{ item.subtitle }}</p>
</div>
</div>
<div class="tag" :class="item.tagstyle" v-if="item.tag">
<strong class="tag-text">#{{ item.tag }}</strong>
</div>
</div>
</a>
</div>
</div>
</template>
<script>
export default {
name: "Service",
props: {
item: Object,
},
};
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,41 @@
<template>
<a v-on:click="toggleSetting()" class="navbar-item is-inline-block-mobile">
<span v-show="value"><i :class="['fas', icon]"></i></span>
<span v-show="!value"><i :class="['fas', iconAlt]"></i></span>
<slot></slot>
</a>
</template>
<script>
export default {
name: "SettingToggle",
props: {
name: String,
icon: String,
iconAlt: String,
},
data: function () {
return {
value: true,
};
},
created: function () {
if (!this.iconAlt) {
this.iconAlt = this.icon;
}
if (this.name in localStorage) {
this.value = JSON.parse(localStorage[this.name]);
}
this.$emit("updated", this.value);
},
methods: {
toggleSetting: function () {
this.value = !this.value;
localStorage[this.name] = this.value;
this.$emit("updated", this.value);
},
},
};
</script>

20
src/main.js Normal file
View File

@@ -0,0 +1,20 @@
import Vue from "vue";
import App from "./App.vue";
import "./registerServiceWorker";
import "@fortawesome/fontawesome-free/css/all.css";
import "@fortawesome/fontawesome-free/js/all.js";
import "./assets/app.scss";
Vue.config.productionTip = false;
Vue.component("DynamicStyle", {
render: function (createElement) {
return createElement("style", this.$slots.default);
},
});
new Vue({
render: (h) => h(App),
}).$mount("#app");

View File

@@ -0,0 +1,34 @@
/* eslint-disable no-console */
import { register } from "register-service-worker";
if (process.env.NODE_ENV === "production") {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() {
console.log(
"App is being served from cache by a service worker.\n" +
"For more details, visit https://goo.gl/AFskqB"
);
},
registered() {
console.log("Service worker has been registered.");
},
cached() {
console.log("Content has been cached for offline use.");
},
updatefound() {
console.log("New content is downloading.");
},
updated() {
console.log("New content is available; please refresh.");
},
offline() {
console.log(
"No internet connection found. App is running in offline mode."
);
},
error(error) {
console.error("Error during service worker registration:", error);
},
});
}