settings.vue
1 <template> 2 <NuxtLayout name="default"> 3 <div class="container"> 4 <section class="account-information"> 5 <h2 class="title">Account Information</h2> 6 <div class="items"> 7 <div class="keys"> 8 <p>User ID</p> 9 <p>Email</p> 10 <p>Github Linked</p> 11 </div> 12 <div class="values"> 13 <p>{{ user?.id }}</p> 14 <p>{{ user?.email }}</p> 15 <p>{{ hasGithubAccount ? "yes" : "no" }}</p> 16 </div> 17 </div> 18 <div class="buttons"> 19 <UiButton 20 v-if="!hasGithubAccount" 21 text="Link Github" 22 keyName="L" 23 @click="linkGithub" /> 24 <UiButton 25 text="Change Email" 26 keyName="E" 27 @click="showEmailModal = true" /> 28 <UiButton 29 text="Change Password" 30 keyName="P" 31 @click="showPasswordModal = true" /> 32 <UiButton text="Logout" keyName="Alt+L" @click="logout" /> 33 </div> 34 </section> 35 36 <UiModal 37 :open="showEmailModal" 38 title="Change Email" 39 :isLoading="isLoading" 40 @cancel="showEmailModal = false" 41 @save="changeEmail" 42 @close="showEmailModal = false"> 43 <template #form-content> 44 <UiInput 45 type="email" 46 v-model="newEmail" 47 placeholder="New Email Address" 48 required /> 49 </template> 50 </UiModal> 51 52 <UiModal 53 :open="showPasswordModal" 54 title="Change Password" 55 description="Password must be at least 12 characters and include uppercase, 56 lowercase, numbers, and special characters" 57 :isLoading="isLoading" 58 @cancel="showPasswordModal = false" 59 @save="changePassword" 60 @close="showPasswordModal = false"> 61 <template #form-content> 62 <UiInput 63 type="password" 64 v-model="newPassword" 65 placeholder="New Password" 66 required /> 67 <UiInput 68 type="password" 69 v-model="confirmPassword" 70 placeholder="Confirm Password" 71 required /> 72 </template> 73 </UiModal> 74 75 <section class="api-key"> 76 <h2 class="title">API Key</h2> 77 <UiInput 78 :locked="true" 79 :type="showApiKey ? 'text' : 'password'" 80 :modelValue="user?.apiKey" /> 81 <div class="buttons"> 82 <UiButton 83 v-if="showApiKey" 84 text="Hide API Key" 85 keyName="S" 86 @click="toggleApiKey" /> 87 <UiButton 88 v-else 89 text="Show API Key" 90 keyName="S" 91 @click="toggleApiKey" /> 92 <UiButton text="Copy API Key" keyName="c" @click="copyApiKey" /> 93 <UiButton 94 text="Regenerate API Key" 95 keyName="r" 96 @click="regenerateApiKey" /> 97 </div> 98 </section> 99 100 <section class="tracking-settings"> 101 <h2 class="title">Tracking Settings</h2> 102 <div class="setting-group"> 103 <p class="setting-description">Keystroke Timeout (minutes):</p> 104 <UiNumberInput 105 id="keystrokeTimeout" 106 v-model="keystrokeTimeout" 107 :min="1" 108 :max="60" 109 @update:modelValue="updateKeystrokeTimeout" /> 110 111 <p> 112 In order to work correctly, summaries need to be regenerated after 113 changing the keystroke timeout. 114 </p> 115 <UiButton 116 text="Regenerate Summaries" 117 keyName="Alt+R" 118 @click="regenerateSummaries" /> 119 </div> 120 </section> 121 122 <section class="wakatime-import"> 123 <h2 class="title">Data Import</h2> 124 <div class="setting-group"> 125 <div class="radio-group"> 126 <UiRadioButton 127 :text="'WakaTime'" 128 :selected="importType === 'wakatime'" 129 :value="'wakatime'" 130 @update="(val: ImportType) => (importType = val)" /> 131 <UiRadioButton 132 :text="'WakAPI'" 133 :selected="importType === 'wakapi'" 134 :value="'wakapi'" 135 @update="(val: ImportType) => (importType = val)" /> 136 </div> 137 138 <UiInput 139 :id="importType + 'ApiKey'" 140 type="password" 141 v-model="importApiKey" 142 :placeholder="apiKeyPlaceholder" 143 v-if="importType === 'wakapi'" /> 144 145 <UiInput 146 id="wakapiInstanceUrl" 147 type="text" 148 v-model="wakapiInstanceUrl" 149 placeholder="Enter your WakAPI instance URL (e.g. https://wakapi.dev)" 150 v-if="importType === 'wakapi'" /> 151 152 <div v-if="importType === 'wakatime'" class="steps"> 153 <p> 154 1. Go to 155 <a href="https://wakatime.com/settings/account" target="_blank" 156 >WakaTime Settings</a 157 > 158 </p> 159 <p> 160 2. Click on <kbd>Export my code stats...</kbd> and select 161 Heartbeats 162 </p> 163 <p>3. Wait and then listenload your data</p> 164 </div> 165 166 <input 167 type="file" 168 id="wakaTimeFileUpload" 169 ref="wakaTimeFileInput" 170 accept=".json" 171 @change="handleFileChange" 172 v-if="importType === 'wakatime'" /> 173 </div> 174 175 <UiButton text="Import Data" keyName="I" @click="importTrackingData" /> 176 </section> 177 </div> 178 </NuxtLayout> 179 </template> 180 181 <script setup lang="ts"> 182 import type { User } from "@prisma/client"; 183 import { ref, onMounted, computed } from "vue"; 184 import { Key } from "@waradu/keyboard"; 185 import * as statsLib from "~~/lib/stats"; 186 187 const userState = useState<User | null>("user"); 188 const user = computed(() => userState.value); 189 const showApiKey = ref(false); 190 const url = useRequestURL(); 191 const toast = useToast(); 192 const route = useRoute(); 193 const keystrokeTimeout = ref(0); 194 const originalKeystrokeTimeout = ref(0); 195 const timeoutChanged = ref(false); 196 const hasGithubAccount = computed(() => !!user.value?.githubId); 197 const WAKATIME = "wakatime" as const; 198 const WAKAPI = "wakapi" as const; 199 type ImportType = typeof WAKATIME | typeof WAKAPI; 200 const importType = ref<ImportType>(WAKATIME); 201 const importApiKey = ref(""); 202 const wakapiInstanceUrl = ref(""); 203 const wakaTimeFileInput = ref<HTMLInputElement | null>(null); 204 const selectedFile = ref<File | null>(null); 205 const selectedFileName = ref<string | null>(null); 206 207 const showEmailModal = ref(false); 208 const showPasswordModal = ref(false); 209 const newEmail = ref(""); 210 const newPassword = ref(""); 211 const confirmPassword = ref(""); 212 const isLoading = ref(false); 213 214 const apiKeyPlaceholder = computed(() => { 215 return `Enter your ${importType.value === "wakatime" ? "WakaTime" : "WakAPI"} API Key`; 216 }); 217 218 async function fetchUserData() { 219 if (userState.value) return userState.value; 220 221 try { 222 const data = await $fetch("/api/user"); 223 userState.value = data as User; 224 225 if (data?.keystrokeTimeout) { 226 statsLib.setKeystrokeTimeout(data.keystrokeTimeout); 227 } 228 229 return data; 230 } catch (error) { 231 console.error("Error fetching user data:", error); 232 return null; 233 } 234 } 235 236 onMounted(async () => { 237 await fetchUserData(); 238 239 if (user.value) { 240 keystrokeTimeout.value = user.value.keystrokeTimeout; 241 originalKeystrokeTimeout.value = user.value.keystrokeTimeout; 242 } 243 244 if (route.query.error) { 245 const errorMessages: Record<string, string> = { 246 link_failed: "Failed to link GitHub account", 247 invalid_state: "Invalid state parameter", 248 no_code: "No code provided", 249 no_email: "No email found", 250 github_auth_failed: "GitHub authentication failed", 251 }; 252 253 const message = errorMessages[route.query.error as string] || "Error"; 254 toast.error(message); 255 } 256 257 if (route.query.success) { 258 const successMessages: Record<string, string> = { 259 github_linked: "GitHub account successfully linked", 260 github_updated: "GitHub credentials updated", 261 accounts_merged: "Accounts successfully merged", 262 }; 263 264 const message = successMessages[route.query.success as string] || "Success"; 265 toast.success(message); 266 } 267 }); 268 269 useKeybind( 270 [Key.L], 271 async () => { 272 if (hasGithubAccount) { 273 await linkGithub(); 274 } 275 }, 276 { prevent: true, ignoreIfEditable: true } 277 ); 278 279 useKeybind( 280 [Key.E], 281 async () => { 282 showEmailModal.value = true; 283 newEmail.value = user.value?.email || ""; 284 }, 285 { prevent: true, ignoreIfEditable: true } 286 ); 287 288 useKeybind( 289 [Key.P], 290 async () => { 291 showPasswordModal.value = true; 292 newPassword.value = ""; 293 confirmPassword.value = ""; 294 }, 295 { prevent: true, ignoreIfEditable: true } 296 ); 297 298 useKeybind( 299 [Key.Alt, Key.L], 300 async () => { 301 await logout(); 302 }, 303 { prevent: true, ignoreIfEditable: true } 304 ); 305 306 useKeybind( 307 [Key.S], 308 async () => { 309 toggleApiKey(); 310 }, 311 { prevent: true, ignoreIfEditable: true } 312 ); 313 314 useKeybind( 315 [Key.C], 316 async () => { 317 await copyApiKey(); 318 }, 319 { prevent: true, ignoreIfEditable: true } 320 ); 321 322 useKeybind( 323 [Key.R], 324 async () => { 325 await regenerateApiKey(); 326 }, 327 { prevent: true, ignoreIfEditable: true } 328 ); 329 330 useKeybind( 331 [Key.I], 332 async () => { 333 await importTrackingData(); 334 }, 335 { prevent: true, ignoreIfEditable: true } 336 ); 337 338 useKeybind( 339 [Key.Alt, Key.R], 340 async () => { 341 if ( 342 !confirm( 343 "Confirm that you want to regenerate all your summaires which can take a while." 344 ) 345 ) { 346 return; 347 } 348 349 await regenerateSummaries(); 350 }, 351 { prevent: true, ignoreIfEditable: true } 352 ); 353 354 async function updateKeystrokeTimeout() { 355 if (!user.value) return; 356 357 try { 358 await $fetch("/api/user", { 359 method: "POST", 360 body: { 361 keystrokeTimeout: keystrokeTimeout.value, 362 }, 363 }); 364 365 if (userState.value) { 366 userState.value.keystrokeTimeout = keystrokeTimeout.value; 367 } 368 369 statsLib.setKeystrokeTimeout(keystrokeTimeout.value); 370 371 toast.success("Keystroke timeout updated"); 372 373 if (originalKeystrokeTimeout.value !== keystrokeTimeout.value) { 374 timeoutChanged.value = true; 375 } 376 377 await statsLib.refreshStats(); 378 } catch (error) { 379 console.error("Error updating keystroke timeout:", error); 380 toast.error("Failed to update keystroke timeout"); 381 } 382 } 383 384 async function changeEmail() { 385 if (!newEmail.value || newEmail.value === user.value?.email) { 386 showEmailModal.value = false; 387 return; 388 } 389 390 isLoading.value = true; 391 392 try { 393 await $fetch("/api/user", { 394 method: "POST", 395 body: { 396 email: newEmail.value, 397 }, 398 }); 399 400 if (userState.value) { 401 userState.value.email = newEmail.value; 402 } 403 404 toast.success("Email updated successfully"); 405 showEmailModal.value = false; 406 newEmail.value = ""; 407 } catch (error: any) { 408 console.error("Error updating email:", error); 409 toast.error(error?.data?.message || "Failed to update email"); 410 } finally { 411 isLoading.value = false; 412 } 413 } 414 415 async function changePassword() { 416 if (!newPassword.value || newPassword.value !== confirmPassword.value) { 417 toast.error("Passwords do not match"); 418 return; 419 } 420 421 isLoading.value = true; 422 423 try { 424 await $fetch("/api/user", { 425 method: "POST", 426 body: { 427 password: newPassword.value, 428 }, 429 }); 430 431 toast.success("Password updated successfully"); 432 showPasswordModal.value = false; 433 newPassword.value = ""; 434 confirmPassword.value = ""; 435 } catch (error: any) { 436 console.error("Error updating password:", error); 437 toast.error(error?.data?.message || "Failed to update password"); 438 } finally { 439 isLoading.value = false; 440 } 441 } 442 443 async function regenerateSummaries() { 444 try { 445 const response = await $fetch("/api/user/regenerateSummaries"); 446 toast.success( 447 (response as any).message || "Summaries regenerated successfully" 448 ); 449 timeoutChanged.value = false; 450 originalKeystrokeTimeout.value = keystrokeTimeout.value; 451 await statsLib.refreshStats(); 452 } catch (error) { 453 console.error("Error regenerating summaries:", error); 454 toast.error("Failed to regenerate summaries"); 455 } 456 } 457 458 function toggleApiKey() { 459 showApiKey.value = !showApiKey.value; 460 } 461 462 async function copyApiKey() { 463 if (!user.value?.apiKey) return; 464 465 try { 466 await navigator.clipboard.writeText(user.value.apiKey); 467 toast.success("API Key copied to clipboard"); 468 } catch (error) { 469 console.error("Failed to copy API key:", error); 470 toast.error("Failed to copy API key"); 471 } 472 } 473 474 async function regenerateApiKey() { 475 if ( 476 !confirm( 477 "Are you sure you want to regenerate your API key? Your existing VS Code extension setup will stop working until you update it." 478 ) 479 ) { 480 return; 481 } 482 483 try { 484 const data = await $fetch("/api/user/apikey"); 485 486 if (userState.value) { 487 userState.value = { 488 ...userState.value, 489 apiKey: data.apiKey, 490 }; 491 showApiKey.value = true; 492 toast.success("API key regenerated successfully"); 493 } 494 } catch (error) { 495 console.error("Error regenerating API key:", error); 496 toast.error("Failed to regenerate API key"); 497 } 498 } 499 500 async function logout() { 501 try { 502 window.location.href = "/api/auth/logout"; 503 } catch (e: any) { 504 toast.error(e.data?.message || "Logout failed"); 505 } 506 } 507 508 async function linkGithub() { 509 window.location.href = "/api/auth/github/link"; 510 } 511 512 function handleFileChange(event: Event) { 513 const input = event.target as HTMLInputElement; 514 if (input.files && input.files.length > 0) { 515 selectedFile.value = input.files[0] ?? null; 516 selectedFileName.value = input.files[0]?.name ?? null; 517 } else { 518 selectedFile.value = null; 519 selectedFileName.value = null; 520 } 521 } 522 523 async function importTrackingData() { 524 if (importType.value === "wakatime") { 525 if (!selectedFile.value) { 526 toast.error("Please select a WakaTime export file"); 527 return; 528 } 529 530 try { 531 const formData = new FormData(); 532 formData.append("file", selectedFile.value); 533 toast.success("WakaTime data import started"); 534 535 await $fetch("/api/wakatime", { 536 method: "POST", 537 body: formData, 538 }); 539 540 toast.success("WakaTime data import completed"); 541 selectedFile.value = null; 542 selectedFileName.value = null; 543 if (wakaTimeFileInput.value) { 544 wakaTimeFileInput.value.value = ""; 545 } 546 } catch (error: any) { 547 console.error("Error importing WakaTime data:", error); 548 toast.error(error?.data?.message || "Failed to import WakaTime data"); 549 } 550 } else { 551 if (!importApiKey.value) { 552 toast.error("Please enter your WakAPI API Key"); 553 return; 554 } 555 556 if (!wakapiInstanceUrl.value) { 557 toast.error("Please enter your WakAPI instance URL"); 558 return; 559 } 560 561 try { 562 const payload = { 563 apiKey: importApiKey.value, 564 instanceType: importType.value, 565 instanceUrl: wakapiInstanceUrl.value, 566 }; 567 toast.success("WakAPI data import started"); 568 569 await $fetch("/api/wakatime", { 570 method: "POST", 571 body: payload, 572 }); 573 574 toast.success("WakAPI data import completed"); 575 importApiKey.value = ""; 576 wakapiInstanceUrl.value = ""; 577 } catch (error: any) { 578 console.error("Error importing WakAPI data:", error); 579 toast.error(error?.data?.message || "Failed to import WakAPI data"); 580 } 581 } 582 } 583 584 useSeoMeta({ 585 title: "Settings - Ziit", 586 description: "Manage your Ziit account settings and API keys", 587 ogTitle: "Settings - Ziit", 588 ogDescription: "Manage your Ziit account settings and API keys", 589 ogImage: "https://ziit.app/logo.webp", 590 ogUrl: "https://ziit.app/settings", 591 ogSiteName: "Ziit", 592 twitterTitle: "Settings - Ziit", 593 twitterDescription: "Manage your Ziit account settings and API keys", 594 twitterImage: "https://ziit.app/logo.webp", 595 twitterCard: "summary", 596 twitterCreator: "@pandadev_", 597 twitterSite: "@pandadev_", 598 author: "PandaDEV", 599 }); 600 601 useHead({ 602 htmlAttrs: { lang: "en" }, 603 link: [ 604 { 605 rel: "canonical", 606 href: "https://ziit.app/settings", 607 }, 608 { 609 rel: "icon", 610 type: "image/ico", 611 href: "/favicon.ico", 612 }, 613 ], 614 script: [ 615 { 616 type: "application/ld+json", 617 innerHTML: JSON.stringify({ 618 "@context": "https://schema.org", 619 "@type": "WebPage", 620 name: "Settings - Ziit", 621 url: "https://ziit.app/settings", 622 }), 623 }, 624 ], 625 }); 626 </script> 627 628 <style scoped lang="scss"> 629 @use "~~/styles/settings.scss"; 630 </style>