]> git.immae.eu Git - github/bastienwirtz/homer.git/blob - src/App.vue
Fix search widget
[github/bastienwirtz/homer.git] / src / App.vue
1 <template>
2 <div
3 id="app"
4 v-if="config"
5 :class="[
6 `theme-${config.theme}`,
7 isDark ? 'is-dark' : 'is-light',
8 !config.footer ? 'no-footer' : '',
9 ]"
10 >
11 <DynamicTheme :themes="config.colors" />
12 <div id="bighead">
13 <section v-if="config.header" class="first-line">
14 <div v-cloak class="container">
15 <div class="logo">
16 <a href="#">
17 <img v-if="config.logo" :src="config.logo" alt="dashboard logo" />
18 </a>
19 <i v-if="config.icon" :class="config.icon"></i>
20 </div>
21 <div
22 class="dashboard-title"
23 :class="{ 'no-logo': !config.icon || !config.logo }"
24 >
25 <span class="headline">{{ config.subtitle }}</span>
26 <h1>{{ config.title }}</h1>
27 </div>
28 </div>
29 </section>
30
31 <Navbar
32 :open="showMenu"
33 :links="config.links"
34 @navbar-toggle="showMenu = !showMenu"
35 >
36 <DarkMode
37 @updated="isDark = $event"
38 :defaultValue="this.config.defaults.colorTheme"
39 />
40
41 <SettingToggle
42 @updated="vlayout = $event"
43 name="vlayout"
44 icon="fa-list"
45 iconAlt="fa-columns"
46 :defaultValue="this.config.defaults.layout == 'columns'"
47 />
48
49 <SearchInput
50 class="navbar-item is-inline-block-mobile"
51 :hotkey="searchHotkey()"
52 @input="filterServices($event.target?.value)"
53 @search-focus="showMenu = true"
54 @search-open="navigateToFirstService($event?.target?.value)"
55 @search-cancel="filterServices()"
56 />
57 </Navbar>
58 </div>
59
60 <section id="main-section" class="section">
61 <div v-cloak class="container">
62 <ConnectivityChecker
63 v-if="config.connectivityCheck"
64 @network-status-update="offline = $event"
65 />
66
67 <GetStarted v-if="configurationNeeded" />
68
69 <div v-if="!offline">
70 <!-- Optional messages -->
71 <Message :item="config.message" />
72
73 <!-- Horizontal layout -->
74 <div v-if="!vlayout || filter" class="columns is-multiline">
75 <template v-for="(group, groupIndex) in services">
76 <h2
77 v-if="group.name"
78 class="column is-full group-title"
79 :key="`header-${groupIndex}`"
80 >
81 <i v-if="group.icon" :class="['fa-fw', group.icon]"></i>
82 <div v-else-if="group.logo" class="group-logo media-left">
83 <figure class="image is-48x48">
84 <img :src="group.logo" :alt="`${group.name} logo`" />
85 </figure>
86 </div>
87 {{ group.name }}
88 </h2>
89 <Service
90 v-for="(item, index) in group.items"
91 :key="`service-${groupIndex}-${index}`"
92 :item="item"
93 :proxy="config.proxy"
94 :class="['column', `is-${12 / config.columns}`]"
95 />
96 </template>
97 </div>
98
99 <!-- Vertical layout -->
100 <div
101 v-if="!filter && vlayout"
102 class="columns is-multiline layout-vertical"
103 >
104 <div
105 :class="['column', `is-${12 / config.columns}`]"
106 v-for="(group, groupIndex) in services"
107 :key="groupIndex"
108 >
109 <h2 v-if="group.name" class="group-title">
110 <i v-if="group.icon" :class="['fa-fw', group.icon]"></i>
111 <div v-else-if="group.logo" class="group-logo media-left">
112 <figure class="image is-48x48">
113 <img :src="group.logo" :alt="`${group.name} logo`" />
114 </figure>
115 </div>
116 {{ group.name }}
117 </h2>
118 <Service
119 v-for="(item, index) in group.items"
120 :key="index"
121 :item="item"
122 :proxy="config.proxy"
123 />
124 </div>
125 </div>
126 </div>
127 </div>
128 </section>
129
130 <footer class="footer">
131 <div class="container">
132 <div
133 class="content has-text-centered"
134 v-if="config.footer"
135 v-html="config.footer"
136 ></div>
137 </div>
138 </footer>
139 </div>
140 </template>
141
142 <script>
143 import jsyaml from "js-yaml";
144 import merge from "lodash.merge";
145
146 import Navbar from "./components/Navbar.vue";
147 import GetStarted from "./components/GetStarted.vue";
148 import ConnectivityChecker from "./components/ConnectivityChecker.vue";
149 import Service from "./components/Service.vue";
150 import Message from "./components/Message.vue";
151 import SearchInput from "./components/SearchInput.vue";
152 import SettingToggle from "./components/SettingToggle.vue";
153 import DarkMode from "./components/DarkMode.vue";
154 import DynamicTheme from "./components/DynamicTheme.vue";
155
156 import defaultConfig from "./assets/defaults.yml?raw";
157
158 export default {
159 name: "App",
160 components: {
161 Navbar,
162 GetStarted,
163 ConnectivityChecker,
164 Service,
165 Message,
166 SearchInput,
167 SettingToggle,
168 DarkMode,
169 DynamicTheme,
170 },
171 data: function () {
172 return {
173 loaded: false,
174 configNotFound: false,
175 config: null,
176 services: null,
177 offline: false,
178 filter: "",
179 vlayout: true,
180 isDark: null,
181 showMenu: false,
182 };
183 },
184 computed: {
185 configurationNeeded: function () {
186 return (this.loaded && !this.services) || this.configNotFound;
187 },
188 },
189 created: async function () {
190 this.buildDashboard();
191 window.onhashchange = this.buildDashboard;
192 this.loaded = true;
193 },
194 methods: {
195 searchHotkey() {
196 if (this.config.hotkey && this.config.hotkey.search) {
197 return this.config.hotkey.search;
198 }
199 },
200 buildDashboard: async function () {
201 const defaults = jsyaml.load(defaultConfig);
202 let config;
203 try {
204 config = await this.getConfig();
205 const path =
206 window.location.hash.substring(1) != ""
207 ? window.location.hash.substring(1)
208 : null;
209
210 if (path) {
211 let pathConfig = await this.getConfig(`assets/${path}.yml`); // the slash (/) is included in the pathname
212 config = Object.assign(config, pathConfig);
213 }
214 } catch (error) {
215 console.log(error);
216 config = this.handleErrors("⚠️ Error loading configuration", error);
217 }
218 this.config = merge(defaults, config);
219 this.services = this.config.services;
220
221 document.title =
222 this.config.documentTitle ||
223 `${this.config.title} | ${this.config.subtitle}`;
224 if (this.config.stylesheet) {
225 let stylesheet = "";
226 for (const file of this.config.stylesheet) {
227 stylesheet += `@import "${file}";`;
228 }
229 this.createStylesheet(stylesheet);
230 }
231 },
232 getConfig: function (path = "assets/config.yml") {
233 return fetch(path).then((response) => {
234 if (response.status == 404 || response.redirected) {
235 this.configNotFound = true;
236 return {};
237 }
238
239 if (!response.ok) {
240 throw Error(`${response.statusText}: ${response.body}`);
241 }
242
243 const that = this;
244 return response
245 .text()
246 .then((body) => {
247 return jsyaml.load(body);
248 })
249 .then(function (config) {
250 if (config.externalConfig) {
251 return that.getConfig(config.externalConfig);
252 }
253 return config;
254 });
255 });
256 },
257 matchesFilter: function (item) {
258 const needle = this.filter?.toLowerCase();
259 return (
260 item.name.toLowerCase().includes(needle) ||
261 (item.subtitle && item.subtitle.toLowerCase().includes(needle)) ||
262 (item.tag && item.tag.toLowerCase().includes(needle)) ||
263 (item.keywords && item.keywords.toLowerCase().includes(needle))
264 );
265 },
266 navigateToFirstService: function (target) {
267 try {
268 const service = this.services[0].items[0];
269 window.open(service.url, target || service.target || "_self");
270 } catch (error) {
271 console.warning("fail to open service");
272 }
273 },
274 filterServices: function (filter) {
275 console.log(filter);
276 this.filter = filter;
277
278 if (!filter) {
279 this.services = this.config.services;
280 return;
281 }
282
283 const searchResultItems = [];
284 for (const group of this.config.services) {
285 for (const item of group.items) {
286 if (this.matchesFilter(item)) {
287 searchResultItems.push(item);
288 }
289 }
290 }
291
292 this.services = [
293 {
294 name: filter,
295 icon: "fas fa-search",
296 items: searchResultItems,
297 },
298 ];
299 },
300 handleErrors: function (title, content) {
301 return {
302 message: {
303 title: title,
304 style: "is-danger",
305 content: content,
306 },
307 };
308 },
309 createStylesheet: function (css) {
310 let style = document.createElement("style");
311 style.appendChild(document.createTextNode(css));
312 document.head.appendChild(style);
313 },
314 },
315 };
316 </script>