/ app / pages / settings.vue
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>