Skip to content

Commit 9072df7

Browse files
committed
feat: add backup/restore config and global LoadingOverlay
- Added backup & restore config via pog.json (with file upload support). - Made LoadingOverlay a global component for consistent state handling. - Refactored KmkInstaller to use BaseModal for update/restore confirmations. - Cleaned up deprecated loading logic in KeyboardConfigurator. - Updated SetupWizard to handle completion events from KmkInstaller.
1 parent 730e6d4 commit 9072df7

File tree

8 files changed

+328
-84
lines changed

8 files changed

+328
-84
lines changed

src/renderer/src/App.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted } from 'vue'
33
import { addToHistory, keyboardStore, notifications, serialKeyboards } from './store'
44
import { addSerialLine } from './store/serial'
55
import { useRouter } from 'vue-router'
6+
import LoadingOverlay from './components/LoadingOverlay.vue'
67
const router = useRouter()
78
const store = computed(() => {
89
return keyboardStore
@@ -69,6 +70,7 @@ onUnmounted(() => {
6970
</div>
7071
</div>
7172
<router-view></router-view>
73+
<LoadingOverlay />
7274
</template>
7375
<style lang="scss">
7476
html,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<template>
2+
<Transition
3+
enter-active-class="transition duration-300 ease-out"
4+
enter-from-class="opacity-0"
5+
enter-to-class="opacity-100"
6+
leave-active-class="transition duration-200 ease-in"
7+
leave-from-class="opacity-100"
8+
leave-to-class="opacity-0"
9+
>
10+
<div
11+
v-if="props.open"
12+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
13+
@click.self="$emit('close')"
14+
>
15+
<div
16+
class="relative w-full max-w-md rounded-2xl border border-gray-200/20 bg-base-100/80 p-8 shadow-2xl backdrop-blur-md"
17+
>
18+
<h3 v-if="props.title" class="mb-2 text-xl font-semibold">{{ props.title }}</h3>
19+
<div class="py-2">
20+
<slot />
21+
</div>
22+
<div class="mt-6 flex items-center justify-end gap-2">
23+
<button class="btn justify-self-start" @click="$emit('close')">
24+
{{ props.cancelText }}
25+
</button>
26+
<button
27+
v-if="props.secondaryText"
28+
class="btn btn-primary justify-self-center"
29+
@click="$emit('secondary')"
30+
>
31+
{{ props.secondaryText }}
32+
</button>
33+
<div v-else></div>
34+
<button
35+
v-if="props.showConfirm && props.confirmText"
36+
class="btn btn-primary justify-self-end"
37+
@click="$emit('confirm')"
38+
>
39+
{{ props.confirmText }}
40+
</button>
41+
</div>
42+
</div>
43+
</div>
44+
</Transition>
45+
</template>
46+
47+
<script setup lang="ts">
48+
interface Props {
49+
open: boolean
50+
title?: string
51+
confirmText?: string
52+
cancelText?: string
53+
secondaryText?: string
54+
showConfirm?: boolean
55+
}
56+
57+
const props = withDefaults(defineProps<Props>(), {
58+
title: '',
59+
confirmText: '',
60+
cancelText: 'Cancel',
61+
secondaryText: '',
62+
showConfirm: true
63+
})
64+
65+
defineEmits<{
66+
close: []
67+
confirm: []
68+
secondary: []
69+
}>()
70+
</script>

src/renderer/src/components/KmkInstaller.vue

Lines changed: 134 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,35 @@
11
<template>
2-
<dialog id="update_modal" class="modal">
3-
<div class="modal-box">
4-
<h3 class="text-lg font-bold">Attention</h3>
5-
<p class="py-4">
6-
Updating the POG files will overwrite all files on your keyboard generated by POG (e.g.
7-
kb.py, code.py, customkeys.py, etc.)
8-
</p>
9-
<p class="py-4">Be sure to backup your code if you still need any of it.</p>
10-
<div class="flex justify-between">
11-
<div class="btn" @click="closeModal">Abort</div>
12-
<div class="btn btn-error" @click="updatePOG">Update POG files</div>
13-
</div>
14-
</div>
15-
<form method="dialog" class="modal-backdrop">
16-
<button>close</button>
17-
</form>
18-
</dialog>
2+
<BaseModal
3+
:open="isUpdateOpen"
4+
title="Attention"
5+
confirm-text="Update POG files"
6+
cancel-text="Abort"
7+
@close="isUpdateOpen = false"
8+
@confirm="updatePOG"
9+
>
10+
<p>
11+
Updating the POG files will overwrite all files on your keyboard generated by POG (e.g. kb.py,
12+
code.py, customkeys.py, etc.)
13+
</p>
14+
<p class="pt-2">Be sure to backup your code if you still need any of it.</p>
15+
</BaseModal>
16+
17+
<BaseModal
18+
:open="isRestoreOpen"
19+
title="Restore Configuration"
20+
cancel-text="Cancel"
21+
secondary-text="Keep ID"
22+
confirm-text="Generate New ID"
23+
@close="isRestoreOpen = false"
24+
@secondary="restoreConfig(false)"
25+
@confirm="restoreConfig(true)"
26+
>
27+
<p>
28+
Do you want to generate new ID for the restored configuration? This is only recommended if you
29+
are restoring a configuration from another keyboard.
30+
</p>
31+
</BaseModal>
32+
1933
<div class="mt-4 p-4 text-left">
2034
<p>
2135
<a href="https://kmkfw.io/" target="_blank" class="link">KMK</a> is a capable firmware for
@@ -52,13 +66,37 @@
5266
v-if="['', 'done'].includes(kmkInstallState)"
5367
class="mt-8 flex flex-col items-center justify-center"
5468
>
55-
<div class="mt-8 grid grid-cols-2 justify-center gap-4">
69+
<div
70+
class="mt-8 grid justify-center gap-4"
71+
:class="{
72+
'grid-cols-1': !keyboardStore.firmwareInstalled,
73+
'grid-cols-2': keyboardStore.firmwareInstalled
74+
}"
75+
>
5676
<button class="btn btn-primary" @click="updateKMK">
5777
{{ keyboardStore.firmwareInstalled ? 'update' : 'install' }} KMK
5878
</button>
59-
<button class="btn btn-primary" @click="openModal">update Firmware</button>
79+
<button v-if="!initialSetup" class="btn btn-primary" @click="isUpdateOpen = true">
80+
update Firmware
81+
</button>
82+
<button v-if="!initialSetup" class="btn btn-primary" @click="backupConfiguration">
83+
Backup Config
84+
</button>
85+
<button
86+
v-if="!initialSetup || keyboardStore.firmwareInstalled"
87+
class="btn btn-primary"
88+
@click="restoreConfiguration"
89+
>
90+
Restore Config
91+
</button>
6092
</div>
61-
<button class="btn btn-primary mt-8" @click="updateKMK">install KMK</button>
93+
<input
94+
ref="fileInput"
95+
type="file"
96+
accept=".json"
97+
style="display: none"
98+
@change="handleFileUpload"
99+
/>
62100
</div>
63101
<div v-if="initialSetup" class="mt-8 flex justify-center">
64102
<button
@@ -68,7 +106,6 @@
68106
>
69107
Next
70108
</button>
71-
<button class="btn mt-4 block" @click="$emit('next')">I installed KMK manually</button>
72109
</div>
73110

74111
<div
@@ -84,14 +121,21 @@
84121
</template>
85122
<script setup lang="ts">
86123
import { ref } from 'vue'
87-
import { keyboardStore } from '../store'
124+
import BaseModal from './BaseModal.vue'
125+
import { keyboardStore, addToHistory } from '../store'
88126
import dayjs from 'dayjs'
127+
import { ulid } from 'ulid'
128+
import { saveConfigurationWithLoading } from '../helpers/saveConfigurationWrapper'
89129
90130
const progress = ref(0)
91131
const kmkInstallState = ref('')
132+
const isUpdateOpen = ref(false)
133+
const isRestoreOpen = ref(false)
134+
const fileInput = ref<HTMLInputElement>()
135+
const pendingConfigData = ref<any>(null)
92136
93-
defineProps<{ initialSetup: boolean }>()
94-
defineEmits(['next'])
137+
const props = defineProps<{ initialSetup: boolean }>()
138+
const emit = defineEmits(['next', 'done'])
95139
const startTime = ref(dayjs())
96140
const endTime = ref(dayjs())
97141
@@ -102,18 +146,78 @@ const updateKMK = async () => {
102146
}
103147
104148
const updatePOG = async () => {
105-
window.api.saveConfiguration(
149+
await saveConfigurationWithLoading(
106150
JSON.stringify({ pogConfig: keyboardStore.serialize(), writeFirmware: true })
107151
)
108-
closeModal()
152+
isUpdateOpen.value = false
109153
}
110154
111-
const openModal = () => {
112-
;(document.getElementById('update_modal') as HTMLDialogElement).showModal()
155+
const backupConfiguration = async () => {
156+
try {
157+
const configData = keyboardStore.serialize()
158+
const jsonString = JSON.stringify(configData, null, 2)
159+
const blob = new Blob([jsonString], { type: 'application/json' })
160+
const url = URL.createObjectURL(blob)
161+
const link = document.createElement('a')
162+
link.href = url
163+
link.download = 'pog_backup.json'
164+
document.body.appendChild(link)
165+
link.click()
166+
document.body.removeChild(link)
167+
URL.revokeObjectURL(url)
168+
} catch (error) {
169+
console.error('Error downloading configuration:', error)
170+
}
113171
}
114172
115-
const closeModal = () => {
116-
;(document.getElementById('update_modal') as HTMLDialogElement).close()
173+
const restoreConfiguration = () => {
174+
fileInput.value?.click()
175+
}
176+
177+
const handleFileUpload = async (event: Event) => {
178+
const target = event.target as HTMLInputElement
179+
const file = target.files?.[0]
180+
if (!file) return
181+
try {
182+
const configData = JSON.parse(await file.text())
183+
pendingConfigData.value = configData
184+
isRestoreOpen.value = true
185+
} catch (error) {
186+
console.error('Error reading or parsing configuration file:', error)
187+
}
188+
target.value = ''
189+
}
190+
191+
const restoreConfig = async (generateNewIds: boolean) => {
192+
if (!pendingConfigData.value) return
193+
194+
const configData = generateNewIds ? { ...pendingConfigData.value } : pendingConfigData.value
195+
if (generateNewIds) {
196+
configData.id = ulid()
197+
}
198+
199+
try {
200+
isRestoreOpen.value = false
201+
202+
await saveConfigurationWithLoading(
203+
JSON.stringify({ pogConfig: configData, serial: false, writeFirmware: true })
204+
)
205+
if (keyboardStore.path) {
206+
const keyboardData = await window.api.selectKeyboard({ path: keyboardStore.path })
207+
if (keyboardData && !keyboardData.error) {
208+
keyboardStore.import(keyboardData)
209+
}
210+
}
211+
if (keyboardStore.keymap.length === 0) keyboardStore.keymap = [[]]
212+
keyboardStore.coordMapSetup = false
213+
214+
if (props.initialSetup) {
215+
addToHistory(keyboardStore)
216+
}
217+
emit('done')
218+
} catch (e) {
219+
console.error('restore failed', e)
220+
}
117221
}
118222
119223
window.api.onUpdateFirmwareInstallProgress(

src/renderer/src/components/LoadingOverlay.vue

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
leave-to-class="opacity-0 -translate-y-2"
99
>
1010
<div
11-
v-if="props.isVisible"
12-
class="fixed left-2 top-2 z-50 w-[350px] rounded-2xl border border-white/20 bg-base-100/10 p-4 text-xs shadow-2xl backdrop-blur-md"
11+
v-if="getLoadingState()"
12+
class="fixed left-2 top-2 z-50 w-[350px] rounded-2xl border border-white/20 bg-base-100/50 p-4 text-xs shadow-2xl backdrop-blur-md"
1313
>
1414
<div class="mb-2 flex items-center gap-3">
1515
<div class="relative h-6 w-6">
@@ -58,15 +58,9 @@
5858
<script setup lang="ts">
5959
import { onMounted, onUnmounted, watch, ref, computed } from 'vue'
6060
import { serialLogs } from '@renderer/store/serial'
61+
import { getLoadingState, hideLoading } from '../helpers/saveConfigurationWrapper'
6162
62-
interface Props {
63-
isVisible: boolean
64-
usingSerial?: boolean
65-
}
66-
67-
const props = withDefaults(defineProps<Props>(), {
68-
usingSerial: false
69-
})
63+
// No props needed - uses global loading state
7064
7165
const emit = defineEmits<{
7266
(e: 'done'): void
@@ -171,7 +165,9 @@ const attachListeners = () => {
171165
transitionToWithMin('saved', () => transitionToWithMin('reloading'))
172166
}
173167
}
174-
window.api.onSaveConfigurationProgress(saveProgressHandler)
168+
if (window.api.onSaveConfigurationProgress) {
169+
window.api.onSaveConfigurationProgress(saveProgressHandler)
170+
}
175171
}
176172
if (!unwatchSerial) {
177173
unwatchSerial = watch(
@@ -190,7 +186,7 @@ const attachListeners = () => {
190186
191187
const detachListeners = () => {
192188
// Remove listeners explicitly to avoid duplicates across multiple saves
193-
if (saveProgressHandler) {
189+
if (saveProgressHandler && window.api.offSaveConfigurationProgress) {
194190
window.api.offSaveConfigurationProgress(saveProgressHandler)
195191
}
196192
if (unwatchSerial) {
@@ -207,6 +203,7 @@ const scheduleDone = () => {
207203
if (minHideTimeout) clearTimeout(minHideTimeout)
208204
minHideTimeout = setTimeout(() => {
209205
emit('done')
206+
hideLoading()
210207
// reset local state for next run
211208
progress.value = { state: '', completed: 0, total: 0 }
212209
statusPhase.value = ''
@@ -228,7 +225,7 @@ const scheduleDone = () => {
228225
}
229226
230227
watch(
231-
() => props.isVisible,
228+
() => getLoadingState(),
232229
(visible) => {
233230
if (visible) {
234231
enterPhase('saving')
@@ -260,11 +257,12 @@ watch(
260257
statusPhase.value = ''
261258
phaseEnterAt = null
262259
}
263-
}
260+
},
261+
{ immediate: true }
264262
)
265263
266264
onMounted(() => {
267-
if (props.isVisible) {
265+
if (getLoadingState()) {
268266
statusPhase.value = 'saving'
269267
attachListeners()
270268
}

0 commit comments

Comments
 (0)