├── .editorconfig
├── .eslintrc
├── .gitignore
├── .idea
├── inspectionProfiles
│ └── Project_Default.xml
├── jsLibraryMappings.xml
├── jsLinters
│ └── eslint.xml
├── modules.xml
├── timestamp.iml
└── vcs.xml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── app
├── assets
│ ├── backgrounds
│ │ └── gradient.svg
│ ├── icons
│ │ ├── bell.svg
│ │ ├── calendar.svg
│ │ ├── command.svg
│ │ ├── power.svg
│ │ └── settings.svg
│ └── logo.svg
├── components
│ ├── Calendar.js
│ ├── Clock.js
│ ├── Locale.js
│ ├── Logger.js
│ ├── Preferences.js
│ ├── SystemTray.js
│ ├── Updater.js
│ └── Window.js
├── index.js
├── ipc.js
├── locales
│ ├── de.js
│ ├── en.js
│ └── index.js
├── paths.js
├── styles
│ ├── components
│ │ ├── button-primary.css
│ │ ├── container-alert.css
│ │ ├── form-group.css
│ │ ├── fx-bounce.css
│ │ ├── icon-dots.css
│ │ ├── is-draggable.css
│ │ ├── is-native.css
│ │ └── list-shortcuts.css
│ ├── meta
│ │ ├── colors.css
│ │ ├── fx.css
│ │ ├── grid.css
│ │ └── typography.css
│ ├── shared
│ │ ├── base.css
│ │ └── typography.css
│ └── styles.css
└── views
│ ├── calendar
│ ├── calendar.css
│ ├── calendar.html
│ ├── ipc.js
│ ├── preload.js
│ └── renderer.js
│ ├── common
│ ├── dynamic-image.js
│ └── translation-key.js
│ └── preferences
│ ├── ipc.js
│ ├── preferences.css
│ ├── preferences.html
│ ├── preload.js
│ └── renderer.js
├── build
├── entitlements.mac.plist
└── icon.icns
├── package-lock.json
├── package.json
└── scripts
└── notarize.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 | end_of_line = lf
10 | # editorconfig-tools is unable to ignore longs strings or urls
11 | max_line_length = off
12 |
13 | [*.md]
14 | indent_size = 4
15 | trim_trailing_whitespace = false
16 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true,
5 | "node": true
6 | },
7 | "extends": [
8 | "airbnb-base"
9 | ],
10 | "parserOptions": {
11 | "ecmaVersion": 13,
12 | "sourceType": "module"
13 | },
14 | "rules": {
15 | "import/extensions": "off",
16 | "import/no-relative-packages": "off",
17 | "max-len": "off",
18 | "no-console": "off",
19 | "no-restricted-syntax": "off"
20 | },
21 | "settings": {
22 | "import/core-modules": ["electron"]
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.toptal.com/developers/gitignore/api/node,macos,windows,intellij
3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,windows,intellij
4 |
5 | ### Intellij ###
6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
8 |
9 | # User-specific stuff
10 | .idea/**/workspace.xml
11 | .idea/**/tasks.xml
12 | .idea/**/usage.statistics.xml
13 | .idea/**/dictionaries
14 | .idea/**/shelf
15 |
16 | # Generated files
17 | .idea/**/contentModel.xml
18 |
19 | # Sensitive or high-churn files
20 | .idea/**/dataSources/
21 | .idea/**/dataSources.ids
22 | .idea/**/dataSources.local.xml
23 | .idea/**/sqlDataSources.xml
24 | .idea/**/dynamic.xml
25 | .idea/**/uiDesigner.xml
26 | .idea/**/dbnavigator.xml
27 |
28 | # Gradle
29 | .idea/**/gradle.xml
30 | .idea/**/libraries
31 |
32 | # Gradle and Maven with auto-import
33 | # When using Gradle or Maven with auto-import, you should exclude module files,
34 | # since they will be recreated, and may cause churn. Uncomment if using
35 | # auto-import.
36 | # .idea/artifacts
37 | # .idea/compiler.xml
38 | # .idea/jarRepositories.xml
39 | # .idea/modules.xml
40 | # .idea/*.iml
41 | # .idea/modules
42 | # *.iml
43 | # *.ipr
44 |
45 | # CMake
46 | cmake-build-*/
47 |
48 | # Mongo Explorer plugin
49 | .idea/**/mongoSettings.xml
50 |
51 | # File-based project format
52 | *.iws
53 |
54 | # IntelliJ
55 | out/
56 |
57 | # mpeltonen/sbt-idea plugin
58 | .idea_modules/
59 |
60 | # JIRA plugin
61 | atlassian-ide-plugin.xml
62 |
63 | # Cursive Clojure plugin
64 | .idea/replstate.xml
65 |
66 | # Crashlytics plugin (for Android Studio and IntelliJ)
67 | com_crashlytics_export_strings.xml
68 | crashlytics.properties
69 | crashlytics-build.properties
70 | fabric.properties
71 |
72 | # Editor-based Rest Client
73 | .idea/httpRequests
74 |
75 | # Android studio 3.1+ serialized cache file
76 | .idea/caches/build_file_checksums.ser
77 |
78 | ### Intellij Patch ###
79 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
80 |
81 | # *.iml
82 | # modules.xml
83 | # .idea/misc.xml
84 | # *.ipr
85 |
86 | # Sonarlint plugin
87 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
88 | .idea/**/sonarlint/
89 |
90 | # SonarQube Plugin
91 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
92 | .idea/**/sonarIssues.xml
93 |
94 | # Markdown Navigator plugin
95 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
96 | .idea/**/markdown-navigator.xml
97 | .idea/**/markdown-navigator-enh.xml
98 | .idea/**/markdown-navigator/
99 |
100 | # Cache file creation bug
101 | # See https://youtrack.jetbrains.com/issue/JBR-2257
102 | .idea/$CACHE_FILE$
103 |
104 | # CodeStream plugin
105 | # https://plugins.jetbrains.com/plugin/12206-codestream
106 | .idea/codestream.xml
107 |
108 | ### macOS ###
109 | # General
110 | .DS_Store
111 | .AppleDouble
112 | .LSOverride
113 |
114 | # Icon must end with two \r
115 | Icon
116 |
117 |
118 | # Thumbnails
119 | ._*
120 |
121 | # Files that might appear in the root of a volume
122 | .DocumentRevisions-V100
123 | .fseventsd
124 | .Spotlight-V100
125 | .TemporaryItems
126 | .Trashes
127 | .VolumeIcon.icns
128 | .com.apple.timemachine.donotpresent
129 |
130 | # Directories potentially created on remote AFP share
131 | .AppleDB
132 | .AppleDesktop
133 | Network Trash Folder
134 | Temporary Items
135 | .apdisk
136 |
137 | ### Node ###
138 | # Logs
139 | logs
140 | *.log
141 | npm-debug.log*
142 | yarn-debug.log*
143 | yarn-error.log*
144 | lerna-debug.log*
145 |
146 | # Diagnostic reports (https://nodejs.org/api/report.html)
147 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
148 |
149 | # Runtime data
150 | pids
151 | *.pid
152 | *.seed
153 | *.pid.lock
154 |
155 | # Directory for instrumented libs generated by jscoverage/JSCover
156 | lib-cov
157 |
158 | # Coverage directory used by tools like istanbul
159 | coverage
160 | *.lcov
161 |
162 | # nyc test coverage
163 | .nyc_output
164 |
165 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
166 | .grunt
167 |
168 | # Bower dependency directory (https://bower.io/)
169 | bower_components
170 |
171 | # node-waf configuration
172 | .lock-wscript
173 |
174 | # Compiled binary addons (https://nodejs.org/api/addons.html)
175 | build/Release
176 |
177 | # Dependency directories
178 | node_modules/
179 | jspm_packages/
180 |
181 | # TypeScript v1 declaration files
182 | typings/
183 |
184 | # TypeScript cache
185 | *.tsbuildinfo
186 |
187 | # Optional npm cache directory
188 | .npm
189 |
190 | # Optional eslint cache
191 | .eslintcache
192 |
193 | # Optional stylelint cache
194 | .stylelintcache
195 |
196 | # Microbundle cache
197 | .rpt2_cache/
198 | .rts2_cache_cjs/
199 | .rts2_cache_es/
200 | .rts2_cache_umd/
201 |
202 | # Optional REPL history
203 | .node_repl_history
204 |
205 | # Output of 'npm pack'
206 | *.tgz
207 |
208 | # Yarn Integrity file
209 | .yarn-integrity
210 |
211 | # dotenv environment variables file
212 | .env
213 | .env.test
214 | .env*.local
215 |
216 | # parcel-bundler cache (https://parceljs.org/)
217 | .cache
218 | .parcel-cache
219 |
220 | # Next.js build output
221 | .next
222 |
223 | # Nuxt.js build / generate output
224 | .nuxt
225 | dist
226 |
227 | # Storybook build outputs
228 | .out
229 | .storybook-out
230 | storybook-static
231 |
232 | # rollup.js default build output
233 | dist/
234 |
235 | # Gatsby files
236 | .cache/
237 | # Comment in the public line in if your project uses Gatsby and not Next.js
238 | # https://nextjs.org/blog/next-9-1#public-directory-support
239 | # public
240 |
241 | # vuepress build output
242 | .vuepress/dist
243 |
244 | # Serverless directories
245 | .serverless/
246 |
247 | # FuseBox cache
248 | .fusebox/
249 |
250 | # DynamoDB Local files
251 | .dynamodb/
252 |
253 | # TernJS port file
254 | .tern-port
255 |
256 | # Stores VSCode versions used for testing VSCode extensions
257 | .vscode-test
258 |
259 | # Temporary folders
260 | tmp/
261 | temp/
262 |
263 | ### Windows ###
264 | # Windows thumbnail cache files
265 | Thumbs.db
266 | Thumbs.db:encryptable
267 | ehthumbs.db
268 | ehthumbs_vista.db
269 |
270 | # Dump file
271 | *.stackdump
272 |
273 | # Folder config file
274 | [Dd]esktop.ini
275 |
276 | # Recycle Bin used on file shares
277 | $RECYCLE.BIN/
278 |
279 | # Windows Installer files
280 | *.cab
281 | *.msi
282 | *.msix
283 | *.msm
284 | *.msp
285 |
286 | # Windows shortcuts
287 | *.lnk
288 |
289 | # End of https://www.toptal.com/developers/gitignore/api/node,macos,windows,intellij
290 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/jsLibraryMappings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/jsLinters/eslint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/timestamp.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # [1.0.1]
2 | ###### 2016-09-09
3 |
4 | A tiny patch release which eliminates two bugs. 🐞
5 |
6 | ###### Fixed
7 | - Calendar window position being offset on multiple monitors
8 | - Preferences and about window being opened multiple times
9 |
10 | # 1.0.0
11 | ###### 2016-09-04
12 |
13 | First public release! 🎉
14 |
15 | [1.0.1]: https://github.com/mzdr/timestamp/compare/1.0.0...1.0.1
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Sebastian Prein
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Timestamp
4 |
5 |
6 |
7 | A better macOS menu bar clock with a customizable date/time display and a calendar. Inspired by [Day-O].
8 |
9 | Built with [Electron] and [date-fns].
10 |
11 | ## Screenshots
12 |
13 | 
14 |
15 | ## Install
16 |
17 | ### Manual
18 | **[Download]**, unzip, and move `Timestamp.app` to the `/Applications` directory.
19 |
20 | ### Homebrew
21 | Simply run `brew install --cask timestamp` in your terminal.
22 |
23 | ## Support
24 |
25 | **Bugs and requests**: Please use the project's [issue tracker].
26 | [](https://github.com/mzdr/timestamp/issues)
27 |
28 | **Want to contribute?** Please fork this repository and open a pull request with your new shiny stuff. 🌟
29 | [](https://github.com/mzdr/timestamp/pulls)
30 |
31 | **Do you like it?** Support the project by starring the repository or [tweet] about it.
32 |
33 | ## Thanks
34 |
35 | **Timestamp** © 2021, Sebastian Prein. Released under the [MIT License].
36 |
37 | [Day-O]: http://shauninman.com/archive/2011/10/20/day_o_mac_menu_bar_clock
38 | [Electron]: http://electron.atom.io/
39 | [date-fns]: https://date-fns.org/
40 | [MIT License]: https://mit-license.org/
41 | [issue tracker]: https://github.com/mzdr/timestamp/issues/new
42 | [tweet]: https://twitter.com/intent/tweet?url=https://github.com/mzdr/timestamp&text=Timestamp,%20a%20better%20macOS%20menu%20bar%20clock%20with%20a%20customizable%20date/time%20display%20and%20a%20calendar.%20%E2%80%94
43 | [customizable]: https://date-fns.org/docs/format
44 | [Download]: https://github.com/mzdr/timestamp/releases/latest
45 | [support]: #support
46 |
--------------------------------------------------------------------------------
/app/assets/backgrounds/gradient.svg:
--------------------------------------------------------------------------------
1 |
63 |
--------------------------------------------------------------------------------
/app/assets/icons/bell.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/assets/icons/calendar.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/assets/icons/command.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/assets/icons/power.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/assets/icons/settings.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
44 |
--------------------------------------------------------------------------------
/app/components/Calendar.js:
--------------------------------------------------------------------------------
1 | const { ipcMain } = require('electron');
2 | const { resolve } = require('path');
3 | const datefns = require('date-fns');
4 |
5 | const Window = require('./Window');
6 |
7 | const {
8 | CALENDAR_GET_CALENDAR,
9 | CALENDAR_GET_DATE,
10 | CALENDAR_GET_WEEKDAYS,
11 | CALENDAR_HIDE,
12 | CALENDAR_SHOW,
13 | } = require('../views/calendar/ipc');
14 |
15 | class Calendar {
16 | constructor({ locale, logger }) {
17 | this.logger = logger;
18 | this.locale = locale.getObject();
19 |
20 | ipcMain.handle(CALENDAR_GET_CALENDAR, this.getCalendar.bind(this));
21 | ipcMain.handle(CALENDAR_GET_DATE, this.getDate.bind(this));
22 | ipcMain.handle(CALENDAR_GET_WEEKDAYS, this.getWeekdays.bind(this));
23 |
24 | ipcMain.on(CALENDAR_HIDE, () => this.window.hide());
25 | ipcMain.on(CALENDAR_SHOW, () => this.window.show());
26 |
27 | this.window = new Window({
28 | name: 'calendar',
29 | sourceFile: resolve(__dirname, '../views/calendar/calendar.html'),
30 | webPreferences: {
31 | preload: resolve(__dirname, '../views/calendar/preload.js'),
32 | backgroundThrottling: false,
33 | },
34 | });
35 |
36 | this.logger.debug('Calendar module created.');
37 | }
38 |
39 | getDate(event, payload = {}) {
40 | const { locale } = this;
41 |
42 | const {
43 | date,
44 | format,
45 | set,
46 | diff,
47 | } = payload;
48 |
49 | let final = date || new Date();
50 |
51 | if (set) {
52 | final = datefns.set(final, set);
53 | }
54 |
55 | if (diff) {
56 | final = datefns.add(final, diff); // date-fns.add() supports negative numbers as well
57 | }
58 |
59 | if (format) {
60 | try {
61 | return datefns.format(final, format, { locale });
62 | } catch (error) {
63 | return '#invalid format#';
64 | }
65 | }
66 |
67 | return final;
68 | }
69 |
70 | getCalendar(event, payload) {
71 | const { locale } = this;
72 | const year = this.getDate(null, payload);
73 | const startOfYear = datefns.startOfYear(year);
74 | const endOfYear = datefns.endOfYear(year);
75 | const totalDays = datefns.differenceInCalendarDays(endOfYear, startOfYear);
76 |
77 | const days = [];
78 |
79 | for (let i = 0; i <= totalDays; i += 1) {
80 | const date = datefns.addDays(startOfYear, i);
81 | const week = datefns.getWeek(date, { locale });
82 | const weekday = datefns.getDay(date);
83 | const day = datefns.format(date, 'd', { locale });
84 |
85 | days.push({
86 | date,
87 | day,
88 | week,
89 | weekday,
90 | });
91 | }
92 |
93 | return days;
94 | }
95 |
96 | getWeekdays() {
97 | const { locale } = this;
98 | const startOfWeek = datefns.startOfWeek(new Date(), { locale });
99 | const startIndex = datefns.getDay(startOfWeek);
100 | const weekdays = [];
101 |
102 | for (let i = 0; i < 7; i += 1) {
103 | const weekday = datefns.addDays(startOfWeek, i);
104 |
105 | weekdays.push(
106 | datefns.format(weekday, 'EEE', { locale }),
107 | );
108 | }
109 |
110 | return {
111 | startIndex,
112 | weekdays,
113 | };
114 | }
115 | }
116 |
117 | module.exports = Calendar;
118 |
--------------------------------------------------------------------------------
/app/components/Clock.js:
--------------------------------------------------------------------------------
1 | const datefns = require('date-fns');
2 |
3 | class Clock {
4 | #tickId = null;
5 |
6 | constructor(options = {}) {
7 | const { onTick, locale, format } = options;
8 |
9 | this.locale = locale.getObject();
10 |
11 | this
12 | .setFormat(format)
13 | .onTick(onTick);
14 | }
15 |
16 | getFormat() {
17 | return this.format;
18 | }
19 |
20 | setFormat(value) {
21 | if (typeof value !== 'string') {
22 | throw new Error(`Clock.format is supposed to be a string, ${typeof value} given.`);
23 | }
24 |
25 | // @see https://date-fns.org/docs/format
26 | this.format = value;
27 |
28 | return this;
29 | }
30 |
31 | onTick(fn) {
32 | this.now = new Date();
33 |
34 | fn(this);
35 |
36 | if (this.#tickId === null) {
37 | this.#tickId = setInterval(() => this.onTick(fn), 1000);
38 | }
39 | }
40 |
41 | toString() {
42 | const { locale } = this;
43 |
44 | try {
45 | return datefns.format(
46 | this.now,
47 | this.getFormat(),
48 | { locale },
49 | );
50 | } catch (error) {
51 | return '#invalid format#';
52 | }
53 | }
54 | }
55 |
56 | module.exports = Clock;
57 |
--------------------------------------------------------------------------------
/app/components/Locale.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable global-require */
2 | const locales = {
3 | date: require('date-fns/locale'),
4 | app: require('../locales'),
5 | };
6 |
7 | class Locale {
8 | constructor(options = {}) {
9 | const { preferred, logger } = options;
10 | const [language, extension] = String(preferred).split('-');
11 |
12 | logger.debug(`Preferred locale is “${preferred}”.`);
13 |
14 | const fullSupport = `${language}${extension}`;
15 | const partialSupport = language;
16 | const fallback = 'en-US';
17 |
18 | // Find locale that is supported by date-fns. Go from best to worst.
19 | this.locale = [fullSupport, partialSupport, fallback].find((k) => locales.date[k]);
20 |
21 | // Pick datefns locale object.
22 | this.localeObject = locales.date[this.locale];
23 |
24 | // Pick app translations. For now we just support language specific translations,
25 | // not regional specific ones. Might come at a later time.
26 | this.translations = locales.app[partialSupport] || locales.app.en;
27 |
28 | logger.debug(`Using “${this.translations.locale}” as application locale and “${this.locale}” as clock/calendar locale.`);
29 | }
30 |
31 | get() {
32 | return this.locale;
33 | }
34 |
35 | getObject() {
36 | return this.localeObject;
37 | }
38 |
39 | translate(key) {
40 | return key
41 | .split('.')
42 | .reduce((o, i) => (o || {})[i], this.translations) || key;
43 | }
44 | }
45 |
46 | module.exports = Locale;
47 |
--------------------------------------------------------------------------------
/app/components/Logger.js:
--------------------------------------------------------------------------------
1 | const { writeFile } = require('fs').promises;
2 | const { logFile } = require('../paths');
3 |
4 | class Logger {
5 | constructor() {
6 | this.filePath = logFile;
7 | this.levels = {
8 | emergency: 0,
9 | alert: 1,
10 | critical: 2,
11 | error: 3,
12 | warning: 4,
13 | notice: 5,
14 | informational: 6,
15 | debug: 7,
16 | };
17 |
18 | this.cleanUp = writeFile(this.filePath, '\n');
19 | }
20 |
21 | async log(level, message) {
22 | const severity = Object.keys(this.levels).find((key) => this.levels[key] === level);
23 | const entry = `[${severity}]: ${message}`;
24 |
25 | await this.cleanUp;
26 |
27 | writeFile(this.filePath, `${entry}\n`, { flag: 'a' });
28 | }
29 |
30 | emergency(message) {
31 | return this.log(this.levels.emergency, message);
32 | }
33 |
34 | alert(message) {
35 | return this.log(this.levels.alert, message);
36 | }
37 |
38 | critical(message) {
39 | return this.log(this.levels.critical, message);
40 | }
41 |
42 | error(message) {
43 | return this.log(this.levels.error, message);
44 | }
45 |
46 | warning(message) {
47 | return this.log(this.levels.warning, message);
48 | }
49 |
50 | notice(message) {
51 | return this.log(this.levels.notice, message);
52 | }
53 |
54 | informational(message) {
55 | return this.log(this.levels.informational, message);
56 | }
57 |
58 | debug(message) {
59 | return this.log(this.levels.debug, message);
60 | }
61 | }
62 |
63 | module.exports = Logger;
64 |
--------------------------------------------------------------------------------
/app/components/Preferences.js:
--------------------------------------------------------------------------------
1 | const { ipcMain } = require('electron');
2 | const { resolve } = require('path');
3 |
4 | const {
5 | readFile,
6 | writeFile,
7 | readdir,
8 | mkdir,
9 | } = require('fs').promises;
10 |
11 | const {
12 | preferencesFile,
13 | customBackgroundsDirectory,
14 | integratedBackgroundsDirectory,
15 | } = require('../paths');
16 |
17 | const Window = require('./Window');
18 |
19 | const {
20 | PREFERENCES_GET,
21 | PREFERENCES_GET_ALL,
22 | PREFERENCES_GET_BACKGROUND_FILE_CONTENTS,
23 | PREFERENCES_GET_BACKGROUNDS,
24 | PREFERENCES_HIDE,
25 | PREFERENCES_SET,
26 | PREFERENCES_SHOW,
27 |
28 | } = require('../views/preferences/ipc');
29 |
30 | class Preferences {
31 | constructor(options = {}) {
32 | const {
33 | onChange,
34 | defaults,
35 | logger,
36 | } = options;
37 |
38 | this.logger = logger;
39 | this.filePath = preferencesFile;
40 | this.onChange = onChange || (() => {});
41 | this.data = new Map(Object.entries(defaults));
42 |
43 | ipcMain.handle(PREFERENCES_GET, (event, key) => this.get(key));
44 | ipcMain.handle(PREFERENCES_GET_ALL, () => this.getAll());
45 | ipcMain.handle(PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, this.getBackgroundFileContents.bind(this));
46 | ipcMain.handle(PREFERENCES_GET_BACKGROUNDS, this.getBackgrounds.bind(this));
47 |
48 | ipcMain.on(PREFERENCES_SET, (event, key, value) => this.set(key, value));
49 | ipcMain.on(PREFERENCES_HIDE, () => this.window.hide());
50 | ipcMain.on(PREFERENCES_SHOW, () => this.window.show());
51 |
52 | this.window = new Window({
53 | name: 'preferences',
54 | titleBarStyle: 'hidden',
55 | transparent: true,
56 | vibrancy: 'sidebar',
57 | trafficLightPosition: { x: 20, y: 20 },
58 | sourceFile: resolve(__dirname, '../views/preferences/preferences.html'),
59 | webPreferences: {
60 | preload: resolve(__dirname, '../views/preferences/preload.js'),
61 | },
62 | });
63 |
64 | this.logger.debug('Preferences module created.');
65 | this.load();
66 | }
67 |
68 | async load() {
69 | try {
70 | this.logger.debug(`Trying to load user preferences from “${this.filePath}”.`);
71 |
72 | Object
73 | .entries(JSON.parse(await readFile(this.filePath, 'utf8')))
74 | .forEach((item) => this.set(...item, false));
75 |
76 | await mkdir(customBackgroundsDirectory);
77 | } catch ({ message }) {
78 | if (/enoent/i.test(message)) {
79 | this.logger.debug('Looks like it’s the first time starting Timestamp. No user preferences found.');
80 | } else if (/eexist/i.test(message)) {
81 | this.logger.debug('Directory for custom backgrounds has already been created.');
82 | } else {
83 | this.logger.error(message);
84 | }
85 | }
86 |
87 | return this;
88 | }
89 |
90 | async save() {
91 | await writeFile(this.filePath, JSON.stringify(Object.fromEntries(this.data)));
92 |
93 | return this;
94 | }
95 |
96 | getAll() {
97 | return new Map(this.data);
98 | }
99 |
100 | async getBackgroundFileContents(event, filePath) {
101 | try {
102 | return await readFile(filePath, { encoding: 'utf-8' });
103 | } catch ({ message }) {
104 | if (/enoent/i.test(message)) {
105 | this.logger.warning(`Couldn’t find background file “${filePath}”.`);
106 | } else {
107 | this.logger.error(message);
108 | }
109 | }
110 |
111 | return '';
112 | }
113 |
114 | async getBackgrounds() {
115 | const backgrounds = [];
116 | const directories = [integratedBackgroundsDirectory, customBackgroundsDirectory];
117 |
118 | await Promise.all(
119 | directories.map(async (directory) => {
120 | try {
121 | (await readdir(directory)).forEach(
122 | (background) => backgrounds.push(resolve(directory, background)),
123 | );
124 | } catch ({ message }) {
125 | this.logger.warn(message);
126 | }
127 | }),
128 | );
129 |
130 | return backgrounds;
131 | }
132 |
133 | get(key) {
134 | return this.data.get(key);
135 | }
136 |
137 | set(key, value, persist = true) {
138 | this.logger.debug(`Setting value for preference with key ”${key}” to “${value}”.`);
139 |
140 | this.data.set(key, value);
141 | this.onChange(key, value);
142 |
143 | if (persist) {
144 | this.save();
145 | }
146 |
147 | return this;
148 | }
149 | }
150 |
151 | module.exports = Preferences;
152 |
--------------------------------------------------------------------------------
/app/components/SystemTray.js:
--------------------------------------------------------------------------------
1 | const { Tray, nativeImage } = require('electron');
2 |
3 | class SystemTray {
4 | constructor(options = {}) {
5 | const { onClick, logger } = options;
6 |
7 | this.logger = logger;
8 | this.prefix = '';
9 | this.tray = new Tray(
10 | nativeImage.createEmpty(),
11 | );
12 |
13 | this.logger.debug('System tray created.');
14 |
15 | if (typeof onClick === 'function') {
16 | this.tray.on('click', onClick);
17 | }
18 | }
19 |
20 | getBounds() {
21 | return this.tray.getBounds();
22 | }
23 |
24 | getPrefix() {
25 | return this.prefix;
26 | }
27 |
28 | setPrefix(value) {
29 | this.prefix = value;
30 |
31 | return this;
32 | }
33 |
34 | getLabel() {
35 | return this.tray.getTitle();
36 | }
37 |
38 | setLabel(label) {
39 | const { tray } = this;
40 |
41 | if (tray.isDestroyed()) {
42 | this.logger.error('Unable to set label since tray is destroyed.');
43 |
44 | return this;
45 | }
46 |
47 | tray.setTitle(`${this.getPrefix()}${this.label = label}`, {
48 | fontType: 'monospacedDigit',
49 | });
50 |
51 | return this;
52 | }
53 | }
54 |
55 | module.exports = SystemTray;
56 |
--------------------------------------------------------------------------------
/app/components/Updater.js:
--------------------------------------------------------------------------------
1 | const { lt } = require('semver');
2 | const { get } = require('https');
3 | const { autoUpdater } = require('electron');
4 |
5 | class Updater {
6 | constructor(options = {}) {
7 | const {
8 | checkEvery = 1000 * 60 * 60 * 24, // 24 hours
9 | currentVersion,
10 | feedUrl,
11 | logger,
12 | onUpdateDownloaded,
13 | } = options;
14 |
15 | this.feedUrl = feedUrl;
16 | this.logger = logger;
17 |
18 | autoUpdater.on('error', this.onError.bind(this));
19 | autoUpdater.on('update-downloaded', onUpdateDownloaded);
20 |
21 | setInterval(this.onTick.bind(this, currentVersion), checkEvery);
22 |
23 | this.logger.debug('Updater module created.');
24 | this.logger.debug(`Checking “${feedUrl}” every ${checkEvery / 1000} seconds for updates.`);
25 |
26 | this.onTick(currentVersion);
27 | }
28 |
29 | async fetchJson() {
30 | return new Promise((resolve, reject) => {
31 | const request = get(this.feedUrl, (response) => {
32 | const { statusCode } = response;
33 | const json = [];
34 |
35 | if (statusCode !== 200) {
36 | reject(new Error(`Feed url is not reachable. Response status code is ${statusCode}.`));
37 | } else {
38 | response.on('data', json.push.bind(json));
39 | response.on('end', () => {
40 | try {
41 | resolve(JSON.parse(json.join()));
42 | } catch (error) {
43 | this.logger.error('Couldn’t parse feed response.');
44 | }
45 | });
46 | }
47 | });
48 |
49 | request.on('error', reject);
50 | });
51 | }
52 |
53 | quitAndInstall() {
54 | autoUpdater.quitAndInstall();
55 |
56 | return this;
57 | }
58 |
59 | async onTick(currentVersion) {
60 | try {
61 | const { version } = await this.fetchJson();
62 |
63 | if (lt(currentVersion, version) === false) {
64 | return;
65 | }
66 |
67 | autoUpdater.setFeedURL(this.feedUrl);
68 | autoUpdater.checkForUpdates();
69 |
70 | this.logger.debug(`Update available. (${currentVersion} -> ${version})`);
71 | } catch ({ message }) {
72 | this.logger.error(`Update tick failed because of “${message}”.`);
73 | }
74 | }
75 |
76 | onError({ message }) {
77 | this.logger.error(`AutoUpdater failed because of “${message}”.`);
78 |
79 | return this;
80 | }
81 | }
82 |
83 | module.exports = Updater;
84 |
--------------------------------------------------------------------------------
/app/components/Window.js:
--------------------------------------------------------------------------------
1 | const { BrowserWindow, shell } = require('electron');
2 |
3 | class Window {
4 | constructor(options = {}) {
5 | const defaults = {
6 | alwaysOnTop: true,
7 | frame: false,
8 | minimizable: false,
9 | resizable: false,
10 | show: false,
11 | };
12 |
13 | const {
14 | sourceFile,
15 | name,
16 | onReady,
17 | ...rest
18 | } = options;
19 |
20 | this.name = name;
21 | this.browserWindow = new BrowserWindow({ ...defaults, ...rest });
22 |
23 | if (typeof onReady === 'function') {
24 | this.browserWindow.on('ready-to-show', onReady);
25 | }
26 |
27 | // @see https://www.electronjs.org/docs/tutorial/security#12-disable-or-limit-navigation
28 | this.browserWindow.webContents.on('will-navigate', (event, navigationUrl) => {
29 | event.preventDefault();
30 |
31 | if (/^https?:\/\//.test(navigationUrl)) {
32 | shell.openExternal(navigationUrl);
33 | }
34 | });
35 |
36 | // @see https://www.electronjs.org/docs/tutorial/security#13-disable-or-limit-creation-of-new-windows
37 | this.browserWindow.webContents.on('new-window', (event) => event.preventDefault());
38 |
39 | this
40 | .browserWindow
41 | .on('close', this.onClose.bind(this))
42 | .loadFile(sourceFile);
43 | }
44 |
45 | isSame(window) {
46 | return window === this.browserWindow;
47 | }
48 |
49 | destroy() {
50 | this.browserWindow.destroy();
51 |
52 | return this;
53 | }
54 |
55 | show() {
56 | this.browserWindow.webContents.send(`${this.name}.show`);
57 | this.browserWindow.show();
58 |
59 | return this;
60 | }
61 |
62 | hide() {
63 | this.browserWindow.webContents.send(`${this.name}.hide`);
64 | this.browserWindow.hide();
65 |
66 | return this;
67 | }
68 |
69 | toggleVisibility() {
70 | return this.isVisible() ? this.hide() : this.show();
71 | }
72 |
73 | isVisible() {
74 | return this.browserWindow.isVisible();
75 | }
76 |
77 | onClose(event) {
78 | this.hide();
79 |
80 | // By default all windows in Timestamp are hidden and not closed
81 | event.preventDefault();
82 | }
83 |
84 | getBrowserWindow() {
85 | return this.browserWindow;
86 | }
87 |
88 | getWebContents() {
89 | return this.browserWindow.webContents;
90 | }
91 |
92 | getContentSize() {
93 | return this.browserWindow.getContentSize();
94 | }
95 |
96 | setContentSize(width, height) {
97 | if (typeof width !== 'number' || typeof height !== 'number') {
98 | throw new Error('Window.setContentSize has been called with non-numeric arguments.');
99 | }
100 |
101 | this.browserWindow.setContentSize(width, height, true);
102 |
103 | return this;
104 | }
105 |
106 | getPosition() {
107 | return this.browserWindow.getPosition();
108 | }
109 |
110 | setPosition(x, y, centerToX = true) {
111 | this.browserWindow.setPosition(
112 | centerToX ? Math.round(x - (this.browserWindow.getSize()[0] / 2)) : x,
113 | y,
114 | );
115 |
116 | return this;
117 | }
118 | }
119 |
120 | module.exports = Window;
121 |
--------------------------------------------------------------------------------
/app/index.js:
--------------------------------------------------------------------------------
1 | const {
2 | app, screen, ipcMain, BrowserWindow,
3 | } = require('electron');
4 |
5 | const { arch, platform, release } = require('os');
6 | const { resolve } = require('path');
7 | const { parseInline } = require('marked');
8 |
9 | const Calendar = require('./components/Calendar');
10 | const Clock = require('./components/Clock');
11 | const Locale = require('./components/Locale');
12 | const Logger = require('./components/Logger');
13 | const Preferences = require('./components/Preferences');
14 | const SystemTray = require('./components/SystemTray');
15 | const Updater = require('./components/Updater');
16 | const { PREFERENCES_CHANGED } = require('./views/preferences/ipc');
17 | const { integratedBackgroundsDirectory } = require('./paths');
18 |
19 | const {
20 | APP_IS_PACKAGED,
21 | APP_QUIT,
22 | APP_RESIZE_WINDOW,
23 | APP_RESTART,
24 | APP_TICK,
25 | APP_TRANSLATE,
26 | APP_UPDATE_DOWNLOADED,
27 | } = require('./ipc');
28 |
29 | const defaultPreferences = {
30 | calendarBackground: resolve(integratedBackgroundsDirectory, 'gradient.svg'),
31 | calendarLegendFormat: 'MMMM y',
32 | calendarTodayFormat: 'EEEE,\ndo MMMM',
33 | clockFormat: 'PPPP',
34 | openAtLogin: false,
35 | };
36 |
37 | (async () => {
38 | await app.whenReady();
39 |
40 | app.dock.hide();
41 |
42 | return new class {
43 | constructor() {
44 | const currentVersion = app.getVersion();
45 |
46 | this.logger = new Logger();
47 |
48 | this.logger.debug(`Starting Timestamp v${currentVersion} on “${platform()}-${arch()} v${release()}”.`);
49 | this.logger.debug(`Running in ${app.isPackaged ? 'production' : 'development'} mode.`);
50 |
51 | if (app.isPackaged) {
52 | this.updater = new Updater({
53 | currentVersion,
54 | feedUrl: 'https://mzdr.github.io/timestamp/update.json',
55 | logger: this.logger,
56 | onUpdateDownloaded: this.onUpdateDownloaded.bind(this),
57 | });
58 | }
59 |
60 | this.locale = new Locale({
61 | logger: this.logger,
62 | preferred: app.getLocale(),
63 | });
64 |
65 | this.tray = new SystemTray({
66 | logger: this.logger,
67 | onClick: this.onTrayClicked.bind(this),
68 | });
69 |
70 | this.clock = new Clock({
71 | format: defaultPreferences.clockFormat,
72 | locale: this.locale,
73 | onTick: this.onTick.bind(this),
74 | });
75 |
76 | this.calendar = new Calendar({
77 | locale: this.locale,
78 | logger: this.logger,
79 | });
80 |
81 | this.preferences = new Preferences({
82 | defaults: defaultPreferences,
83 | logger: this.logger,
84 | onChange: this.onPreferencesChanged.bind(this),
85 | });
86 |
87 | ipcMain.handle(APP_IS_PACKAGED, () => app.isPackaged);
88 | ipcMain.handle(APP_TRANSLATE, this.onTranslate.bind(this));
89 | ipcMain.on(APP_QUIT, () => app.exit());
90 | ipcMain.on(APP_RESIZE_WINDOW, this.onResizeWindow.bind(this));
91 | ipcMain.on(APP_RESTART, this.onRestart.bind(this));
92 | }
93 |
94 | onRestart() {
95 | if (this.updater === undefined) {
96 | return this;
97 | }
98 |
99 | this.calendar.window.destroy();
100 | this.preferences.window.destroy();
101 | this.updater.quitAndInstall();
102 |
103 | return this;
104 | }
105 |
106 | onResizeWindow({ sender }, { width, height }) {
107 | const { calendar, preferences } = this;
108 | const window = BrowserWindow.fromWebContents(sender);
109 |
110 | [calendar, preferences]
111 | .find((view) => view.window.isSame(window))
112 | .window
113 | .setContentSize(width, height);
114 | }
115 |
116 | onPreferencesChanged(key, value) {
117 | if (key === 'openAtLogin') {
118 | app.setLoginItemSettings({ openAtLogin: value });
119 | } else if (key === 'clockFormat') {
120 | this.clock.setFormat(value);
121 | } else if (/^calendar/.test(key)) {
122 | this.calendar.window.getWebContents().send(PREFERENCES_CHANGED, key, value);
123 | }
124 |
125 | return this;
126 | }
127 |
128 | onTick(clock) {
129 | this.tray.setLabel(clock.toString());
130 | this.calendar?.window.getWebContents().send(APP_TICK, clock.now);
131 | }
132 |
133 | onTranslate(event, key, options = {}) {
134 | const { markdown = false } = options;
135 | const translation = this.locale.translate(key);
136 |
137 | if (markdown) {
138 | return parseInline(translation);
139 | }
140 |
141 | return translation;
142 | }
143 |
144 | onTrayClicked() {
145 | const { calendar, tray } = this;
146 | const bounds = tray.getBounds();
147 | const currentMousePosition = screen.getCursorScreenPoint();
148 | const currentDisplay = screen.getDisplayNearestPoint(currentMousePosition);
149 | const yOffset = 6;
150 |
151 | // Always center calendar window relative to tray icon
152 | calendar
153 | .window
154 | .setPosition(bounds.x + (bounds.width / 2), currentDisplay.workArea.y + yOffset)
155 | .toggleVisibility();
156 | }
157 |
158 | onUpdateDownloaded() {
159 | this.tray.setPrefix('→ ');
160 | this.preferences.window.getWebContents().send(APP_UPDATE_DOWNLOADED);
161 | this.calendar.window.getWebContents().send(APP_UPDATE_DOWNLOADED);
162 | }
163 | }();
164 | })();
165 |
--------------------------------------------------------------------------------
/app/ipc.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer } = require('electron');
2 | const { productName, version, copyright } = require('../package.json');
3 |
4 | const APP_IS_PACKAGED = 'app.is-packaged';
5 | const APP_QUIT = 'app.quit';
6 | const APP_RESIZE_WINDOW = 'app.resize-window';
7 | const APP_RESTART = 'app.restart';
8 | const APP_TICK = 'app.tick';
9 | const APP_TRANSLATE = 'app.translate';
10 | const APP_UPDATE_DOWNLOADED = 'app.update-downloaded';
11 |
12 | module.exports = {
13 | APP_IS_PACKAGED,
14 | APP_QUIT,
15 | APP_RESIZE_WINDOW,
16 | APP_RESTART,
17 | APP_TICK,
18 | APP_TRANSLATE,
19 | APP_UPDATE_DOWNLOADED,
20 |
21 | api: {
22 | productName,
23 | version,
24 | copyright,
25 |
26 | isPackaged: () => ipcRenderer.invoke(APP_IS_PACKAGED),
27 | on: (channel, fn) => ipcRenderer.on(`app.${channel}`, fn),
28 | quit: () => ipcRenderer.send(APP_QUIT),
29 | resizeWindow: (payload) => ipcRenderer.send(APP_RESIZE_WINDOW, payload),
30 | restart: () => ipcRenderer.send(APP_RESTART),
31 | translate: (key, options) => ipcRenderer.invoke(APP_TRANSLATE, key, options),
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/app/locales/de.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | name: 'Deutsch',
3 | locale: 'de',
4 | app: {
5 | restart: 'Neustarten',
6 | updateDownloaded: 'Eine neue Version von Timestamp wurde heruntergeladen. Bitte starten Sie die App neu, um sie zu aktualisieren.',
7 | },
8 | preferences: {
9 | category: {
10 | general: 'Generell',
11 | tray: 'System Tray',
12 | calendar: 'Kalender',
13 | shortcuts: 'Tastaturkürzel',
14 | quit: 'Beenden',
15 | },
16 | openAtLogin: {
17 | label: 'Autostart',
18 | description: 'Aktivieren Sie diese Option, wenn Sie möchten, dass Timestamp automatisch beim Starten des Computers gestartet werden soll.',
19 | },
20 | clockFormat: {
21 | label: 'Format der Uhr',
22 | description: 'Das [Format](https://date-fns.org/docs/format) der Uhrzeitanzeige im System-Tray.',
23 | },
24 | calendarBackground: {
25 | label: 'Hintergrund',
26 | description: 'Wählen Sie einen Kalenderhintergrund aus der Ihrem Geschmack entspricht.',
27 | },
28 | calendarLegendFormat: {
29 | label: 'Format der Legende',
30 | description: 'Das [Format](https://date-fns.org/docs/format) der Legende über dem Monat.',
31 | },
32 | calendarTodayFormat: {
33 | label: 'Format des aktuellen Tages',
34 | description: 'Das [Format](https://date-fns.org/docs/format) des aktuellen Tages welches im Kalenderkopf angezeigt wird.',
35 | },
36 | shortcuts: {
37 | description: 'Im Folgenden finden Sie eine vollständige Liste der Tastenkombinationen, die Sie im Kalenderfenster verwenden können.',
38 | keys: [
39 | ['W', 'Wochennummern anzeigen'],
40 | ['Leertaste', 'Aktuellen Tag anzeigen'],
41 | ],
42 | },
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/app/locales/en.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | name: 'English',
3 | locale: 'en',
4 | app: {
5 | restart: 'Restart',
6 | updateDownloaded: 'A new version of Timestamp has been downloaded. Please restart the app in order to update it.',
7 | },
8 | preferences: {
9 | category: {
10 | general: 'General',
11 | tray: 'System tray',
12 | calendar: 'Calendar',
13 | shortcuts: 'Shortcuts',
14 | quit: 'Quit',
15 | },
16 | openAtLogin: {
17 | label: 'Open at login',
18 | description: 'Enable this option if you want Timestamp to start automatically when you start your computer.',
19 | },
20 | clockFormat: {
21 | label: 'Clock format',
22 | description: 'The format [pattern](https://date-fns.org/docs/format) of the system tray clock.',
23 | },
24 | calendarBackground: {
25 | label: 'Background',
26 | description: 'Choose a calendar background that suits your personal liking.',
27 | },
28 | calendarLegendFormat: {
29 | label: 'Legend format',
30 | description: 'The format [pattern](https://date-fns.org/docs/format) of the legend above the month.',
31 | },
32 | calendarTodayFormat: {
33 | label: 'Today format',
34 | description: 'The format [pattern](https://date-fns.org/docs/format) of the today display in the calendar head.',
35 | },
36 | shortcuts: {
37 | description: 'See below for a complete list of keyboard shortcuts that you can use in the calendar window.',
38 | keys: [
39 | ['W', 'Toggle week numbers'],
40 | ['Space', 'Go to today'],
41 | ],
42 | },
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/app/locales/index.js:
--------------------------------------------------------------------------------
1 | const de = require('./de');
2 | const en = require('./en');
3 |
4 | module.exports = {
5 | de,
6 | en,
7 | };
8 |
--------------------------------------------------------------------------------
/app/paths.js:
--------------------------------------------------------------------------------
1 | const { app } = require('electron');
2 | const { resolve } = require('path');
3 |
4 | const storagePath = app.getPath('userData');
5 |
6 | module.exports = {
7 | integratedBackgroundsDirectory: resolve(__dirname, 'assets/backgrounds'),
8 | customBackgroundsDirectory: resolve(storagePath, 'Backgrounds'),
9 | logFile: resolve(storagePath, 'Output.log'),
10 | preferencesFile: resolve(storagePath, 'UserPreferences.json'),
11 | };
12 |
--------------------------------------------------------------------------------
/app/styles/components/button-primary.css:
--------------------------------------------------------------------------------
1 | .button-primary {
2 | background-blend-mode: color-burn;
3 | background-color: var(--color-brand);
4 | background-image: linear-gradient(180deg, #fff, #ccc);
5 | border-radius: 3px;
6 | border: 0;
7 | color: var(--palette-white);
8 | font-family: inherit;
9 | font-size: inherit;
10 | outline: 0;
11 | padding: 6px 12px;
12 | text-shadow: -1px 0 rgba(0, 0, 0, 0.1);
13 | }
14 |
--------------------------------------------------------------------------------
/app/styles/components/container-alert.css:
--------------------------------------------------------------------------------
1 | .container-alert {
2 | align-items: center;
3 | display: grid;
4 | grid-gap: var(--grid-gap);
5 | grid-template-areas: "icon message actions";
6 | overflow: hidden;
7 | padding: var(--grid-gap);
8 | }
9 |
10 | .container-alert > .icon {
11 | align-items: center;
12 | display: flex;
13 | font-size: calc(var(--type-size) * 2);
14 | grid-area: icon;
15 | justify-content: center;
16 | }
17 | .container-alert > .message { grid-area: message; }
18 | .container-alert > .actions { grid-area: actions; }
19 |
--------------------------------------------------------------------------------
/app/styles/components/form-group.css:
--------------------------------------------------------------------------------
1 | .form-group {
2 | display: grid;
3 | grid-gap: calc(var(--grid-gap) / 3) calc(var(--grid-gap) * 3);
4 | grid-template-areas: "label action" "description action";
5 | grid-template-columns: 1fr min-content;
6 | }
7 |
8 | .form-group > .label {
9 | align-self: center;
10 | grid-area: label;
11 | }
12 |
13 | .form-group > .description {
14 | color: var(--color-shy);
15 | font-size: var(--shy-size);
16 | grid-area: description;
17 | }
18 |
19 | .form-group > .action {
20 | align-self: flex-start;
21 | background-color: #fff;
22 | border-radius: 3px;
23 | border: 0;
24 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
25 | font: inherit;
26 | grid-area: action;
27 | margin: 0;
28 | outline: 0;
29 | padding: 6px 12px;
30 | }
31 |
32 | .form-group > .action.-text {
33 | color: var(--palette-black);
34 | resize: none;
35 | text-align: center;
36 | }
37 |
38 | .form-group > .action.-toggle {
39 | height: 20px;
40 | width: 20px;
41 | }
42 |
43 | .form-group > .action.-select {
44 | appearance: none;
45 | text-transform: capitalize;
46 | }
47 |
--------------------------------------------------------------------------------
/app/styles/components/fx-bounce.css:
--------------------------------------------------------------------------------
1 | @keyframes bounce {
2 | 0%, 20%, 53%, 80%, 100% {
3 | animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
4 | transform: translate3d(0, 0, 0);
5 | }
6 |
7 | 40%, 43% {
8 | animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
9 | transform: translate3d(0, -10px, 0);
10 | }
11 |
12 | 70% {
13 | animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
14 | transform: translate3d(0, -6px, 0);
15 | }
16 |
17 | 90% {
18 | transform: translate3d(0, -2px, 0);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/styles/components/icon-dots.css:
--------------------------------------------------------------------------------
1 | .icon-dots {
2 | display: block;
3 | height: 6px;
4 | overflow: visible;
5 | }
6 |
7 | .icon-dots > .dot {
8 | fill: currentColor;
9 | opacity: 0.5;
10 | }
11 |
12 | .icon-dots > .dot:nth-of-type(2) {
13 | animation-duration: 1.25s;
14 | animation-fill-mode: both;
15 | animation-iteration-count: infinite;
16 | animation-name: bounce;
17 | animation-play-state: paused;
18 | transform-origin: center bottom;
19 | }
20 |
--------------------------------------------------------------------------------
/app/styles/components/is-draggable.css:
--------------------------------------------------------------------------------
1 | .is-draggable {
2 | -webkit-app-region: drag;
3 | }
4 |
5 | .is-draggable.-not {
6 | -webkit-app-region: no-drag;
7 | }
8 |
--------------------------------------------------------------------------------
/app/styles/components/is-native.css:
--------------------------------------------------------------------------------
1 | .is-native {
2 | --palette-black: #272727;
3 | --palette-white: #f5f2f2;
4 |
5 | --color-line: #dcd9da;
6 | }
7 |
8 | @media (prefers-color-scheme: dark) {
9 | .is-native {
10 | --palette-black: #322d2c;
11 | --palette-white: #e0e0df;
12 |
13 | --color-paper: var(--palette-black);
14 | --color-pen: var(--palette-white);
15 | --color-line: #434041;
16 | }
17 | }
18 |
19 | .is-native.-transparent {
20 | background-color: transparent;
21 | }
22 |
--------------------------------------------------------------------------------
/app/styles/components/list-shortcuts.css:
--------------------------------------------------------------------------------
1 | .list-shortcuts {
2 | display: grid;
3 | grid-template-columns: 1fr 2fr;
4 | grid-gap: calc(var(--grid-gap) / 2) var(--grid-gap);
5 | }
6 |
7 | .list-shortcuts > .keys {
8 | align-items: center;
9 | display: grid;
10 | font-size: var(--shy-size);
11 | grid-auto-flow: column;
12 | grid-column-gap: calc(var(--grid-gap) / 2);
13 | justify-content: flex-start;
14 | }
15 |
16 | .list-shortcuts > .keys > .key {
17 | background-color: var(--palette-white);
18 | background-image: linear-gradient(to top, #e6e6e6, transparent);
19 | border-radius: 3px;
20 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
21 | color: var(--palette-black);
22 | min-width: 1.25ch;
23 | padding: 6px 12px;
24 | text-align: center;
25 | }
26 |
--------------------------------------------------------------------------------
/app/styles/meta/colors.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --palette-blue: #008bed;
3 | --palette-green: #0cbd00;
4 | --palette-black: #111;
5 | --palette-gray-20: #333;
6 | --palette-gray-40: #666;
7 | --palette-gray-60: #999;
8 | --palette-gray-80: #ccc;
9 | --palette-white: #fff;
10 |
11 | --color-brand: var(--palette-blue);
12 | --color-paper: var(--palette-white);
13 | --color-line: var(--palette-gray-80);
14 | --color-pen: var(--palette-black);
15 | --color-shy: var(--palette-gray-60);
16 | }
17 |
18 | @media (prefers-color-scheme: dark) {
19 | :root {
20 | --palette-white: #eee;
21 |
22 | --color-paper: var(--palette-black);
23 | --color-pen: var(--palette-white);
24 | --color-shy: var(--palette-gray-40);
25 | --color-line: var(--palette-gray-20);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/styles/meta/fx.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --fx-duration: 300ms;
3 | --fx-radius: 15px;
4 | }
5 |
--------------------------------------------------------------------------------
/app/styles/meta/grid.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --grid-gap: 22px;
3 | }
4 |
--------------------------------------------------------------------------------
/app/styles/meta/typography.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --type-family: system-ui;
3 | --type-rendering: optimizeLegibility;
4 | --type-rhythm: 1.5;
5 | --type-sentence: 60ch;
6 | --type-size: 14px;
7 |
8 | --shy-size: 12px;
9 | }
10 |
--------------------------------------------------------------------------------
/app/styles/shared/base.css:
--------------------------------------------------------------------------------
1 | :root {
2 | background-color: var(--color-paper);
3 | box-sizing: border-box;
4 | color: var(--color-pen);
5 | cursor: default;
6 | }
7 |
8 | *,
9 | *::before,
10 | *::after {
11 | box-sizing: inherit;
12 | }
13 |
14 | ::-webkit-scrollbar {
15 | display: none;
16 | }
17 |
18 | body {
19 | margin: 0;
20 | }
21 |
22 | a {
23 | color: var(--color-brand);
24 | }
25 |
--------------------------------------------------------------------------------
/app/styles/shared/typography.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: var(--type-family);
3 | font-size: var(--type-size);
4 | line-height: var(--type-rhythm);
5 | text-rendering: var(--type-rendering);
6 | }
7 |
--------------------------------------------------------------------------------
/app/styles/styles.css:
--------------------------------------------------------------------------------
1 | @import "meta/colors.css";
2 | @import "meta/fx.css";
3 | @import "meta/grid.css";
4 | @import "meta/typography.css";
5 | @import "shared/base.css";
6 | @import "shared/typography.css";
7 | @import "components/button-primary.css";
8 | @import "components/container-alert.css";
9 | @import "components/form-group.css";
10 | @import "components/fx-bounce.css";
11 | @import "components/icon-dots.css";
12 | @import "components/is-draggable.css";
13 | @import "components/is-native.css";
14 | @import "components/list-shortcuts.css";
15 |
--------------------------------------------------------------------------------
/app/views/calendar/calendar.css:
--------------------------------------------------------------------------------
1 | :root {
2 | width: 338px;
3 | }
4 |
5 | :root.update-downloaded .icon-dots > .dot:nth-of-type(2) {
6 | animation-play-state: running;
7 | }
8 |
9 | :root:not(.show-navigation) .calendar-view > .navigation {
10 | display: none;
11 | }
12 |
13 | .calendar-view {
14 | display: grid;
15 | position: relative;
16 | user-select: none;
17 | }
18 |
19 | .calendar-view > * {
20 | grid-column: 1;
21 | }
22 |
23 | .calendar-view > .head {
24 | grid-row: 1;
25 | }
26 |
27 | .calendar-view > .head > .today {
28 | display: block;
29 | font-size: calc(var(--type-size) * 2.5);
30 | font-weight: 100;
31 | padding: calc(var(--grid-gap) * 2.5) var(--grid-gap);
32 | text-align: center;
33 | white-space: pre;
34 | }
35 |
36 | .calendar-view > .head > .preferences {
37 | box-sizing: content-box;
38 | padding: 18px 12px;
39 | position: absolute;
40 | right: 12px;
41 | top: 6px;
42 | }
43 |
44 | .calendar-view > .legend {
45 | align-items: center;
46 | color: var(--color-shy);
47 | display: grid;
48 | grid-column-gap: 20px;
49 | grid-row: 2;
50 | grid-template-columns: 50px minmax(60px, max-content) 50px;
51 | justify-content: center;
52 | padding: 14px var(--grid-gap);
53 | text-align: center;
54 | white-space: pre;
55 | }
56 |
57 | .calendar-view > .legend::before,
58 | .calendar-view > .legend::after {
59 | content: "";
60 | height: 1px;
61 | }
62 |
63 | .calendar-view > .legend::before {
64 | background-image: linear-gradient(to left, var(--color-line), transparent);
65 | }
66 |
67 | .calendar-view > .legend::after {
68 | background-image: linear-gradient(to right, var(--color-line), transparent);
69 | }
70 |
71 | .calendar-view > .days {
72 | aspect-ratio: 7 / 6;
73 | box-sizing: content-box;
74 | grid-row: 3;
75 | padding: 0 var(--grid-gap) var(--grid-gap);
76 | }
77 |
78 | .calendar-view > .navigation {
79 | align-content: center;
80 | backdrop-filter: blur(20px);
81 | background-color: rgba(255, 255, 255, 0.7);
82 | display: grid;
83 | grid-row-gap: var(--grid-gap);
84 | grid-row: 1 / 3;
85 | grid-template-columns: repeat(3, 1fr);
86 | padding: var(--grid-gap);
87 | width: 100%;
88 | }
89 |
90 | .go-to {
91 | align-items: center;
92 | background-color: transparent;
93 | border-radius: 1em;
94 | border: 0;
95 | color: currentColor;
96 | display: flex;
97 | font-family: var(--type-family);
98 | font-size: calc(var(--type-size) * 1.5);
99 | font-weight: 200;
100 | justify-content: center;
101 | line-height: calc(var(--type-rhythm) * 1.5);
102 | padding: 0;
103 | }
104 |
105 | .go-to.-current,
106 | .go-to.-year {
107 | font-weight: 400;
108 | }
109 |
110 | .go-to.-current {
111 | box-shadow: inset 0 0 0 1px currentColor;
112 | }
113 |
114 | .icon-chevron {
115 | opacity: 0.6;
116 | width: 16px;
117 | }
118 |
119 | @media (prefers-color-scheme: dark) {
120 | .calendar-view > .navigation {
121 | background-color: rgba(17, 17, 17, 0.5);
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/app/views/calendar/calendar.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
18 |
19 |
20 |
21 |
22 |
23 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/app/views/calendar/ipc.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer } = require('electron');
2 |
3 | const CALENDAR_GET_CALENDAR = 'calendar.get-calendar';
4 | const CALENDAR_GET_DATE = 'calendar.get-date';
5 | const CALENDAR_GET_WEEKDAYS = 'calendar.get-weekdays';
6 | const CALENDAR_HIDE = 'calendar.hide';
7 | const CALENDAR_SHOW = 'calendar.show';
8 |
9 | module.exports = {
10 | CALENDAR_GET_CALENDAR,
11 | CALENDAR_GET_DATE,
12 | CALENDAR_GET_WEEKDAYS,
13 | CALENDAR_HIDE,
14 | CALENDAR_SHOW,
15 |
16 | api: {
17 | getCalendar: (payload) => ipcRenderer.invoke(CALENDAR_GET_CALENDAR, payload),
18 | getDate: (payload) => ipcRenderer.invoke(CALENDAR_GET_DATE, payload),
19 | getWeekdays: () => ipcRenderer.invoke(CALENDAR_GET_WEEKDAYS),
20 | hide: () => ipcRenderer.send(CALENDAR_HIDE),
21 | on: (channel, fn) => ipcRenderer.on(`calendar.${channel}`, fn),
22 | show: () => ipcRenderer.send(CALENDAR_SHOW),
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/app/views/calendar/preload.js:
--------------------------------------------------------------------------------
1 | const { contextBridge } = require('electron');
2 |
3 | const { api: app } = require('../../ipc');
4 | const { api: preferences } = require('../preferences/ipc');
5 | const { api: calendar } = require('./ipc');
6 |
7 | contextBridge.exposeInMainWorld('app', app);
8 | contextBridge.exposeInMainWorld('calendar', calendar);
9 | contextBridge.exposeInMainWorld('preferences', preferences);
10 |
--------------------------------------------------------------------------------
/app/views/calendar/renderer.js:
--------------------------------------------------------------------------------
1 | import {
2 | bindAttributes,
3 | bindEventListeners,
4 | Calendar,
5 | define,
6 | findReferences,
7 | } from '@browserkids/web-components';
8 |
9 | define(Calendar);
10 |
11 | window.renderer = new class Renderer {
12 | #isPackaged = null;
13 |
14 | #resize = null;
15 |
16 | constructor({ app, calendar, preferences }) {
17 | this.$root = document.documentElement;
18 | this.$refs = findReferences(this.$root);
19 |
20 | this.app = app;
21 | this.calendar = calendar;
22 | this.preferences = preferences;
23 |
24 | this.data = bindAttributes(this.$root, {
25 | hour: '',
26 | legend: '',
27 | month: '',
28 | rootClasses: [],
29 | source: '',
30 | today: '',
31 | });
32 |
33 | bindEventListeners(this.$root, this);
34 |
35 | this.app.on('tick', this.onTick.bind(this));
36 | this.app.on('update-downloaded', this.onUpdateDownloaded.bind(this));
37 | this.calendar.on('hide', this.onHide.bind(this));
38 | this.preferences.on('changed', this.onPreferencesChanged.bind(this));
39 |
40 | this
41 | .createObserver({ resize: true })
42 | .render();
43 | }
44 |
45 | createObserver(settings = {}) {
46 | const { resize = false } = settings;
47 |
48 | if (resize) {
49 | this.#resize = new ResizeObserver(this.onResize.bind(this));
50 | this.#resize.observe(this.$root);
51 | }
52 |
53 | return this;
54 | }
55 |
56 | goPreviousYear() {
57 | return this;
58 | }
59 |
60 | goNextYear() {
61 | return this;
62 | }
63 |
64 | goMonth() {
65 | return this;
66 | }
67 |
68 | onLegendClicked() {
69 | return this;
70 | }
71 |
72 | onCalendarUpdate() {
73 | return this;
74 | }
75 |
76 | onPreferencesChanged(event, key) {
77 | if (key === 'calendarTodayFormat') {
78 | this.setToday();
79 | } else if (key === 'calendarBackground') {
80 | this.setHeadBackground();
81 | }
82 | }
83 |
84 | onHide() {
85 | this.onTodayClicked();
86 | }
87 |
88 | async onKeyDown(event) {
89 | const { key, metaKey } = event;
90 |
91 | if (key === 'Escape') {
92 | this.calendar.hide();
93 | } else if (key === ',' && metaKey) {
94 | this.preferences.show();
95 | } else if (key === 'q' && metaKey) {
96 | this.app.quit();
97 | } else if (key === ' ') {
98 | this.onTodayClicked();
99 | }
100 |
101 | // In general prevent any default browser shortcuts in production
102 | if (await this.isPackaged) {
103 | event.preventDefault();
104 | }
105 | }
106 |
107 | onResize() {
108 | this.app.resizeWindow({
109 | height: this.$root.offsetHeight,
110 | width: this.$root.offsetWidth,
111 | });
112 | }
113 |
114 | onShowPreferences() {
115 | this.preferences.show();
116 | }
117 |
118 | onTick(event, now) {
119 | Object.assign(this.data, {
120 | hour: now.getHours(),
121 | month: now.getMonth(),
122 | });
123 |
124 | this.setToday();
125 | }
126 |
127 | onTodayClicked() {
128 | return this;
129 | }
130 |
131 | onUpdateDownloaded() {
132 | this.data.rootClasses = ['update-downloaded'];
133 | }
134 |
135 | async setHeadBackground() {
136 | this.data.source = await this.preferences.getBackgroundFileContents(
137 | await this.preferences.get('calendarBackground'),
138 | );
139 | }
140 |
141 | async setToday() {
142 | const format = await this.preferences.get('calendarTodayFormat');
143 | const today = await this.calendar.getDate({ format: format.replace(/\n/g, '\'
\'') });
144 |
145 | if (today === this.data.today) {
146 | return;
147 | }
148 |
149 | this.data.today = today;
150 | }
151 |
152 | render() {
153 | this.setToday();
154 | this.setHeadBackground();
155 | }
156 |
157 | get isPackaged() {
158 | return (async () => {
159 | if (this.#isPackaged === null) {
160 | this.#isPackaged = await this.app.isPackaged();
161 | }
162 |
163 | return this.#isPackaged;
164 | })();
165 | }
166 | }(window);
167 |
--------------------------------------------------------------------------------
/app/views/common/dynamic-image.js:
--------------------------------------------------------------------------------
1 | import { define } from '@browserkids/web-components';
2 |
3 | define(class DynamicImage extends HTMLElement {
4 | template = `
5 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | `;
31 |
32 | data = {
33 | source: '',
34 | };
35 |
36 | set source(value) {
37 | if (this.data.source === value) {
38 | return;
39 | }
40 |
41 | this.data.source = value;
42 | }
43 |
44 | get source() {
45 | return this.data.source;
46 | }
47 |
48 | get hour() {
49 | if (this.hasAttribute('hour')) {
50 | return parseInt(this.getAttribute('hour'), 10);
51 | }
52 |
53 | return null;
54 | }
55 |
56 | set hour(value) {
57 | this.setAttribute('hour', value);
58 | }
59 |
60 | get month() {
61 | if (this.hasAttribute('month')) {
62 | return parseInt(this.getAttribute('month'), 10);
63 | }
64 |
65 | return null;
66 | }
67 |
68 | set month(value) {
69 | this.setAttribute('month', value);
70 | }
71 | });
72 |
--------------------------------------------------------------------------------
/app/views/common/translation-key.js:
--------------------------------------------------------------------------------
1 | customElements.define('translation-key', class TranslationKey extends HTMLElement {
2 | #key;
3 |
4 | constructor() {
5 | super();
6 |
7 | this.#key = this.textContent;
8 | this.render();
9 | }
10 |
11 | async render() {
12 | const { app } = window;
13 |
14 | if (this.hasAttribute('markdown')) {
15 | this.innerHTML = await app?.translate(this.#key, { markdown: true });
16 | } else {
17 | this.textContent = await app?.translate(this.#key);
18 | }
19 | }
20 | });
21 |
--------------------------------------------------------------------------------
/app/views/preferences/ipc.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer } = require('electron');
2 |
3 | const PREFERENCES_CHANGED = 'preferences.changed';
4 | const PREFERENCES_GET = 'preferences.get';
5 | const PREFERENCES_GET_ALL = 'preferences.getAll';
6 | const PREFERENCES_GET_BACKGROUND_FILE_CONTENTS = 'preferences.get-background-file-contents';
7 | const PREFERENCES_GET_BACKGROUNDS = 'preferences.get-backgrounds';
8 | const PREFERENCES_HIDE = 'preferences.hide';
9 | const PREFERENCES_SET = 'preferences.set';
10 | const PREFERENCES_SHOW = 'preferences.show';
11 |
12 | module.exports = {
13 | PREFERENCES_CHANGED,
14 | PREFERENCES_GET,
15 | PREFERENCES_GET_ALL,
16 | PREFERENCES_GET_BACKGROUND_FILE_CONTENTS,
17 | PREFERENCES_GET_BACKGROUNDS,
18 | PREFERENCES_HIDE,
19 | PREFERENCES_SET,
20 | PREFERENCES_SHOW,
21 |
22 | api: {
23 | get: (key) => ipcRenderer.invoke(PREFERENCES_GET, key),
24 | getAll: () => ipcRenderer.invoke(PREFERENCES_GET_ALL),
25 | getBackgroundFileContents: (payload) => ipcRenderer.invoke(PREFERENCES_GET_BACKGROUND_FILE_CONTENTS, payload),
26 | getBackgrounds: () => ipcRenderer.invoke(PREFERENCES_GET_BACKGROUNDS),
27 | hide: () => ipcRenderer.send(PREFERENCES_HIDE),
28 | on: (channel, fn) => ipcRenderer.on(`preferences.${channel}`, fn),
29 | set: (key, value) => ipcRenderer.send(PREFERENCES_SET, key, value),
30 | show: () => ipcRenderer.send(PREFERENCES_SHOW),
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/app/views/preferences/preferences.css:
--------------------------------------------------------------------------------
1 | :root {
2 | user-select: none;
3 | width: 720px;
4 | }
5 |
6 | .preferences-view {
7 | display: grid;
8 | grid-template-areas: "side alert" "side contents";
9 | grid-template-columns: 192px 1fr;
10 | grid-template-rows: min-content 1fr;
11 | }
12 |
13 | .preferences-view > .side { grid-area: side; }
14 | .preferences-view > .alert { grid-area: alert; }
15 | .preferences-view > .contents { grid-area: contents; }
16 |
17 | .preferences-view > .alert,
18 | .preferences-view > .contents {
19 | background-color: var(--color-paper);
20 | }
21 |
22 | .preferences-view > .alert {
23 | border-bottom: 1px solid var(--color-line);
24 | }
25 |
26 | .preferences-view > .alert.-hidden {
27 | display: none;
28 | }
29 |
30 | .preferences-side {
31 | box-shadow: inset -1px 0 0 var(--color-line);
32 | display: grid;
33 | grid-template-rows: repeat(3, min-content);
34 | padding: var(--grid-gap);
35 | }
36 |
37 | .preferences-side > .logo {
38 | margin: calc(var(--grid-gap) * 2) auto var(--grid-gap);
39 | width: 128px;
40 | }
41 |
42 | .preferences-side > .name {
43 | font-size: calc(var(--type-size) * 1.5);
44 | font-weight: 300;
45 | margin: 0;
46 | text-align: center;
47 | }
48 |
49 | .preferences-side > .navigation {
50 | margin: calc(var(--grid-gap) * 2) 0;
51 | }
52 |
53 | .preferences-side > .about {
54 | color: var(--color-shy);
55 | font-size: var(--shy-size);
56 | text-align: center;
57 | }
58 |
59 | .preferences-navigation {
60 | display: grid;
61 | grid-row-gap: calc(var(--grid-gap) / 3);
62 | }
63 |
64 | .preferences-navigation > .item {
65 | align-items: center;
66 | appearance: none;
67 | background: transparent;
68 | border: 0;
69 | color: currentColor;
70 | display: grid;
71 | font: inherit;
72 | grid-column-gap: calc(var(--grid-gap) / 2);
73 | grid-template-columns: min-content 1fr;
74 | outline: 0;
75 | padding: 4px 8px;
76 | position: relative;
77 | text-align: left;
78 | }
79 |
80 | .preferences-navigation > .item.-active::before {
81 | background-color: var(--color-pen);
82 | border-radius: 5px;
83 | content: "";
84 | height: 100%;
85 | left: 0;
86 | opacity: 0.14;
87 | position: absolute;
88 | top: 0;
89 | width: 100%;
90 | z-index: -1;
91 | }
92 |
93 | .preferences-navigation > .item > .icon {
94 | color: currentColor;
95 | height: 15px;
96 | opacity: 0.5;
97 | stroke-width: 1.5;
98 | width: 15px;
99 | }
100 |
101 | .preferences-contents {
102 | display: grid;
103 | }
104 |
105 | .preferences-contents > .content {
106 | align-content: flex-start;
107 | display: grid;
108 | grid-area: 1 / 1 / -1 / -1;
109 | grid-row-gap: var(--grid-gap);
110 | padding: calc(var(--grid-gap) * 2);
111 | }
112 |
113 | .preferences-contents > .content:not(.-active) {
114 | display: none;
115 | }
116 |
117 | .preferences-contents > .content > .description {
118 | color: var(--color-shy);
119 | }
120 |
--------------------------------------------------------------------------------
/app/views/preferences/preferences.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
38 |
39 |
40 | 🎉
41 | app.updateDownloaded
42 |
43 |
46 |
47 |
48 |
49 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/app/views/preferences/preload.js:
--------------------------------------------------------------------------------
1 | const { contextBridge } = require('electron');
2 |
3 | const { api: app } = require('../../ipc');
4 | const { api: preferences } = require('./ipc');
5 |
6 | contextBridge.exposeInMainWorld('app', app);
7 | contextBridge.exposeInMainWorld('preferences', preferences);
8 |
--------------------------------------------------------------------------------
/app/views/preferences/renderer.js:
--------------------------------------------------------------------------------
1 | import { bindAttributes, bindEventListeners, findReferences } from '@browserkids/web-components';
2 |
3 | window.renderer = new class Renderer {
4 | #isPackaged = null;
5 |
6 | #resize = null;
7 |
8 | constructor({ app, preferences }) {
9 | this.$root = document.documentElement;
10 | this.$refs = findReferences(this.$root);
11 | this.app = app;
12 | this.preferences = preferences;
13 |
14 | bindEventListeners(this.$root, this);
15 |
16 | bindAttributes(this.$root, {
17 | productName: this.app.productName,
18 | version: this.app.version,
19 | });
20 |
21 | this.app.on('update-downloaded', this.onUpdateDownloaded.bind(this));
22 |
23 | this
24 | .createObserver({ resize: true })
25 | .render();
26 | }
27 |
28 | createObserver(settings = {}) {
29 | const { resize = false } = settings;
30 |
31 | if (resize) {
32 | this.#resize = new ResizeObserver(this.onResize.bind(this));
33 | this.#resize.observe(this.$root);
34 | }
35 |
36 | return this;
37 | }
38 |
39 | onCategoryClicked({ currentTarget }) {
40 | const { $content, $tab } = this.$refs;
41 | const index = $tab.indexOf(currentTarget);
42 | const toggleActive = ($el, position) => $el.classList.toggle('-active', index === position);
43 |
44 | [$tab, $content].forEach(($el) => $el.forEach(toggleActive));
45 | }
46 |
47 | onInput({ target }) {
48 | const {
49 | name,
50 | value,
51 | type,
52 | checked,
53 | } = target;
54 |
55 | const isBoolean = ['on', 'off'].indexOf(value) >= 0 && ['checkbox', 'radio'].indexOf(type) >= 0;
56 |
57 | this.preferences.set(name, isBoolean ? checked : value);
58 | }
59 |
60 | async onKeyDown(event) {
61 | const { key } = event;
62 |
63 | if (key === 'Escape') {
64 | this.preferences.hide();
65 | }
66 |
67 | // In general prevent any default browser shortcuts in production
68 | if (await this.isPackaged) {
69 | event.preventDefault();
70 | }
71 | }
72 |
73 | onQuitClicked() {
74 | this.app.quit();
75 | }
76 |
77 | onResize() {
78 | this.app.resizeWindow({
79 | height: this.$root.offsetHeight,
80 | width: this.$root.offsetWidth,
81 | });
82 | }
83 |
84 | onRestartClicked() {
85 | this.app.restart();
86 | }
87 |
88 | onUpdateDownloaded() {
89 | this.$refs.$alert.classList.remove('-hidden');
90 | }
91 |
92 | async render() {
93 | const {
94 | $backgrounds,
95 | $keys,
96 | $form,
97 | $tab,
98 | } = this.$refs;
99 |
100 | const all = await this.preferences.getAll();
101 | const backgrounds = await this.preferences.getBackgrounds();
102 | const shortcuts = await this.app.translate('preferences.shortcuts.keys');
103 |
104 | backgrounds
105 | .map((background) => ([background, background.split('/').pop().split('.').shift()]))
106 | .map(([value, name]) => ``)
107 | .forEach((background) => $backgrounds.insertAdjacentHTML('beforeend', background));
108 |
109 | shortcuts
110 | .map(([keys, label]) => ([keys.split('+').map((key) => `${key}`).join('+'), label]))
111 | .map(([keys, label]) => `${keys}${label}`)
112 | .forEach((shortcut) => $keys.insertAdjacentHTML('beforeend', shortcut));
113 |
114 | Array
115 | .from(all)
116 | .filter(([key]) => $form[key])
117 | .forEach(([key, value]) => {
118 | $form[key][typeof value === 'boolean' ? 'checked' : 'value'] = value;
119 | });
120 |
121 | this.onCategoryClicked({ currentTarget: $tab[0] });
122 | }
123 |
124 | get isPackaged() {
125 | return (async () => {
126 | if (this.#isPackaged === null) {
127 | this.#isPackaged = await this.app.isPackaged();
128 | }
129 |
130 | return this.#isPackaged;
131 | })();
132 | }
133 | }(window);
134 |
--------------------------------------------------------------------------------
/build/entitlements.mac.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.cs.allow-jit
6 |
7 | com.apple.security.cs.allow-unsigned-executable-memory
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/build/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mzdr/timestamp/c881044cda49b7fef4b91ef3ffa20e6bdb32a2d3/build/icon.icns
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "timestamp",
3 | "productName": "Timestamp",
4 | "description": "A better macOS menu bar clock with a customizable date/time display and a calendar.",
5 | "version": "1.1.0",
6 | "author": "Sebastian Prein ",
7 | "copyright": "© 2021 Sebastian Prein",
8 | "homepage": "https://github.com/mzdr/timestamp",
9 | "license": "MIT",
10 | "main": "app/index.js",
11 | "keywords": [
12 | "calendar",
13 | "clock",
14 | "customizable",
15 | "date",
16 | "electron",
17 | "macos",
18 | "menubar",
19 | "time",
20 | "timestamp"
21 | ],
22 | "build": {
23 | "appId": "com.mzdr.timestamp",
24 | "files": [
25 | "app/**/*",
26 | "node_modules/**/*",
27 | "package.json"
28 | ],
29 | "mac": {
30 | "category": "public.app-category.utilities",
31 | "hardenedRuntime": true,
32 | "gatekeeperAssess": false,
33 | "entitlements": "build/entitlements.mac.plist",
34 | "entitlementsInherit": "build/entitlements.mac.plist",
35 | "darkModeSupport": true
36 | },
37 | "afterSign": "scripts/notarize.js"
38 | },
39 | "private": true,
40 | "scripts": {
41 | "build": "electron-builder",
42 | "clean": "rimraf dist",
43 | "lint": "eslint ./app",
44 | "lint:fix": "npm run lint -- --fix",
45 | "prebuild": "npm run lint && npm run clean",
46 | "start": "electron ./app --enable-logging",
47 | "test": "npm run lint"
48 | },
49 | "dependencies": {
50 | "@browserkids/web-components": "^0.8.0",
51 | "date-fns": "^2.28.0",
52 | "marked": "^4.0.8",
53 | "semver": "^7.3.5"
54 | },
55 | "devDependencies": {
56 | "dotenv": "^10.0.0",
57 | "electron": "^16.0.5",
58 | "electron-builder": "^22.14.5",
59 | "electron-notarize": "^1.1.1",
60 | "eslint": "^8.6.0",
61 | "eslint-config-airbnb-base": "^15.0.0",
62 | "eslint-plugin-import": "^2.25.3",
63 | "rimraf": "^3.0.2"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/scripts/notarize.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | require('dotenv').config();
3 |
4 | // eslint-disable-next-line import/no-extraneous-dependencies
5 | const { notarize } = require('electron-notarize');
6 | const { build } = require('../package.json');
7 |
8 | exports.default = async function notarizing(context) {
9 | const { electronPlatformName, appOutDir, packager } = context;
10 | const isUnpacked = process.argv.includes('--dir');
11 | const isMacOs = electronPlatformName === 'darwin';
12 |
13 | if (isUnpacked || isMacOs === false) {
14 | return;
15 | }
16 |
17 | const appBundleId = build.appId;
18 | const appPath = `${appOutDir}/${packager.appInfo.productFilename}.app`;
19 | const appleId = process.env.APPLE_ID;
20 | const appleIdPassword = process.env.APPLE_ID_PASSWORD;
21 |
22 | await notarize({
23 | appBundleId,
24 | appPath,
25 | appleId,
26 | appleIdPassword,
27 | });
28 | };
29 |
--------------------------------------------------------------------------------