diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/App.vue | 214 | ||||
-rw-r--r-- | src/assets/app.scss | 336 | ||||
-rw-r--r-- | src/assets/defaults.yml | 39 | ||||
-rw-r--r-- | src/assets/themes/sui.scss | 34 | ||||
-rw-r--r-- | src/components/ConnectivityChecker.vue | 52 | ||||
-rw-r--r-- | src/components/DarkMode.vue | 34 | ||||
-rw-r--r-- | src/components/DynamicTheme.vue | 34 | ||||
-rw-r--r-- | src/components/Message.vue | 41 | ||||
-rw-r--r-- | src/components/Navbar.vue | 66 | ||||
-rw-r--r-- | src/components/SearchInput.vue | 42 | ||||
-rw-r--r-- | src/components/Service.vue | 40 | ||||
-rw-r--r-- | src/components/SettingToggle.vue | 41 | ||||
-rw-r--r-- | src/main.js | 20 | ||||
-rw-r--r-- | src/registerServiceWorker.js | 34 |
14 files changed, 1027 insertions, 0 deletions
diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..f7fd34a --- /dev/null +++ b/src/App.vue | |||
@@ -0,0 +1,214 @@ | |||
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 | <img v-if="config.logo" :src="config.logo" /> | ||
17 | <i v-if="config.icon" :class="config.icon"></i> | ||
18 | </div> | ||
19 | <div class="dashboard-title"> | ||
20 | <span class="headline">{{ config.subtitle }}</span> | ||
21 | <h1>{{ config.title }}</h1> | ||
22 | </div> | ||
23 | </div> | ||
24 | </section> | ||
25 | |||
26 | <Navbar | ||
27 | :open="showMenu" | ||
28 | :links="config.links" | ||
29 | @navbar:toggle="showMenu = !showMenu" | ||
30 | > | ||
31 | <DarkMode @updated="isDark = $event" /> | ||
32 | |||
33 | <SettingToggle | ||
34 | @updated="vlayout = $event" | ||
35 | name="vlayout" | ||
36 | icon="fa-list" | ||
37 | iconAlt="fa-columns" | ||
38 | /> | ||
39 | |||
40 | <SearchInput | ||
41 | class="navbar-item is-inline-block-mobile" | ||
42 | @input="filterServices" | ||
43 | @search:focus="showMenu = true" | ||
44 | @search:open="navigateToFirstService" | ||
45 | @search:cancel="filterServices" | ||
46 | /> | ||
47 | </Navbar> | ||
48 | </div> | ||
49 | |||
50 | <section id="main-section" class="section"> | ||
51 | <div v-cloak class="container"> | ||
52 | <ConnectivityChecker @network:status-update="offline = $event" /> | ||
53 | <div v-if="!offline"> | ||
54 | <!-- Optional messages --> | ||
55 | <Message :item="config.message" /> | ||
56 | |||
57 | <!-- Horizontal layout --> | ||
58 | <div v-if="!vlayout || filter" class="columns is-multiline"> | ||
59 | <template v-for="group in services"> | ||
60 | <h2 v-if="group.name" class="column is-full group-title"> | ||
61 | <i v-if="group.icon" :class="group.icon"></i> | ||
62 | {{ group.name }} | ||
63 | </h2> | ||
64 | <Service | ||
65 | v-for="item in group.items" | ||
66 | :key="item.url" | ||
67 | v-bind:item="item" | ||
68 | class="column is-one-third-widescreen" | ||
69 | /> | ||
70 | </template> | ||
71 | </div> | ||
72 | |||
73 | <!-- Vertical layout --> | ||
74 | <div | ||
75 | v-if="!filter && vlayout" | ||
76 | class="columns is-multiline layout-vertical" | ||
77 | > | ||
78 | <div | ||
79 | class="column is-one-third-widescreen" | ||
80 | v-for="group in services" | ||
81 | :key="group.name" | ||
82 | > | ||
83 | <h2 v-if="group.name" class="group-title"> | ||
84 | <i v-if="group.icon" :class="group.icon"></i> | ||
85 | {{ group.name }} | ||
86 | </h2> | ||
87 | <Service | ||
88 | v-for="item in group.items" | ||
89 | v-bind:item="item" | ||
90 | :key="item.url" | ||
91 | /> | ||
92 | </div> | ||
93 | </div> | ||
94 | </div> | ||
95 | </div> | ||
96 | </section> | ||
97 | |||
98 | <footer class="footer"> | ||
99 | <div class="container"> | ||
100 | <div | ||
101 | class="content has-text-centered" | ||
102 | v-if="config.footer" | ||
103 | v-html="config.footer" | ||
104 | ></div> | ||
105 | </div> | ||
106 | </footer> | ||
107 | </div> | ||
108 | </template> | ||
109 | |||
110 | <script> | ||
111 | const jsyaml = require("js-yaml"); | ||
112 | const merge = require("lodash.merge"); | ||
113 | |||
114 | import Navbar from "./components/Navbar.vue"; | ||
115 | import ConnectivityChecker from "./components/ConnectivityChecker.vue"; | ||
116 | import Service from "./components/Service.vue"; | ||
117 | import Message from "./components/Message.vue"; | ||
118 | import SearchInput from "./components/SearchInput.vue"; | ||
119 | import SettingToggle from "./components/SettingToggle.vue"; | ||
120 | import DarkMode from "./components/DarkMode.vue"; | ||
121 | import DynamicTheme from "./components/DynamicTheme.vue"; | ||
122 | |||
123 | import defaultConfig from "./assets/defaults.yml"; | ||
124 | |||
125 | export default { | ||
126 | name: "App", | ||
127 | components: { | ||
128 | Navbar, | ||
129 | ConnectivityChecker, | ||
130 | Service, | ||
131 | Message, | ||
132 | SearchInput, | ||
133 | SettingToggle, | ||
134 | DarkMode, | ||
135 | DynamicTheme | ||
136 | }, | ||
137 | data: function () { | ||
138 | return { | ||
139 | config: null, | ||
140 | services: null, | ||
141 | offline: false, | ||
142 | filter: "", | ||
143 | vlayout: true, | ||
144 | isDark: null, | ||
145 | showMenu: false | ||
146 | }; | ||
147 | }, | ||
148 | created: async function () { | ||
149 | try { | ||
150 | const defaults = jsyaml.load(defaultConfig); | ||
151 | let config = await this.getConfig(); | ||
152 | |||
153 | this.config = merge(defaults, config); | ||
154 | |||
155 | console.log(this.config); | ||
156 | this.services = this.config.services; | ||
157 | document.title = `${this.config.title} | ${this.config.subtitle}`; | ||
158 | } catch (error) { | ||
159 | this.offline = true; | ||
160 | } | ||
161 | }, | ||
162 | methods: { | ||
163 | getConfig: function () { | ||
164 | return fetch("config.yml").then(function (response) { | ||
165 | if (response.status != 200) { | ||
166 | return; | ||
167 | } | ||
168 | return response.text().then(function (body) { | ||
169 | return jsyaml.load(body); | ||
170 | }); | ||
171 | }); | ||
172 | }, | ||
173 | matchesFilter: function (item) { | ||
174 | return ( | ||
175 | item.name.toLowerCase().includes(this.filter) || | ||
176 | (item.tag && item.tag.toLowerCase().includes(this.filter)) | ||
177 | ); | ||
178 | }, | ||
179 | navigateToFirstService: function (target) { | ||
180 | try { | ||
181 | const service = this.services[0].items[0]; | ||
182 | window.open(service.url, target || service.target || "_self"); | ||
183 | } catch (error) { | ||
184 | console.warning("fail to open service"); | ||
185 | } | ||
186 | }, | ||
187 | filterServices: function (filter) { | ||
188 | this.filter = filter; | ||
189 | |||
190 | if (!filter) { | ||
191 | this.services = this.config.services; | ||
192 | return; | ||
193 | } | ||
194 | |||
195 | const searchResultItems = []; | ||
196 | for (const group of this.config.services) { | ||
197 | for (const item of group.items) { | ||
198 | if (this.matchesFilter(item)) { | ||
199 | searchResultItems.push(item); | ||
200 | } | ||
201 | } | ||
202 | } | ||
203 | |||
204 | this.services = [ | ||
205 | { | ||
206 | name: filter, | ||
207 | icon: "fas fa-search", | ||
208 | items: searchResultItems | ||
209 | } | ||
210 | ]; | ||
211 | } | ||
212 | } | ||
213 | }; | ||
214 | </script> | ||
diff --git a/src/assets/app.scss b/src/assets/app.scss new file mode 100644 index 0000000..4b69864 --- /dev/null +++ b/src/assets/app.scss | |||
@@ -0,0 +1,336 @@ | |||
1 | @charset "utf-8"; | ||
2 | |||
3 | @import url("//fonts.googleapis.com/css?family=Lato:400,700|Pacifico|Raleway&display=swap"); | ||
4 | @import "bulma"; | ||
5 | |||
6 | // Themes import | ||
7 | @import "./themes/sui.scss"; | ||
8 | |||
9 | @mixin ellipsis() { | ||
10 | white-space: nowrap; | ||
11 | overflow: hidden; | ||
12 | text-overflow: ellipsis; | ||
13 | } | ||
14 | |||
15 | html { | ||
16 | height: 100%; | ||
17 | } | ||
18 | |||
19 | body { | ||
20 | font-family: "Raleway", sans-serif; | ||
21 | height: 100%; | ||
22 | |||
23 | #app { | ||
24 | min-height: 100%; | ||
25 | background-color: var(--background); | ||
26 | color: var(--text); | ||
27 | transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms; | ||
28 | |||
29 | a { | ||
30 | &:hover { | ||
31 | color: var(--link-hover); | ||
32 | } | ||
33 | } | ||
34 | |||
35 | .title { | ||
36 | color: var(--text-title); | ||
37 | } | ||
38 | .subtitle { | ||
39 | color: var(--text-subtitle); | ||
40 | } | ||
41 | |||
42 | .card { | ||
43 | background-color: var(--card-background); | ||
44 | box-shadow: 0 2px 15px 0 var(--card-shadow); | ||
45 | &:hover { | ||
46 | background-color: var(--card-background); | ||
47 | } | ||
48 | } | ||
49 | |||
50 | .message { | ||
51 | background-color: var(--card-background); | ||
52 | .message-body { | ||
53 | color: var(--text); | ||
54 | } | ||
55 | } | ||
56 | |||
57 | .footer { | ||
58 | background-color: var(--card-background); | ||
59 | box-shadow: 0 2px 15px 0 var(--card-shadow); | ||
60 | } | ||
61 | } | ||
62 | |||
63 | h1, | ||
64 | h2, | ||
65 | h3, | ||
66 | h4, | ||
67 | h5, | ||
68 | h6 { | ||
69 | font-family: "Lato", sans-serif; | ||
70 | } | ||
71 | |||
72 | h1 { | ||
73 | font-size: 2rem; | ||
74 | } | ||
75 | |||
76 | h2 { | ||
77 | font-size: 1.7rem; | ||
78 | margin-top: 2rem; | ||
79 | margin-bottom: 1rem; | ||
80 | |||
81 | .fas, | ||
82 | .fab, | ||
83 | .far { | ||
84 | margin-right: 10px; | ||
85 | } | ||
86 | |||
87 | span { | ||
88 | font-weight: bold; | ||
89 | color: var(--highlight-secondary); | ||
90 | } | ||
91 | } | ||
92 | |||
93 | [v-cloak] { | ||
94 | display: none; | ||
95 | } | ||
96 | |||
97 | #bighead { | ||
98 | color: var(--text-header); | ||
99 | |||
100 | .dashboard-title { | ||
101 | padding: 6px 0 0 80px; | ||
102 | } | ||
103 | |||
104 | .first-line { | ||
105 | height: 100px; | ||
106 | vertical-align: center; | ||
107 | background-color: var(--highlight-primary); | ||
108 | |||
109 | h1 { | ||
110 | margin-top: -12px; | ||
111 | font-size: 2rem; | ||
112 | } | ||
113 | |||
114 | .headline { | ||
115 | margin-top: 5px; | ||
116 | font-size: 0.9rem; | ||
117 | } | ||
118 | |||
119 | .container { | ||
120 | height: 80px; | ||
121 | padding: 10px 0; | ||
122 | } | ||
123 | |||
124 | .logo { | ||
125 | float: left; | ||
126 | i { | ||
127 | vertical-align: top; | ||
128 | padding: 8px 15px; | ||
129 | font-size: 50px; | ||
130 | } | ||
131 | |||
132 | img { | ||
133 | padding: 10px; | ||
134 | max-height: 70px; | ||
135 | max-width: 70px; | ||
136 | } | ||
137 | } | ||
138 | } | ||
139 | .navbar, | ||
140 | .navbar-menu { | ||
141 | background-color: var(--highlight-secondary); | ||
142 | |||
143 | a { | ||
144 | color: var(--text-header); | ||
145 | padding: 8px 12px; | ||
146 | &:hover, | ||
147 | &:focus { | ||
148 | color: var(--text-header); | ||
149 | background-color: var(--highlight-hover); | ||
150 | } | ||
151 | } | ||
152 | } | ||
153 | .navbar-end { | ||
154 | text-align: right; | ||
155 | } | ||
156 | } | ||
157 | |||
158 | #main-section { | ||
159 | margin-bottom: 2rem; | ||
160 | padding: 0; | ||
161 | |||
162 | h2 { | ||
163 | padding-bottom: 0px; | ||
164 | @include ellipsis(); | ||
165 | } | ||
166 | |||
167 | .title { | ||
168 | font-size: 1.1em; | ||
169 | @include ellipsis(); | ||
170 | } | ||
171 | |||
172 | .subtitle { | ||
173 | font-size: 0.9em; | ||
174 | @include ellipsis(); | ||
175 | } | ||
176 | |||
177 | .container { | ||
178 | padding: 1.2rem 0.75rem; | ||
179 | } | ||
180 | |||
181 | .message { | ||
182 | margin-top: 45px; | ||
183 | box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1); | ||
184 | |||
185 | .message-header { | ||
186 | font-weight: bold; | ||
187 | } | ||
188 | |||
189 | .message-body { | ||
190 | border: none; | ||
191 | } | ||
192 | } | ||
193 | } | ||
194 | |||
195 | .media-content { | ||
196 | overflow: hidden; | ||
197 | text-overflow: inherit; | ||
198 | } | ||
199 | |||
200 | .tag { | ||
201 | color: var(--highlight-secondary); | ||
202 | background-color: var(--highlight-secondary); | ||
203 | position: absolute; | ||
204 | top: 1rem; | ||
205 | right: -0.2rem; | ||
206 | width: 3px; | ||
207 | overflow: hidden; | ||
208 | transition: all 0.2s ease-out; | ||
209 | padding: 0; | ||
210 | |||
211 | .tag-text { | ||
212 | display: none; | ||
213 | } | ||
214 | } | ||
215 | |||
216 | .card { | ||
217 | border-radius: 5px; | ||
218 | border: none; | ||
219 | box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1); | ||
220 | transition: cubic-bezier(0.165, 0.84, 0.44, 1) 300ms; | ||
221 | |||
222 | a { | ||
223 | outline: none; | ||
224 | } | ||
225 | } | ||
226 | |||
227 | .card:hover { | ||
228 | transform: translate(0, -3px); | ||
229 | |||
230 | .tag { | ||
231 | width: auto; | ||
232 | color: #ffffff; | ||
233 | padding: 0 0.75em; | ||
234 | |||
235 | .tag-text { | ||
236 | display: block; | ||
237 | } | ||
238 | } | ||
239 | } | ||
240 | |||
241 | .card-content { | ||
242 | height: 85px; | ||
243 | padding: 1.3rem; | ||
244 | } | ||
245 | |||
246 | .layout-vertical { | ||
247 | .card { | ||
248 | border-radius: 0; | ||
249 | } | ||
250 | |||
251 | .column div:first-of-type .card { | ||
252 | border-radius: 5px 5px 0 0; | ||
253 | } | ||
254 | |||
255 | .column div:last-child .card { | ||
256 | border-radius: 0 0 5px 5px; | ||
257 | } | ||
258 | } | ||
259 | |||
260 | .footer { | ||
261 | position: fixed; | ||
262 | left: 0; | ||
263 | right: 0; | ||
264 | bottom: 0; | ||
265 | padding: 0.5rem; | ||
266 | text-align: left; | ||
267 | color: #676767; | ||
268 | font-size: 0.85rem; | ||
269 | transition: background-color cubic-bezier(0.165, 0.84, 0.44, 1) 300ms; | ||
270 | } | ||
271 | |||
272 | .no-footer { | ||
273 | #main-section { | ||
274 | margin-bottom: 0; | ||
275 | } | ||
276 | |||
277 | .footer { | ||
278 | display: none; | ||
279 | } | ||
280 | } | ||
281 | |||
282 | .search-bar { | ||
283 | position: relative; | ||
284 | display: inline-block; | ||
285 | input { | ||
286 | border: none; | ||
287 | background-color: var(--highlight-hover); | ||
288 | border-radius: 5px; | ||
289 | margin-top: 2px; | ||
290 | padding: 2px 12px 2px 30px; | ||
291 | transition: all 100ms linear; | ||
292 | color: #ffffff; | ||
293 | height: 30px; | ||
294 | width: 100px; | ||
295 | |||
296 | &:focus { | ||
297 | color: #000000; | ||
298 | width: 250px; | ||
299 | background-color: #ffffff; | ||
300 | } | ||
301 | } | ||
302 | |||
303 | .search-label::before { | ||
304 | font-family: "Font Awesome 5 Free"; | ||
305 | position: absolute; | ||
306 | top: 14px; | ||
307 | left: 16px; | ||
308 | content: "\f002"; | ||
309 | font-weight: 900; | ||
310 | width: 20px; | ||
311 | height: 20px; | ||
312 | color: #ffffff; | ||
313 | } | ||
314 | |||
315 | &:focus-within .search-label::before { | ||
316 | color: #6e6e6e; | ||
317 | } | ||
318 | } | ||
319 | |||
320 | .offline-message { | ||
321 | text-align: center; | ||
322 | margin: 35px 0; | ||
323 | |||
324 | svg { | ||
325 | font-size: 2rem; | ||
326 | } | ||
327 | |||
328 | svg.fa-redo-alt { | ||
329 | font-size: 1.3rem; | ||
330 | line-height: 1rem; | ||
331 | vertical-align: middle; | ||
332 | cursor: pointer; | ||
333 | color: #3273dc; | ||
334 | } | ||
335 | } | ||
336 | } | ||
diff --git a/src/assets/defaults.yml b/src/assets/defaults.yml new file mode 100644 index 0000000..1909328 --- /dev/null +++ b/src/assets/defaults.yml | |||
@@ -0,0 +1,39 @@ | |||
1 | --- | ||
2 | # Default configuration | ||
3 | |||
4 | title: "Dashboard" | ||
5 | subtitle: "Homer" | ||
6 | logo: "logo.png" | ||
7 | |||
8 | header: true | ||
9 | 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. | ||
10 | |||
11 | theme: default | ||
12 | colors: | ||
13 | light: | ||
14 | highlight-primary: "#3367d6" | ||
15 | highlight-secondary: "#4285f4" | ||
16 | highlight-hover: "#5a95f5" | ||
17 | background: "#f5f5f5" | ||
18 | card-background: "#ffffff" | ||
19 | text: "#363636" | ||
20 | text-header: "#ffffff" | ||
21 | text-title: "#303030" | ||
22 | text-subtitle: "#424242" | ||
23 | card-shadow: rgba(0, 0, 0, 0.1) | ||
24 | link-hover: "#363636" | ||
25 | dark: | ||
26 | highlight-primary: "#3367d6" | ||
27 | highlight-secondary: "#4285f4" | ||
28 | highlight-hover: "#5a95f5" | ||
29 | background: "#131313" | ||
30 | card-background: "#2b2b2b" | ||
31 | text: "#eaeaea" | ||
32 | text-header: "#ffffff" | ||
33 | text-title: "#fafafa" | ||
34 | text-subtitle: "#f5f5f5" | ||
35 | card-shadow: rgba(0, 0, 0, 0.4) | ||
36 | link-hover: "#ffdd57" | ||
37 | |||
38 | links: [] | ||
39 | services: [] | ||
diff --git a/src/assets/themes/sui.scss b/src/assets/themes/sui.scss new file mode 100644 index 0000000..f94433e --- /dev/null +++ b/src/assets/themes/sui.scss | |||
@@ -0,0 +1,34 @@ | |||
1 | /* | ||
2 | * SUI theme | ||
3 | * Inpired by the great https://github.com/jeroenpardon/sui start page | ||
4 | * Author: @bastienwirtz | ||
5 | */ | ||
6 | body #app.theme-sui { | ||
7 | #bighead .dashboard-title { | ||
8 | padding: 65px 0 0 12px; | ||
9 | |||
10 | h1 { | ||
11 | margin-top: 0; | ||
12 | font-weight: bold; | ||
13 | font-size: 2.2rem; | ||
14 | } | ||
15 | } | ||
16 | |||
17 | .navbar .navbar-item:hover { | ||
18 | background-color: transparent; | ||
19 | } | ||
20 | |||
21 | .card, | ||
22 | .card:hover { | ||
23 | background-color: transparent; | ||
24 | box-shadow: none; | ||
25 | |||
26 | .title { | ||
27 | font-weight: bold; | ||
28 | } | ||
29 | |||
30 | .card-content { | ||
31 | padding: 0; | ||
32 | } | ||
33 | } | ||
34 | } | ||
diff --git a/src/components/ConnectivityChecker.vue b/src/components/ConnectivityChecker.vue new file mode 100644 index 0000000..a91a809 --- /dev/null +++ b/src/components/ConnectivityChecker.vue | |||
@@ -0,0 +1,52 @@ | |||
1 | <template> | ||
2 | <div v-if="offline" class="offline-message"> | ||
3 | <i class="far fa-dizzy"></i> | ||
4 | <h1> | ||
5 | You're offline bro. | ||
6 | <span @click="checkOffline"> <i class="fas fa-redo-alt"></i></span> | ||
7 | </h1> | ||
8 | </div> | ||
9 | </template> | ||
10 | |||
11 | <script> | ||
12 | export default { | ||
13 | name: "ConnectivityChecker", | ||
14 | data: function () { | ||
15 | return { | ||
16 | offline: false, | ||
17 | }; | ||
18 | }, | ||
19 | created: function () { | ||
20 | let that = this; | ||
21 | this.checkOffline(); | ||
22 | |||
23 | document.addEventListener( | ||
24 | "visibilitychange", | ||
25 | function () { | ||
26 | if (document.visibilityState == "visible") { | ||
27 | that.checkOffline(); | ||
28 | } | ||
29 | }, | ||
30 | false | ||
31 | ); | ||
32 | }, | ||
33 | methods: { | ||
34 | checkOffline: function () { | ||
35 | let that = this; | ||
36 | return fetch(window.location.href + "?alive", { | ||
37 | method: "HEAD", | ||
38 | cache: "no-store", | ||
39 | }) | ||
40 | .then(function () { | ||
41 | that.offline = false; | ||
42 | }) | ||
43 | .catch(function () { | ||
44 | that.offline = true; | ||
45 | }) | ||
46 | .finally(function () { | ||
47 | that.$emit("network:status-update", that.offline); | ||
48 | }); | ||
49 | }, | ||
50 | }, | ||
51 | }; | ||
52 | </script> | ||
diff --git a/src/components/DarkMode.vue b/src/components/DarkMode.vue new file mode 100644 index 0000000..0bcde0f --- /dev/null +++ b/src/components/DarkMode.vue | |||
@@ -0,0 +1,34 @@ | |||
1 | <template> | ||
2 | <a | ||
3 | v-on:click="toggleTheme()" | ||
4 | aria-label="Toggle dark mode" | ||
5 | class="navbar-item is-inline-block-mobile" | ||
6 | > | ||
7 | <i class="fas fa-adjust"></i> | ||
8 | </a> | ||
9 | </template> | ||
10 | |||
11 | <script> | ||
12 | export default { | ||
13 | name: "Darkmode", | ||
14 | data: function () { | ||
15 | return { | ||
16 | isDark: null, | ||
17 | }; | ||
18 | }, | ||
19 | created: function () { | ||
20 | this.isDark = | ||
21 | "overrideDark" in localStorage | ||
22 | ? JSON.parse(localStorage.overrideDark) | ||
23 | : matchMedia("(prefers-color-scheme: dark)").matches; | ||
24 | this.$emit("updated", this.isDark); | ||
25 | }, | ||
26 | methods: { | ||
27 | toggleTheme: function () { | ||
28 | this.isDark = !this.isDark; | ||
29 | localStorage.overrideDark = this.isDark; | ||
30 | this.$emit("updated", this.isDark); | ||
31 | }, | ||
32 | }, | ||
33 | }; | ||
34 | </script> | ||
diff --git a/src/components/DynamicTheme.vue b/src/components/DynamicTheme.vue new file mode 100644 index 0000000..cf9963b --- /dev/null +++ b/src/components/DynamicTheme.vue | |||
@@ -0,0 +1,34 @@ | |||
1 | <template> | ||
2 | <DynamicStyle> | ||
3 | /* light / dark theme switch based on system pref if available */ body #app | ||
4 | { | ||
5 | {{ getVars(themes.light) }} | ||
6 | } @media (prefers-color-scheme: light), (prefers-color-scheme: | ||
7 | no-preference) { body #app { | ||
8 | {{ getVars(themes.light) }} | ||
9 | } } @media (prefers-color-scheme: dark) { body #app { } } /* light / dark | ||
10 | theme override base on user choice. */ body #app.is-dark { | ||
11 | {{ getVars(themes.dark) }} | ||
12 | } body #app.is-light { | ||
13 | {{ getVars(themes.light) }} | ||
14 | } | ||
15 | </DynamicStyle> | ||
16 | </template> | ||
17 | |||
18 | <script> | ||
19 | export default { | ||
20 | name: "DynamicTheme", | ||
21 | props: { | ||
22 | themes: Object, | ||
23 | }, | ||
24 | methods: { | ||
25 | getVars: function (theme) { | ||
26 | let vars = []; | ||
27 | for (const themeVars in theme) { | ||
28 | vars.push(`--${themeVars}: ${theme[themeVars]}`); | ||
29 | } | ||
30 | return vars.join(";"); | ||
31 | }, | ||
32 | }, | ||
33 | }; | ||
34 | </script> | ||
diff --git a/src/components/Message.vue b/src/components/Message.vue new file mode 100644 index 0000000..fcb0fbb --- /dev/null +++ b/src/components/Message.vue | |||
@@ -0,0 +1,41 @@ | |||
1 | <template> | ||
2 | <article v-if="item" class="message" :class="item.style"> | ||
3 | <div v-if="item.title" class="message-header"> | ||
4 | <p>{{ item.title }}</p> | ||
5 | </div> | ||
6 | <div v-if="item.content" class="message-body" v-html="item.content"></div> | ||
7 | </article> | ||
8 | </template> | ||
9 | |||
10 | <script> | ||
11 | export default { | ||
12 | name: "Message", | ||
13 | props: { | ||
14 | item: Object, | ||
15 | }, | ||
16 | created: function () { | ||
17 | // Look for a new message if an endpoint is provided. | ||
18 | let that = this; | ||
19 | if (this.item && this.item.url) { | ||
20 | this.getMessage(this.item.url).then(function (message) { | ||
21 | // keep the original config value if no value is provided by the endpoint | ||
22 | for (const prop of ["title", "style", "content"]) { | ||
23 | if (prop in message && message[prop] !== null) { | ||
24 | that.item[prop] = message[prop]; | ||
25 | } | ||
26 | } | ||
27 | }); | ||
28 | } | ||
29 | }, | ||
30 | methods: { | ||
31 | getMessage: function (url) { | ||
32 | return fetch(url).then(function (response) { | ||
33 | if (response.status != 200) { | ||
34 | return; | ||
35 | } | ||
36 | return response.json(); | ||
37 | }); | ||
38 | }, | ||
39 | }, | ||
40 | }; | ||
41 | </script> | ||
diff --git a/src/components/Navbar.vue b/src/components/Navbar.vue new file mode 100644 index 0000000..a64ff3b --- /dev/null +++ b/src/components/Navbar.vue | |||
@@ -0,0 +1,66 @@ | |||
1 | <template> | ||
2 | <div v-cloak v-if="links" class="container-fluid"> | ||
3 | <nav class="navbar" role="navigation" aria-label="main navigation"> | ||
4 | <div class="container"> | ||
5 | <div class="navbar-brand"> | ||
6 | <a | ||
7 | role="button" | ||
8 | aria-label="menu" | ||
9 | aria-expanded="false" | ||
10 | class="navbar-burger" | ||
11 | :class="{ 'is-active': showMenu }" | ||
12 | v-on:click="$emit('navbar:toggle')" | ||
13 | > | ||
14 | <span aria-hidden="true"></span> | ||
15 | <span aria-hidden="true"></span> | ||
16 | <span aria-hidden="true"></span> | ||
17 | </a> | ||
18 | </div> | ||
19 | <div class="navbar-menu" :class="{ 'is-active': showMenu }"> | ||
20 | <div class="navbar-start"> | ||
21 | <a | ||
22 | class="navbar-item" | ||
23 | v-for="link in links" | ||
24 | :key="link.url" | ||
25 | :href="link.url" | ||
26 | :target="link.target" | ||
27 | > | ||
28 | <i | ||
29 | v-if="link.icon" | ||
30 | style="margin-right: 6px;" | ||
31 | :class="link.icon" | ||
32 | ></i> | ||
33 | {{ link.name }} | ||
34 | </a> | ||
35 | </div> | ||
36 | <div class="navbar-end"> | ||
37 | <slot></slot> | ||
38 | </div> | ||
39 | </div> | ||
40 | </div> | ||
41 | </nav> | ||
42 | </div> | ||
43 | </template> | ||
44 | |||
45 | <script> | ||
46 | export default { | ||
47 | name: "Navbar", | ||
48 | props: { | ||
49 | open: { | ||
50 | type: Boolean, | ||
51 | default: false, | ||
52 | }, | ||
53 | links: Array, | ||
54 | }, | ||
55 | computed: { | ||
56 | showMenu: function () { | ||
57 | return this.open && this.isSmallScreen(); | ||
58 | }, | ||
59 | }, | ||
60 | methods: { | ||
61 | isSmallScreen: function () { | ||
62 | return window.matchMedia("screen and (max-width: 1023px)").matches; | ||
63 | }, | ||
64 | }, | ||
65 | }; | ||
66 | </script> | ||
diff --git a/src/components/SearchInput.vue b/src/components/SearchInput.vue new file mode 100644 index 0000000..22b5eef --- /dev/null +++ b/src/components/SearchInput.vue | |||
@@ -0,0 +1,42 @@ | |||
1 | <template> | ||
2 | <div class="search-bar"> | ||
3 | <label for="search" class="search-label"></label> | ||
4 | <input | ||
5 | type="text" | ||
6 | ref="search" | ||
7 | :value="value" | ||
8 | @input="$emit('input', $event.target.value.toLowerCase())" | ||
9 | @keyup.enter.exact="$emit('search:open')" | ||
10 | @keyup.alt.enter="$emit('search:open', '_blank')" | ||
11 | /> | ||
12 | </div> | ||
13 | </template> | ||
14 | |||
15 | <script> | ||
16 | export default { | ||
17 | name: "SearchInput", | ||
18 | props: ["value"], | ||
19 | mounted() { | ||
20 | this._keyListener = function (event) { | ||
21 | if (event.key === "/") { | ||
22 | event.preventDefault(); | ||
23 | this.$emit("search:focus"); | ||
24 | this.$nextTick(() => { | ||
25 | this.$refs.search.focus(); | ||
26 | }); | ||
27 | } | ||
28 | if (event.key === "Escape") { | ||
29 | this.$refs.search.value = ""; | ||
30 | this.$refs.search.blur(); | ||
31 | this.$emit("search:cancel"); | ||
32 | } | ||
33 | }; | ||
34 | document.addEventListener("keydown", this._keyListener.bind(this)); | ||
35 | }, | ||
36 | beforeDestroy() { | ||
37 | document.removeEventListener("keydown", this._keyListener); | ||
38 | }, | ||
39 | }; | ||
40 | </script> | ||
41 | |||
42 | <style lang="scss" scoped></style> | ||
diff --git a/src/components/Service.vue b/src/components/Service.vue new file mode 100644 index 0000000..d27b17b --- /dev/null +++ b/src/components/Service.vue | |||
@@ -0,0 +1,40 @@ | |||
1 | <template> | ||
2 | <div> | ||
3 | <div class="card"> | ||
4 | <a :href="item.url" :target="item.target"> | ||
5 | <div class="card-content"> | ||
6 | <div class="media"> | ||
7 | <div v-if="item.logo" class="media-left"> | ||
8 | <figure class="image is-48x48"> | ||
9 | <img :src="item.logo" /> | ||
10 | </figure> | ||
11 | </div> | ||
12 | <div v-if="item.icon" class="media-left"> | ||
13 | <figure class="image is-48x48"> | ||
14 | <i style="font-size: 35px;" :class="item.icon"></i> | ||
15 | </figure> | ||
16 | </div> | ||
17 | <div class="media-content"> | ||
18 | <p class="title is-4">{{ item.name }}</p> | ||
19 | <p class="subtitle is-6">{{ item.subtitle }}</p> | ||
20 | </div> | ||
21 | </div> | ||
22 | <div class="tag" :class="item.tagstyle" v-if="item.tag"> | ||
23 | <strong class="tag-text">#{{ item.tag }}</strong> | ||
24 | </div> | ||
25 | </div> | ||
26 | </a> | ||
27 | </div> | ||
28 | </div> | ||
29 | </template> | ||
30 | |||
31 | <script> | ||
32 | export default { | ||
33 | name: "Service", | ||
34 | props: { | ||
35 | item: Object, | ||
36 | }, | ||
37 | }; | ||
38 | </script> | ||
39 | |||
40 | <style scoped lang="scss"></style> | ||
diff --git a/src/components/SettingToggle.vue b/src/components/SettingToggle.vue new file mode 100644 index 0000000..94655bc --- /dev/null +++ b/src/components/SettingToggle.vue | |||
@@ -0,0 +1,41 @@ | |||
1 | <template> | ||
2 | <a v-on:click="toggleSetting()" class="navbar-item is-inline-block-mobile"> | ||
3 | <span v-show="value"><i :class="['fas', icon]"></i></span> | ||
4 | <span v-show="!value"><i :class="['fas', iconAlt]"></i></span> | ||
5 | <slot></slot> | ||
6 | </a> | ||
7 | </template> | ||
8 | |||
9 | <script> | ||
10 | export default { | ||
11 | name: "SettingToggle", | ||
12 | props: { | ||
13 | name: String, | ||
14 | icon: String, | ||
15 | iconAlt: String, | ||
16 | }, | ||
17 | data: function () { | ||
18 | return { | ||
19 | value: true, | ||
20 | }; | ||
21 | }, | ||
22 | created: function () { | ||
23 | if (!this.iconAlt) { | ||
24 | this.iconAlt = this.icon; | ||
25 | } | ||
26 | |||
27 | if (this.name in localStorage) { | ||
28 | this.value = JSON.parse(localStorage[this.name]); | ||
29 | } | ||
30 | |||
31 | this.$emit("updated", this.value); | ||
32 | }, | ||
33 | methods: { | ||
34 | toggleSetting: function () { | ||
35 | this.value = !this.value; | ||
36 | localStorage[this.name] = this.value; | ||
37 | this.$emit("updated", this.value); | ||
38 | }, | ||
39 | }, | ||
40 | }; | ||
41 | </script> | ||
diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..2095acf --- /dev/null +++ b/src/main.js | |||
@@ -0,0 +1,20 @@ | |||
1 | import Vue from "vue"; | ||
2 | import App from "./App.vue"; | ||
3 | import "./registerServiceWorker"; | ||
4 | |||
5 | import "@fortawesome/fontawesome-free/css/all.css"; | ||
6 | import "@fortawesome/fontawesome-free/js/all.js"; | ||
7 | |||
8 | import "./assets/app.scss"; | ||
9 | |||
10 | Vue.config.productionTip = false; | ||
11 | |||
12 | Vue.component("DynamicStyle", { | ||
13 | render: function (createElement) { | ||
14 | return createElement("style", this.$slots.default); | ||
15 | }, | ||
16 | }); | ||
17 | |||
18 | new Vue({ | ||
19 | render: (h) => h(App), | ||
20 | }).$mount("#app"); | ||
diff --git a/src/registerServiceWorker.js b/src/registerServiceWorker.js new file mode 100644 index 0000000..1473a0a --- /dev/null +++ b/src/registerServiceWorker.js | |||
@@ -0,0 +1,34 @@ | |||
1 | /* eslint-disable no-console */ | ||
2 | |||
3 | import { register } from "register-service-worker"; | ||
4 | |||
5 | if (process.env.NODE_ENV === "production") { | ||
6 | register(`${process.env.BASE_URL}service-worker.js`, { | ||
7 | ready() { | ||
8 | console.log( | ||
9 | "App is being served from cache by a service worker.\n" + | ||
10 | "For more details, visit https://goo.gl/AFskqB" | ||
11 | ); | ||
12 | }, | ||
13 | registered() { | ||
14 | console.log("Service worker has been registered."); | ||
15 | }, | ||
16 | cached() { | ||
17 | console.log("Content has been cached for offline use."); | ||
18 | }, | ||
19 | updatefound() { | ||
20 | console.log("New content is downloading."); | ||
21 | }, | ||
22 | updated() { | ||
23 | console.log("New content is available; please refresh."); | ||
24 | }, | ||
25 | offline() { | ||
26 | console.log( | ||
27 | "No internet connection found. App is running in offline mode." | ||
28 | ); | ||
29 | }, | ||
30 | error(error) { | ||
31 | console.error("Error during service worker registration:", error); | ||
32 | }, | ||
33 | }); | ||
34 | } | ||