initial commit. Cloned timetracker repository

This commit is contained in:
Daniel Goc
2026-03-10 09:02:57 +01:00
commit f2952bcef0
189 changed files with 21334 additions and 0 deletions

9
bo/src/App.vue Normal file
View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<Suspense>
<RouterView />
</Suspense>
</template>

141
bo/src/app.config.ts Normal file
View File

@@ -0,0 +1,141 @@
import type { NuxtUIOptions } from '@nuxt/ui/unplugin'
export const uiOptions: NuxtUIOptions = {
ui: {
colors: {
primary: 'blue',
neutral: 'zink',
},
pagination: {
slots: {
root: '',
}
},
// selectMenu: {
// variants: {
// size: {
// xxl: {
// group: 'mt-20!'
// },
// },
// },
button: {
slots: {
base: 'border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0!',
},
},
input: {
slots: {
base: 'text-(--black) dark:text-white border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0!',
},
},
// variants: {
// size: {
// xxl: {
// base: 'h-8 sm:h-[38px] px-[10px] py-[10px] border! border-(--border-light)! dark:border-(--border-dark)!',
// trailingIcon: 'px-6 !text-base',
// root: 'w-full',
// },
// },
// },
// defaultVariants: {
// size: 'xxl',
// },
// },
// textarea: {
// slots: {
// base: 'disabled:!opacity-100 text-(--black) dark:text-white disabled:text-(--gray) !text-base placeholder:text-(--gray)/50! dark:placeholder:text-(--gray)!',
// trailingIcon: 'shrink-0 pr-4 !text-base',
// error: 'text-sm! !sm:text-[15px] leading-none! mt-1!',
// },
// variants: {
// size: {
// xxl: {
// base: 'px-[25px] py-[15px]',
// trailingIcon: 'px-6 !text-base',
// root: 'w-full',
// },
// },
// },
// defaultVariants: {
// size: 'xxl',
// },
// },
// formField: {
// slots: {
// base: 'flex !flex-col border! border-(--border-light)! dark:border-(--border-dark)!',
// label: 'text-[15px] text-(--gray)! dark:text-(--gray-dark)! pl-6! leading-none! font-normal! mb-1 sm:mb-1',
// error: 'text-sm! !sm:text-[15px] leading-none! mt-1!',
// },
// variants: {
// size: {
// xxl: 'w-full',
// label: '!label !mb-1',
// },
// },
// defaultVariants: {
// size: 'xxl',
// },
// },
// defaultVariants: {
// size: 'xxl',
// },
// },
select: {
slots: {
base: 'w-full! cursor-pointer border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0!',
itemLabel: 'text-black! dark:text-white!',
itemTrailingIcon: 'text-black! dark:text-white!'
},
// variants: {
// size: {
// xxl: {
// base: ' h-12 sm:h-[54px] px-[25px]',
// item: 'py-2 px-2',
// trailingIcon: 'px-6 !text-base',
// leading: '!px-[25px]',
// itemLabel: 'text-black dark:text-white',
// },
// },
// },
// defaultVariants: {
// size: 'xxl',
// },
// },
// inputDate: {
// slots: {
// leadingIcon: 'border-none! outline-0! ring-0!',
// },
// defaultVariants: {
// size: 'xxl',
// },
// },
// checkbox: {
// slots: {
// label: 'block !font-normal',
// indicator: '!bg-(--accent-brown)',
// },
// },
// radioGroup: {
// slots: {
// label: 'block !font-normal text-base font-normal leading-none text-(--black) dark:text-(--second-light)',
// indicator: '!bg-(--accent-brown)',
// size: 'xxl',
// },
// },
// modal: {
// slots: {
// overlay: 'dark:bg-(--main-dark)/90',
// },
// },
// tooltip: {
// slots: {
// content: 'max-w-60 sm:max-w-100 bg-(--main-light)! dark:bg-(--black)! w-full h-full',
// text: 'whitespace-normal',
// },
}
}
}

87
bo/src/assets/main.css Normal file
View File

@@ -0,0 +1,87 @@
@import 'tailwindcss';
@import '@nuxt/ui';
body {
font-family: "Inter", sans-serif;
}
.inter {
font-family: "Inter", sans-serif;
}
.container{
max-width: 2100px;
margin: auto;
}
@theme {
--main-light: #FFFEFB;
--second-light: #F5F6FA;
--main-dark: #212121;
--black: #1A1A1A;
/* gray */
--gray: #6B6B6B;
--gray-dark: #A3A3A3;
--accent-green: #004F3D;
--accent-green-dark: #00A882;
--accent-brown: #9A7F62;
--accent-red: #B72D2D;
--dark-red: #F94040;
--accent-orange: #E68D2B;
--accent-blue: #002B4F;
/* borders */
--border-light: #E8E7E0;
--border-dark: #3F3E3D;
/* text */
--text-dark: #FFFEFB;
/* placeholder */
--placeholder: #8C8C8A;
--ui-bg: var(--main-light);
--ui-primary: var(--color-gray-300);
--ui-secondary: var(--accent-green);
--ui-border-accented: var(--border-light);
--ui-text-dimmed: var(--gray);
--ui-bg-elevated: var(--color-gray-300);
--ui-border: var(--border-light);
--ui-color-neutral-700: var(--black);
--ui-error: var(--accent-red);
--border: var(--border-light);
--tw-border-style: var(--border-light);
}
.dark {
--ui-bg: var(--black);
--ui-primary: var(--color-gray-500);
--ui-secondary: var(--accent-green-dark);
--ui-border-accented: var(--border-dark);
--ui-text-dimmed: var(--gray-dark);
--ui-border: var(--border-dark);
--ui-bg-elevated: var(--color-gray-500);
--ui-error: var(--dark-red);
--border: var(--border-dark);
--tw-border-style: var(--border-dark);
}
.label-form {
@apply text-(--gray) dark:text-(--gray-dark) pl-0 md:pl-6 leading-none;
}
.title {
@apply font-medium text-[19px] sm:text-xl md:text-[22px] leading-none text-(--black) dark:text-(--main-light);
}
.column-title {
@apply md:ml-[25px] mb-[25px] sm:mb-[30px];
}
.form-title {
@apply text-(--accent-green) dark:text-(--accent-green-dark) font-medium;
}

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import HomeView from '@/views/HomeView.vue';
import LangSwitch from './inner/langSwitch.vue'
import ThemeSwitch from './inner/themeSwitch.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
</script>
<template>
<header
class="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-(--black) backdrop-blur-md border-b border-(--border-light) dark:border-(--border-dark)">
<div class="container px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<!-- Logo -->
<RouterLink :to="{ name: 'home' }" class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-primary text-white flex items-center justify-center">
<UIcon name="i-heroicons-clock" class="w-5 h-5" />
</div>
<span class="font-semibold text-gray-900 dark:text-white">TimeTracker</span>
</RouterLink>
<!-- Right Side Actions -->
<HomeView />
<div class="flex items-center gap-2">
<!-- Language Switcher -->
<LangSwitch />
<!-- Theme Switcher -->
<ThemeSwitch />
<!-- Logout Button (only when authenticated) -->
<button v-if="authStore.isAuthenticated" @click="authStore.logout()"
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 hover:text-primary dark:hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
Logout
</button>
</div>
</div>
</div>
</header>
</template>

View File

@@ -0,0 +1,15 @@
<template>
<button :type="type" :disabled="disabled"
:class="['px-[25px] h-[43px] leading-none rounded-md text-[15px] sm:text-[16px] dark:text-white text-black',
fillType === 'border' ? 'border border-(--border-light) dark:border-(--border-dark)' : false,]">
<slot />
</button>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ type?: 'button' | 'submit', fillType?: 'border', disabled?: boolean }>(), {
type: 'button',
fillType: 'border',
disabled: false,
})
</script>

View File

View File

@@ -0,0 +1,71 @@
<template>
<USelectMenu v-model="locale" :items="langs"
class="w-40 bg-white dark:bg-(--black) rounded-md shadow-sm hover:none!"
valueKey="iso_code" :searchInput="false">
<template #default="{ modelValue }">
<div class="flex items-center gap-1">
<span class="text-md dark:text-white text-black">{{langs.find(x => x.iso_code == modelValue)?.flag}}</span>
<span class="font-medium dark:text-white text-black">{{langs.find(x => x.iso_code == modelValue)?.name}}</span>
</div>
</template>
<template #item-leading="{ item }">
<div class="flex items-center rounded-md cursor-pointer transition-colors">
<span class="text-md ">{{ item.flag }}</span>
<span class="ml-2 dark:text-white text-black font-medium">{{ item.name }}</span>
</div>
</template>
</USelectMenu>
</template>
<script setup lang="ts">
import { langs, currentLang } from '@/router/langs'
import { useRouter, useRoute } from 'vue-router'
import { useCookie } from '@/composable/useCookie'
import { computed, watch } from 'vue'
import { i18n } from '@/plugins/i18n'
const router = useRouter()
const route = useRoute()
const cookie = useCookie()
const locale = computed({
get() {
return currentLang.value?.iso_code || i18n.locale.value
},
set(value: string) {
i18n.locale.value = value
currentLang.value = langs.find((x) => x.iso_code == value)
// Update URL to reflect language change
const currentPath = route.path
const pathParts = currentPath.split('/').filter(Boolean)
cookie.setCookie('lang_id', `${langs.find((x) => x.iso_code == value)?.id}`, { days: 60, secure: true, sameSite: 'Lax' })
if (pathParts.length > 0) {
// Check if first part is a locale
const isLocale = langs.some((l) => l.lang_code === pathParts[0])
if (isLocale) {
// Replace existing locale
pathParts[0] = value
router.replace({ path: '/' + pathParts.join('/'), query: route.query })
} else {
// Add locale to path
router.replace({ path: '/' + value + currentPath, query: route.query })
}
}
},
})
// Sync i18n locale with router locale on initial load
watch(
() => route.params.locale,
(newLocale) => {
if (newLocale && typeof newLocale === 'string') {
i18n.locale.value = newLocale
currentLang.value = langs.find((x) => x.iso_code == newLocale)
}
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,12 @@
<template>
<UButton variant="ghost" size="sm" @click="themeStorage.setTheme()">
<span class="hidden sm:inline">
<UIcon class="size-5" :name="themeStorage.themeIcon" />
</span>
</UButton>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/stores/theme'
const themeStorage = useThemeStore()
</script>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div
class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
<UIcon name="i-heroicons-shield-check" class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Zásady ochrany osobních údajů</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Poslední aktualizace: březen 2026</p>
</div>
<!-- Content Card -->
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
<div class="prose prose-sm sm:prose dark:prose-invert max-w-none space-y-6">
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">1. Úvod</h2>
<p class="text-gray-600 dark:text-gray-400">
V TimeTracker bereme vaše soukromí vážně. Tyto Zásady ochrany osobních údajů vysvětlují, jak shromažďujeme, používáme, sdílíme a chráníme vaše
informace při používání naší aplikace.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">2. Informace, které shromažďujeme</h2>
<p class="text-gray-600 dark:text-gray-400">Můžeme shromažďovat osobní údaje, které nám dobrovolně poskytujete, když:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Registrujete účet</li>
<li>Používáte funkce sledování času</li>
<li>Vytváříte nebo spravujete projekty</li>
<li>Generujete reporty</li>
<li>Kontaktujete naši podporu</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">3. Jak používáme vaše informace</h2>
<p class="text-gray-600 dark:text-gray-400">Shromážděné informace používáme k:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Poskytování a údržbě našich služeb</li>
<li>Sledování vašeho času a správě projektů</li>
<li>Zlepšování našich služeb a uživatelského zážitku</li>
<li>Komunikaci s vámi ohledně aktualizací a podpory</li>
<li>Plnění právních povinností</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">4. Ukládání a zabezpečení dat</h2>
<p class="text-gray-600 dark:text-gray-400">
Vaše data jsou bezpečně ukládána pomocí šifrování podle průmyslových standardů. Implementujeme vhodná technická a organizační opatření na ochranu
vašich osobních údajů před neoprávněným přístupem, změnou, zveřejněním nebo zničením.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">5. Sdílení dat</h2>
<p class="text-gray-600 dark:text-gray-400">
Vaše osobní údaje neprodáváme, nevyměňujeme ani jinak nepřevádíme třetím stranám. Můžeme sdílet informace s:
</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Poskytovateli služeb, kteří nám pomáhají</li>
<li>Právními orgány, když to vyžaduje zákon</li>
<li>Obchodními partnery s vaším souhlasem</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">6. Vaše práva</h2>
<p class="text-gray-600 dark:text-gray-400">Máte právo na:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Přístup k vašim osobním údajům</li>
<li>Opravu nepřesných údajů</li>
<li>Žádost o smazání vašich údajů</li>
<li>Export vašich dat v přenosném formátu</li>
<li>Odhlášení z marketingových sdělení</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">7. Cookies a sledovací technologie</h2>
<p class="text-gray-600 dark:text-gray-400">
Používáme cookies a podobné sledovací technologie pro zlepšení vašeho zážitku. Cookies můžete ovládat prostřednictvím nastavení prohlížeče.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">8. Soukromí dětí</h2>
<p class="text-gray-600 dark:text-gray-400">
Naše služba není určena pro děti mladší 13 let. Vědomě neshromažďujeme osobní údaje od dětí mladších 13 let.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">9. Změny těchto zásad</h2>
<p class="text-gray-600 dark:text-gray-400">
Tyto Zásady ochrany osobních údajů můžeme čas od času aktualizovat. Jakékoli změny vám oznámíme zveřejněním nových zásad na této stránce a
aktualizací data "Poslední aktualizace".
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">10. Kontaktujte nás</h2>
<p class="text-gray-600 dark:text-gray-400">
Máte-li jakékoli dotazy ohledně těchto Zásad ochrany osobních údajů, kontaktujte nás na adrese privacy@timetracker.com.
</p>
</section>
</div>
<template #footer>
<div class="flex justify-center"></div>
</template>
</UCard>
</div>
</div>
</template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div
class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
<UIcon name="i-heroicons-document-text" class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Podmínky použití</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Poslední aktualizace: březen 2026</p>
</div>
<!-- Content Card -->
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
<div class="prose prose-sm sm:prose dark:prose-invert max-w-none space-y-6">
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">1. Přijetí podmínek</h2>
<p class="text-gray-600 dark:text-gray-400">
Používáním aplikace TimeTracker souhlasíte a zavazujete se dodržovat podmínky a ustanovení této dohody.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">2. Popis služby</h2>
<p class="text-gray-600 dark:text-gray-400">
TimeTracker je aplikace pro sledování času, která uživatelům umožňuje sledovat pracovní hodiny, spravovat projekty a generovat reporty.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">3. Odpovědnosti uživatele</h2>
<p class="text-gray-600 dark:text-gray-400">Souhlasíte s:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Poskytováním přesných a úplných informací</li>
<li>Udržováním bezpečnosti svého účtu</li>
<li>Nesdílením přihlašovacích údajů s ostatními</li>
<li>Používáním služby v souladu s platnými zákony</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">4. Ochrana osobních údajů</h2>
<p class="text-gray-600 dark:text-gray-400">
Jsme odhodláni chránit vaše soukromí. Vaše osobní údaje budou zpracovány v souladu s naší Zásadami ochrany osobních údajů a příslušnými zákony o
ochraně dat.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">5. Duševní vlastnictví</h2>
<p class="text-gray-600 dark:text-gray-400">
Služba TimeTracker a veškerý její obsah, včetně mimo jiné textů, grafiky, loga a softwaru, je majetkem TimeTracker a je chráněn zákony o duševním
vlastnictví.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">6. Omezení odpovědnosti</h2>
<p class="text-gray-600 dark:text-gray-400">
TimeTracker neodpovídá za jakékoli nepřímé, náhodné, zvláštní, následné nebo trestné škody vzniklé v důsledku vašeho používání nebo neschopnosti
používat službu.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">7. Ukončení</h2>
<p class="text-gray-600 dark:text-gray-400">
Vyhrazujeme si právo ukončit nebo pozastavit váš účet kdykoli, bez předchozího upozornění, za chování, které por tyto Podmušujeínky použití nebo
je škodlivé pro ostatní uživatele nebo službu.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">8. Změny podmínek</h2>
<p class="text-gray-600 dark:text-gray-400">
Vyhrazujeme si právo kdykoli upravit tyto Podmínky použití. Vaše další používání TimeTracker po jakýchkoli změnách znamená přijetí nových
podmínek.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">9. Kontaktní informace</h2>
<p class="text-gray-600 dark:text-gray-400">
Máte-li jakékoli dotazy ohledně těchto Podmínek použití, kontaktujte nás na adrese support@timetracker.com.
</p>
</section>
</div>
<template #footer>
<div class="flex justify-center"></div>
</template>
</UCard>
</div>
</div>
</template>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div
class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
<UIcon name="i-heroicons-shield-check" class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Privacy Policy</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Last updated: March 2026</p>
</div>
<!-- Content Card -->
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
<div class="prose prose-sm sm:prose dark:prose-invert max-w-none space-y-6">
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">1. Introduction</h2>
<p class="text-gray-600 dark:text-gray-400">
At TimeTracker, we take your privacy seriously. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when
you use our application.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">2. Information We Collect</h2>
<p class="text-gray-600 dark:text-gray-400">We may collect personal information that you voluntarily provide to us when you:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Register for an account</li>
<li>Use our time tracking features</li>
<li>Create or manage projects</li>
<li>Generate reports</li>
<li>Contact our support team</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">3. How We Use Your Information</h2>
<p class="text-gray-600 dark:text-gray-400">We use the information we collect to:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Provide and maintain our services</li>
<li>Track your time and manage your projects</li>
<li>Improve our services and user experience</li>
<li>Communicate with you about updates and support</li>
<li>Comply with legal obligations</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">4. Data Storage and Security</h2>
<p class="text-gray-600 dark:text-gray-400">
Your data is stored securely using industry-standard encryption. We implement appropriate technical and organizational measures to protect your
personal information against unauthorized access, alteration, disclosure, or destruction.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">5. Data Sharing</h2>
<p class="text-gray-600 dark:text-gray-400">
We do not sell, trade, or otherwise transfer your personal information to outside parties. We may share information with:
</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Service providers who assist in our operations</li>
<li>Legal authorities when required by law</li>
<li>Business partners with your consent</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">6. Your Rights</h2>
<p class="text-gray-600 dark:text-gray-400">You have the right to:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Access your personal information</li>
<li>Correct inaccurate data</li>
<li>Request deletion of your data</li>
<li>Export your data in a portable format</li>
<li>Opt-out of marketing communications</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">7. Cookies and Tracking Technologies</h2>
<p class="text-gray-600 dark:text-gray-400">
We use cookies and similar tracking technologies to enhance your experience. You can control cookies through your browser settings.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">8. Children's Privacy</h2>
<p class="text-gray-600 dark:text-gray-400">
Our service is not intended for children under 13. We do not knowingly collect personal information from children under 13.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">9. Changes to This Policy</h2>
<p class="text-gray-600 dark:text-gray-400">
We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new policy on this page and updating the
"Last updated" date.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">10. Contact Us</h2>
<p class="text-gray-600 dark:text-gray-400">If you have any questions about this Privacy Policy, please contact us at privacy@timetracker.com.</p>
</section>
</div>
<template #footer>
<div class="flex justify-center"></div>
</template>
</UCard>
</div>
</div>
</template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div
class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
<UIcon name="i-heroicons-document-text" class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Terms and Conditions</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Last updated: March 2026</p>
</div>
<!-- Content Card -->
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
<div class="prose prose-sm sm:prose dark:prose-invert max-w-none space-y-6">
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">1. Acceptance of Terms</h2>
<p class="text-gray-600 dark:text-gray-400">
By accessing and using TimeTracker, you accept and agree to be bound by the terms and provision of this agreement.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">2. Description of Service</h2>
<p class="text-gray-600 dark:text-gray-400">
TimeTracker is a time tracking application that allows users to track their working hours, manage projects, and generate reports.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">3. User Responsibilities</h2>
<p class="text-gray-600 dark:text-gray-400">You agree to:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Provide accurate and complete information</li>
<li>Maintain the security of your account</li>
<li>Not share your login credentials with others</li>
<li>Use the service in compliance with applicable laws</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">4. Privacy and Data Protection</h2>
<p class="text-gray-600 dark:text-gray-400">
We are committed to protecting your privacy. Your personal data will be processed in accordance with our Privacy Policy and applicable data
protection laws.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">5. Intellectual Property</h2>
<p class="text-gray-600 dark:text-gray-400">
The TimeTracker service and all its contents, including but not limited to text, graphics, logos, and software, are the property of TimeTracker
and are protected by intellectual property laws.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">6. Limitation of Liability</h2>
<p class="text-gray-600 dark:text-gray-400">
TimeTracker shall not be liable for any indirect, incidental, special, consequential, or punitive damages resulting from your use of or inability
to use the service.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">7. Termination</h2>
<p class="text-gray-600 dark:text-gray-400">
We reserve the right to terminate or suspend your account at any time, without prior notice, for conduct that we believe violates these Terms and
Conditions or is harmful to other users or the service.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">8. Changes to Terms</h2>
<p class="text-gray-600 dark:text-gray-400">
We reserve the right to modify these Terms and Conditions at any time. Your continued use of TimeTracker after any changes indicates your
acceptance of the new terms.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">9. Contact Information</h2>
<p class="text-gray-600 dark:text-gray-400">
If you have any questions about these Terms and Conditions, please contact us at support@timetracker.com.
</p>
</section>
</div>
<template #footer>
<div class="flex justify-center"></div>
</template>
</UCard>
</div>
</div>
</template>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div
class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
<UIcon name="i-heroicons-shield-check" class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Polityka Prywatności</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Ostatnia aktualizacja: marzec 2026</p>
</div>
<!-- Content Card -->
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
<div class="prose prose-sm sm:prose dark:prose-invert max-w-none space-y-6">
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">1. Wprowadzenie</h2>
<p class="text-gray-600 dark:text-gray-400">
W TimeTracker traktujemy Twoją prywatność poważnie. Niniejsza Polityka Prywatności wyjaśnia, jak gromadzimy, wykorzystujemy, udostępniamy i
chronimy Twoje informacje podczas korzystania z naszej aplikacji.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">2. Informacje, które gromadzimy</h2>
<p class="text-gray-600 dark:text-gray-400">Możemy gromadzić dane osobowe, które dobrowolnie nam podajesz, gdy:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Rejestrujesz konto</li>
<li>Korzystasz z funkcji śledzenia czasu</li>
<li>Tworzysz lub zarządzasz projektami</li>
<li>Generujesz raporty</li>
<li>Kontaktujesz się z naszym zespołem wsparcia</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">3. Jak wykorzystujemy Twoje informacje</h2>
<p class="text-gray-600 dark:text-gray-400">Wykorzystujemy zebrane informacje do:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Świadczenia i utrzymywania naszych usług</li>
<li>Śledzenia Twojego czasu i zarządzania projektami</li>
<li>Ulepszania naszych usług i doświadczenia użytkownika</li>
<li>Komunikowania się z Tobą w sprawach aktualizacji i wsparcia</li>
<li>Wypełniania zobowiązań prawnych</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">4. Przechowywanie i bezpieczeństwo danych</h2>
<p class="text-gray-600 dark:text-gray-400">
Twoje dane bezpiecznie przechowywane z wykorzystaniem szyfrowania zgodnego ze standardami branżowymi. Implementujemy odpowiednie środki
techniczne i organizacyjne w celu ochrony Twoich danych osobowych przed nieautoryzowanym dostępem, zmianą, ujawnieniem lub zniszczeniem.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">5. Udostępnianie danych</h2>
<p class="text-gray-600 dark:text-gray-400">
Nie sprzedajemy, nie wymieniamy ani w inny sposób nie przekazujemy Twoich danych osobowych stronom trzecim. Możemy udostępniać informacje:
</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Dostawcom usług wspierającym nasze działania</li>
<li>Organom prawnym, gdy wymaga tego prawo</li>
<li>Partnerom biznesowym za Twoją zgodą</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">6. Twoje prawa</h2>
<p class="text-gray-600 dark:text-gray-400">Masz prawo do:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Dostępu do swoich danych osobowych</li>
<li>Korekty niedokładnych danych</li>
<li>Żądania usunięcia swoich danych</li>
<li>Eksportu danych w formacie przenośnym</li>
<li>Rezygnacji z komunikacji marketingowej</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">7. Pliki cookies i technologie śledzenia</h2>
<p class="text-gray-600 dark:text-gray-400">
Wykorzystujemy pliki cookies i podobne technologie śledzące, aby poprawić Twoje doświadczenie. Możesz kontrolować pliki cookies poprzez ustawienia
przeglądarki.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">8. Prywatność dzieci</h2>
<p class="text-gray-600 dark:text-gray-400">
Nasza usługa nie jest przeznaczona dla dzieci poniżej 13. roku życia. Świadomie nie gromadzimy danych osobowych od dzieci poniżej 13. roku życia.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">9. Zmiany w niniejszej polityce</h2>
<p class="text-gray-600 dark:text-gray-400">
Możemy aktualizować niniejszą Politykę Prywatności od czasu do czasu. Powiadomimy Cię o wszelkich zmianach poprzez zamieszczenie nowej polityki na
tej stronie i zaktualizowanie daty "Ostatnia aktualizacja".
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">10. Skontaktuj się z nami</h2>
<p class="text-gray-600 dark:text-gray-400">
Jeśli masz jakiekolwiek pytania dotyczące niniejszej Polityki Prywatności, skontaktuj się z nami pod adresem privacy@timetracker.com.
</p>
</section>
</div>
<template #footer>
<div class="flex justify-center"></div>
</template>
</UCard>
</div>
</div>
</template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div
class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8"
>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
<UIcon name="i-heroicons-document-text" class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Regulamin</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Ostatnia aktualizacja: marzec 2026</p>
</div>
<!-- Content Card -->
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
<div class="prose prose-sm sm:prose dark:prose-invert max-w-none space-y-6">
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">1. Akceptacja Regulaminu</h2>
<p class="text-gray-600 dark:text-gray-400">
Korzystając z aplikacji TimeTracker, akceptujesz i zgadzasz się na przestrzeganie warunków i postanowień niniejszej umowy.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">2. Opis Usługi</h2>
<p class="text-gray-600 dark:text-gray-400">
TimeTracker to aplikacja do śledzenia czasu pracy, która umożliwia użytkownikom śledzenie godzin pracy, zarządzanie projektami oraz generowanie
raportów.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">3. Obowiązki Użytkownika</h2>
<p class="text-gray-600 dark:text-gray-400">Zgadzasz się na:</p>
<ul class="list-disc list-inside space-y-2 text-gray-600 dark:text-gray-400 ml-4">
<li>Podawanie dokładnych i kompletnych informacji</li>
<li>Utrzymywanie bezpieczeństwa swojego konta</li>
<li>Nieudostępnianie danych logowania innym osobom</li>
<li>Korzystanie z usługi zgodnie z obowiązującymi przepisami prawa</li>
</ul>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">4. Prywatność i Ochrona Danych</h2>
<p class="text-gray-600 dark:text-gray-400">
Jesteśmy zobowiązani do ochrony Twojej prywatności. Twoje dane osobowe będą przetwarzane zgodnie z naszą Polityką Prywatności oraz obowiązującymi
przepisami o ochronie danych.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">5. Własność Intelektualna</h2>
<p class="text-gray-600 dark:text-gray-400">
Usługa TimeTracker oraz wszystkie jej treści, w tym między innymi teksty, grafika, logo i oprogramowanie, stanowią własność TimeTracker i
chronione przepisami o własności intelektualnej.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">6. Ograniczenie Odpowiedzialności</h2>
<p class="text-gray-600 dark:text-gray-400">
TimeTracker nie ponosi odpowiedzialności za jakiekolwiek pośrednie, przypadkowe, specjalne, następcze lub karne szkody wynikające z korzystania
lub niemożności korzystania z usługi.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">7. Rozwiązanie Umowy</h2>
<p class="text-gray-600 dark:text-gray-400">
zastrzegamy sobie prawo do rozwiązania lub zawieszenia Twojego konta w dowolnym momencie, bez wcześniejszego powiadomienia, za zachowanie, które
narusza niniejszy Regulamin lub jest szkodliwe dla innych użytkowników lub usługi.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">8. Zmiany w Regulaminie</h2>
<p class="text-gray-600 dark:text-gray-400">
zastrzegamy sobie prawo do modyfikacji niniejszego Regulaminu w dowolnym momencie. Dalsze korzystanie z TimeTracker po wprowadzeniu zmian oznacza
akceptację nowych warunków.
</p>
</section>
<section>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">9. Informacje Kontaktowe</h2>
<p class="text-gray-600 dark:text-gray-400">
Jeśli masz jakiekolwiek pytania dotyczące niniejszego Regulaminu, skontaktuj się z nami pod adresem support@timetracker.com.
</p>
</section>
</div>
<template #footer>
<div class="flex justify-center"></div>
</template>
</UCard>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
export const useCookie = () => {
function getCookie(name: string): string | null {
const cookies = document.cookie ? document.cookie.split('; ') : []
for (const cookie of cookies) {
const [key, ...rest] = cookie.split('=')
if (key === name) {
return decodeURIComponent(rest.join('='))
}
}
return null
}
function setCookie(
name: string,
value: string,
options?: {
days?: number
path?: string
domain?: string
secure?: boolean
sameSite?: 'Lax' | 'Strict' | 'None'
},
) {
let cookie = `${name}=${encodeURIComponent(value)}`
if (options?.days) {
const date = new Date()
date.setTime(date.getTime() + options.days * 24 * 60 * 60 * 1000)
cookie += `; expires=${date.toUTCString()}`
}
cookie += `; path=${options?.path ?? '/'}`
if (options?.domain) {
cookie += `; domain=${options.domain}`
}
if (options?.secure) {
cookie += `; Secure`
}
if (options?.sameSite) {
cookie += `; SameSite=${options.sameSite}`
}
document.cookie = cookie
}
function deleteCookie(name: string, path: string = '/', domain?: string) {
let cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}`
if (domain) {
cookie += `; domain=${domain}`
}
document.cookie = cookie
}
return {
getCookie,
setCookie,
deleteCookie,
}
}

View File

@@ -0,0 +1,77 @@
import type { Resp } from '@/types'
export async function useFetchJson<T = unknown>(url: string, opt?: RequestInit): Promise<Resp<T>> {
const prefix = import.meta.env.VITE_API_URL ?? ''
const urlFull = join(prefix, url)
const headers = new Headers(opt?.headers)
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const fetchOptions: RequestInit = {
...opt,
headers,
// Always include cookies so the backend can read the HTTPOnly access_token
credentials: 'same-origin',
}
try {
const res = await fetch(urlFull, fetchOptions)
const contentType = res.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
throw { message: 'this is not proper json format' } as Resp<any>
}
const data = await res.json()
// Handle 401 — access token expired; try to refresh via the HTTPOnly refresh_token cookie
if (res.status === 401) {
const { useAuthStore } = await import('@/stores/auth')
const authStore = useAuthStore()
const refreshed = await authStore.refreshAccessToken()
if (refreshed) {
// Retry the original request — cookies are updated by the refresh endpoint
const retryRes = await fetch(urlFull, fetchOptions)
const retryContentType = retryRes.headers.get('content-type') ?? ''
if (!retryContentType.includes('application/json')) {
throw { message: 'this is not proper json format' } as Resp<any>
}
const retryData = await retryRes.json()
if (!retryRes.ok) {
throw retryData as Resp<any>
}
return retryData as Resp<T>
}
// Refresh failed — logout and propagate the error
authStore.logout()
throw data as Resp<any>
}
if (!res.ok) {
throw data as Resp<any>
}
return data as Resp<T>
} catch (error) {
throw error as Resp<any>
}
}
export function join(...parts: string[]): string {
const path = parts
.filter(Boolean)
.join('/')
.replace(/\/{2,}/g, '/')
return path.startsWith('/') ? path : `/${path}`
}

View File

@@ -0,0 +1,87 @@
import { useFetchJson } from './useFetchJson'
import type { Resp } from '@/types/response'
const API_PREFIX = '/api/v1/repo'
export interface QuarterData {
quarter: string
time: number
}
export interface IssueTimeSummary {
IssueID: number
IssueName: string
UserId: number
Initials: string
CreatedDate: string
TotalHoursSpent: number
}
export interface IssueResponse {
items: IssueTimeSummary[]
count: number
}
export interface PagingParams {
page?: number
pageSize?: number
}
export async function getRepos(): Promise<any> {
const result = await useFetchJson<number[]>(`${API_PREFIX}/get-repos`)
return result
}
// export async function getYears(repoID: number): Promise<any> {
// return useFetchJson<number[]>(`${API_PREFIX}/get-years?repoID=${repoID}`)
// }
// console.log(getYears(), 'leraaaaaa')
export async function getYears(repoID: number): Promise<any> {
return useFetchJson<number[]>(`${API_PREFIX}/get-years?repoID=${repoID}`);
}
// Correct way to log the data
export async function getQuarters(repoID: number, year: number): Promise<any> {
return useFetchJson<QuarterData[]>(`${API_PREFIX}/get-quarters?repoID=${repoID}&year=${year}`)
}
// export async function getIssues(
// repoID: number,
// year: number,
// quarter: number,
// page: number = 1,
// pageSize: number = 10
// ): Promise<any> {
// // The get-issues endpoint uses GET with pagination in query params
// return useFetchJson<IssueResponse>(
// `${API_PREFIX}/get-issues?repoID=${repoID}&year=${year}&quarter=${quarter}&page_number=${page}&elements_per_page=${pageSize}`
// )
// }
// async function logYears() {
// const years = await getIssues(7); // pass a repoID
// console.log(years, 'leraaaaaa');
// }
export async function getIssues(
repoID: number,
year: number,
quarter: number,
page: number = 1,
pageSize: number = 10
): Promise<any> {
return useFetchJson<IssueResponse>(
`${API_PREFIX}/get-issues?repoID=${repoID}&year=${year}&quarter=${quarter}&page_number=${page}&elements_per_page=${pageSize}`
);
}
// Correct logging function
async function logIssues() {
const repoID = 7;
const year = 2026; // example year
const quarter = 1; // example quarter
const issues = await getIssues(repoID, year, quarter);
console.log(issues, 'leraaaaaa');
}
logIssues();

View File

@@ -0,0 +1,98 @@
import type { Ref } from 'vue'
import type { FormError } from '@nuxt/ui'
import { settings } from '@/router/settings'
import { i18n } from '@/plugins/i18n'
export const useValidation = () => {
const errors = [] as FormError[]
function reset() {
errors.length = 0
}
function validateFirstName(first_name_ref: Ref<string>, name: string, message: string) {
if (!first_name_ref.value || !/^[A-Za-z]{2,}$/.test(first_name_ref.value)) {
errors.push({ name: name, message: message })
}
}
function validateLastName(last_name_ref: Ref<string>, name: string, message: string) {
if (!last_name_ref.value || !/^[A-Za-z]{2,}$/.test(last_name_ref.value)) {
errors.push({ name: name, message: message })
}
}
function validateEmail(email_ref: Ref<string>, name: string, message: string) {
if (!email_ref.value || !/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(email_ref.value)) {
errors.push({ name: name, message: message })
}
}
// function validatePasswords(
// password_ref: Ref<string>,
// password_name: string,
// confirm_password_ref: Ref<string>,
// confirm_name: string,
// message_password: string,
// message_confirm_password: string,
// ) {
// const regex = new RegExp(settings.app?.password_regex ?? '^.{8,}$')
// if (!password_ref.value) {
// errors.push({ name: password_name, message: message_password })
// } else if (!regex.test(password_ref.value)) {
// errors.push({
// name: password_name,
// message: 'general.registration_validation_password_requirements'
// })
// }
// if (!confirm_password_ref.value) {
// errors.push({ name: confirm_name, message: message_confirm_password })
// } else if (password_ref.value !== confirm_password_ref.value) {
// errors.push({
// name: confirm_name,
// message: 'registration_validation_password_not_same'
// })
// }
// }
function validatePasswords(
password_ref: Ref<string>,
password_name: string,
confirm_password_ref: Ref<string>,
confirm_name: string,
message_confirm_password: string,
) {
const regexPass = new RegExp(
'^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=[\\]{};:\'",.<>/?]).{8,}$'
)
if (!password_ref.value) {
errors.push({ name: password_name, message: i18n.t('validate_error.password_required') })
} else if (!regexPass.test(password_ref.value)) {
errors.push({
name: password_name,
message: i18n.t('validate_error.registration_validation_password_requirements')
})
}
if (!confirm_password_ref.value) {
errors.push({ name: confirm_name, message: message_confirm_password })
} else if (password_ref.value !== confirm_password_ref.value) {
errors.push({
name: confirm_name,
message: i18n.t('validate_error.registration_validation_password_not_same')
})
}
}
return {
errors,
reset,
validateFirstName,
validateLastName,
validateEmail,
validatePasswords,
}
}

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import TopBarLogin from '@/components/TopBarLogin.vue'
</script>
<template>
<div class="h-screen grid grid-rows-[auto_1fr_auto]">
<!-- <header class="w-full bg-gray-100 text-primary shadow border-b-gray-300 p-4 mb-8">Header</header> -->
<UContainer>
<main class="p-10">
<router-view />
</main>
</UContainer>
</div>
</template>

11
bo/src/layouts/empty.vue Normal file
View File

@@ -0,0 +1,11 @@
<template>
<main :key="i18n.locale.value">
<TopBarLogin />
<router-view />
</main>
</template>
<script setup lang="ts">
import TopBarLogin from '@/components/TopBarLogin.vue'
import { i18n } from '@/plugins/i18n'
import HomeView from '@/views/HomeView.vue';
</script>

17
bo/src/main.ts Normal file
View File

@@ -0,0 +1,17 @@
import './assets/main.css'
import { i18ninstall } from '@/plugins/i18n'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from '@/App.vue'
import ui from '@nuxt/ui/vue-plugin'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ui)
app.use(i18ninstall)
app.mount('#app')

47
bo/src/plugins/i18n.ts Normal file
View File

@@ -0,0 +1,47 @@
import { useFetchJson } from '@/composable/useFetchJson'
import { langs } from '@/router/langs'
import type { Resp } from '@/types'
import { getLangs } from '@/utils/fake'
import { watch } from 'vue'
import { createI18n, type LocaleMessageValue, type PathValue, type VueMessageType } from 'vue-i18n'
// const x =
export const i18ninstall = createI18n({
legacy: false, // you must set `false`, to use Composition API
locale: 'en',
lazy: true,
messages: {},
messageResolver: (obj, path) => {
const value = path
.split('.')
// eslint-disable-next-line
.reduce<unknown>((o, key) => (o as any)?.[key], obj as any)
if (value === '' || value === null || value === undefined) {
return null
}
return value as PathValue
},
})
export const i18n = i18ninstall.global
let downloadedLangs = [] as string[]
watch(
i18n.locale,
async (l) => {
if (!downloadedLangs.includes(l)) {
const lang = langs.find((x) => x.iso_code == l)
if (!lang) return
downloadedLangs.push(l)
const res = await useFetchJson<any>(`/api/v1/translations?lang_id=${lang?.id}&scope=backoffice`)
// console.log(res.items[lang.id as number]['backoffice'])
i18n.setLocaleMessage(l, res.items[lang.id]['backoffice'])
}
},
{},
)

86
bo/src/router/index.ts Normal file
View File

@@ -0,0 +1,86 @@
import { createRouter, createWebHistory } from 'vue-router'
import Default from '@/layouts/default.vue'
import Empty from '@/layouts/empty.vue'
import { currentLang, initLangs, langs } from './langs'
import { getSettings } from './settings'
// Helper: read the non-HTTPOnly is_authenticated cookie set by the backend.
// The backend sets it to "1" on login and removes it on logout.
function isAuthenticated(): boolean {
if (typeof document === 'undefined') return false
return document.cookie.split('; ').some((c) => c === 'is_authenticated=1')
}
await initLangs()
await getSettings()
const router = createRouter({
history: createWebHistory(import.meta.env.VITE_BASE_URL),
routes: [
{
path: '/',
redirect: () => `/${currentLang.value?.iso_code}`,
},
{
path: '/:locale',
children: [
// {
// path: '',
// component: Default,
// children: [
// ],
// },
{
path: '',
component: Empty,
children: [
{ path: '', component: () => import('../views/HomeView.vue'), name: 'home' },
{ path: 'chart', component: () => import('../views/RepoChartView.vue'), name: 'chart' },
{ path: 'login', component: () => import('@/views/LoginView.vue'), name: 'login', meta: { guest: true } },
{ path: 'register', component: () => import('@/views/RegisterView.vue'), name: 'register', meta: { guest: true } },
{ path: 'password-recovery', component: () => import('@/views/PasswordRecoveryView.vue'), name: 'password-recovery', meta: { guest: true } },
{ path: 'reset-password', component: () => import('@/views/ResetPasswordForm.vue'), name: 'reset-password', meta: { guest: true } },
{ path: 'verify-email', component: () => import('@/views/VerifyEmailView.vue'), name: 'verify-email', meta: { guest: true } },
],
},
],
},
],
})
// Navigation guard: language handling + auth protection
router.beforeEach((to, from, next) => {
const locale = to.params.locale as string
const localeLang = langs.find((x) => x.iso_code == locale)
// Check if the locale is valid
if (locale && langs.length > 0) {
const validLocale = langs.find((l) => l.lang_code === locale)
if (validLocale) {
currentLang.value = localeLang
// Auth guard: if the route does NOT have meta.guest = true, require authentication
if (!to.meta?.guest && !isAuthenticated()) {
return next({ name: 'login', params: { locale } })
}
return next()
} else if (locale) {
// Invalid locale - redirect to default language
return next(`/${currentLang.value?.iso_code}${to.path.replace(`/${locale}`, '') || '/'}`)
}
}
// No locale in URL - redirect to default language
if (!locale && to.path !== '/') {
return next(`/${currentLang.value?.iso_code}${to.path}`)
}
next()
})
export default router

30
bo/src/router/langs.ts Normal file
View File

@@ -0,0 +1,30 @@
import { useCookie } from "@/composable/useCookie"
import { useFetchJson } from "@/composable/useFetchJson"
import type { Language } from "@/types"
import { reactive, ref } from "vue"
export const langs = reactive([] as Language[])
export const currentLang = ref<Language>()
const deflang = ref<Language>()
const cookie = useCookie()
// Get available language codes for route matching
// export const availableLocales = computed(() => langs.map((l) => l.lang_code))
// Initialize languages from API
export async function initLangs() {
try {
const { items } = await useFetchJson<Language[]>('/api/v1/langs')
langs.push(...items)
let idfromcookie = null
const cc = cookie.getCookie('lang_id')
if (cc) {
idfromcookie = langs.find((x) => x.id == parseInt(cc))
}
deflang.value = items.find((x) => x.is_default == true)
currentLang.value = idfromcookie ?? deflang.value
} catch (error) {
console.error('Failed to fetch languages:', error)
}
}

11
bo/src/router/settings.ts Normal file
View File

@@ -0,0 +1,11 @@
import { useFetchJson } from "@/composable/useFetchJson";
import type { Resp } from "@/types";
import type { Settings } from "@/types/settings";
import { reactive } from "vue";
export const settings = reactive({} as Settings)
export async function getSettings() {
const { items } = await useFetchJson<Resp<Settings>>('/api/v1/settings',)
Object.assign(settings, items)
}

204
bo/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,204 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useFetchJson } from '@/composable/useFetchJson'
export interface User {
id: string
email: string
name: string
}
interface AuthResponse {
access_token: string
token_type: string
expires_in: number
user: User
}
// Read the non-HTTPOnly is_authenticated cookie set by the backend.
// The backend sets it to "1" on login and removes it on logout.
function readIsAuthenticatedCookie(): boolean {
if (typeof document === 'undefined') return false
return document.cookie.split('; ').some((c) => c === 'is_authenticated=1')
}
export const useAuthStore = defineStore('auth', () => {
// useRouter must be called at the top level of the setup function, not inside a method
// We use window.location as a fallback-safe redirect mechanism instead, to avoid
// the "Cannot read properties of undefined" error when the router is not yet available.
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Auth state is derived from the is_authenticated cookie (set/cleared by backend).
// We use a ref so Vue reactivity works; it is initialised from the cookie on store creation.
const _isAuthenticated = ref<boolean>(readIsAuthenticatedCookie())
const isAuthenticated = computed(() => _isAuthenticated.value)
/** Call after any successful login to sync the reactive flag. */
function _syncAuthState() {
_isAuthenticated.value = readIsAuthenticatedCookie()
}
async function login(email: string, password: string) {
loading.value = true
error.value = null
try {
const data = await useFetchJson<AuthResponse>('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const response = (data as any).items || data
if (!response.access_token) {
throw new Error('No access token received')
}
user.value = response.user
_syncAuthState()
return true
} catch (e: any) {
error.value = e?.error ?? 'An error occurred'
return false
} finally {
loading.value = false
}
}
async function register(
first_name: string,
last_name: string,
email: string,
password: string,
confirm_password: string,
lang?: string,
) {
loading.value = true
error.value = null
try {
await useFetchJson('/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ first_name, last_name, email, password, confirm_password, lang: lang || 'en' }),
})
return { success: true, requiresVerification: true }
} catch (e: any) {
error.value = e?.error ?? 'An error occurred'
return { success: false, requiresVerification: false }
} finally {
loading.value = false
}
}
async function requestPasswordReset(email: string) {
loading.value = true
error.value = null
try {
await useFetchJson('/api/v1/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
return true
} catch (e: any) {
error.value = e?.error ?? 'An error occurred'
return false
} finally {
loading.value = false
}
}
async function resetPassword(token: string, password: string) {
loading.value = true
error.value = null
try {
await useFetchJson('/api/v1/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
return true
} catch (e: any) {
error.value = e?.error ?? 'An error occurred'
return false
} finally {
loading.value = false
}
}
function loginWithGoogle() {
window.location.href = '/api/v1/auth/google'
}
/**
* Logout: calls the backend to revoke the refresh token and clear HTTPOnly cookies,
* clears local reactive state, then redirects to the login page.
*/
async function logout() {
try {
await useFetchJson('/api/v1/auth/logout', {
method: 'POST',
})
} catch {
// Continue with local cleanup even if the backend call fails
} finally {
user.value = null
_isAuthenticated.value = false
// Use dynamic import to get the router instance safely from outside the setup context
const { default: router } = await import('@/router')
router.push({ name: 'login' })
}
}
/**
* Refresh the access token by calling the backend.
* The backend reads the HTTPOnly refresh_token cookie, rotates it, and sets new cookies.
* Returns true on success.
*/
async function refreshAccessToken(): Promise<boolean> {
try {
await useFetchJson('/api/v1/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// No body needed — the backend reads the refresh_token from the HTTPOnly cookie
})
_syncAuthState()
return true
} catch {
// Refresh failed — clear local state
user.value = null
_isAuthenticated.value = false
return false
}
}
function clearError() {
error.value = null
}
return {
user,
loading,
error,
isAuthenticated,
login,
loginWithGoogle,
register,
requestPasswordReset,
resetPassword,
logout,
refreshAccessToken,
clearError,
}
})

14
bo/src/stores/settings.ts Normal file
View File

@@ -0,0 +1,14 @@
import { useFetchJson } from '@/composable/useFetchJson'
import type { Resp } from '@/types'
import type { Settings } from '@/types/settings'
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('settings', () => {
async function getSettings() {
const { items } = await useFetchJson<Resp<Settings>>('/api/v1/settings',)
console.log(items);
}
getSettings()
return {}
})

47
bo/src/stores/theme.ts Normal file
View File

@@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useThemeStore = defineStore('theme', () => {
const vueuseColorScheme = ref(localStorage.getItem('vueuse-color-scheme'))
const themeIcon = computed(() => {
switch (true) {
case vueuseColorScheme.value == 'light':
return 'i-heroicons-sun'
case vueuseColorScheme.value == 'dark':
return 'i-heroicons-moon'
case vueuseColorScheme.value == 'auto':
return 'i-heroicons-computer-desktop'
}
})
function setTheme() {
switch (true) {
case localStorage.getItem('vueuse-color-scheme') == 'dark':
vueuseColorScheme.value = 'light'
localStorage.setItem('vueuse-color-scheme', 'light')
document.documentElement.classList.toggle('dark', false)
break
case localStorage.getItem('vueuse-color-scheme') == 'light':
vueuseColorScheme.value = 'dark'
localStorage.setItem('vueuse-color-scheme', 'dark')
document.documentElement.classList.toggle('dark', true)
break
case localStorage.getItem('vueuse-color-scheme') == 'auto':
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
vueuseColorScheme.value = 'light'
localStorage.setItem('vueuse-color-scheme', 'light')
document.documentElement.classList.toggle('dark', false)
} else {
vueuseColorScheme.value = 'light'
localStorage.setItem('vueuse-color-scheme', 'light')
document.documentElement.classList.toggle('dark', false)
}
break
}
}
return {
vueuseColorScheme,
setTheme,
themeIcon,
}
})

2
bo/src/types/index.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export * from '@types/lang'
export * from '@types/response'

14
bo/src/types/lang.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
export interface Language {
id: number
created_at: string
updated_at: string
name: string
iso_code: string
lang_code: string
date_format: string
date_format_short: string
rtl: boolean
is_default: boolean
active: boolean
flag: string
}

5
bo/src/types/response.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export interface Resp<T> {
message: string
items: T
count?: number
}

35
bo/src/types/settings.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
export interface Settings {
app: App
server: Server
auth: Auth
features: Features
version: Version
}
export interface App {
name: string
environment: string
base_url: string
password_regex: string
}
export interface Server {
port: number
host: string
}
export interface Auth {
jwt_expiration: number
refresh_expiration: number
}
export interface Features {
email_enabled: boolean
oauth_google: boolean
}
export interface Version {
version: string
commit: string
build_date: string
}

293
bo/src/types/ui-app.config.d.ts vendored Normal file
View File

@@ -0,0 +1,293 @@
import type { AppConfigInput, CustomAppConfig } from 'nuxt/schema'
import type { Defu } from 'defu'
import cfg0 from "../../app/app.config"
declare global {
const defineAppConfig: <C extends AppConfigInput>(config: C) => C
}
declare const inlineConfig = {
nuxt: {},
ui: {
colors: {
primary: "green",
secondary: "blue",
success: "green",
info: "blue",
warning: "yellow",
error: "red",
neutral: "slate"
},
icons: {
arrowDown: "i-lucide-arrow-down",
arrowLeft: "i-lucide-arrow-left",
arrowRight: "i-lucide-arrow-right",
arrowUp: "i-lucide-arrow-up",
caution: "i-lucide-circle-alert",
check: "i-lucide-check",
chevronDoubleLeft: "i-lucide-chevrons-left",
chevronDoubleRight: "i-lucide-chevrons-right",
chevronDown: "i-lucide-chevron-down",
chevronLeft: "i-lucide-chevron-left",
chevronRight: "i-lucide-chevron-right",
chevronUp: "i-lucide-chevron-up",
close: "i-lucide-x",
copy: "i-lucide-copy",
copyCheck: "i-lucide-copy-check",
dark: "i-lucide-moon",
drag: "i-lucide-grip-vertical",
ellipsis: "i-lucide-ellipsis",
error: "i-lucide-circle-x",
external: "i-lucide-arrow-up-right",
eye: "i-lucide-eye",
eyeOff: "i-lucide-eye-off",
file: "i-lucide-file",
folder: "i-lucide-folder",
folderOpen: "i-lucide-folder-open",
hash: "i-lucide-hash",
info: "i-lucide-info",
light: "i-lucide-sun",
loading: "i-lucide-loader-circle",
menu: "i-lucide-menu",
minus: "i-lucide-minus",
panelClose: "i-lucide-panel-left-close",
panelOpen: "i-lucide-panel-left-open",
plus: "i-lucide-plus",
reload: "i-lucide-rotate-ccw",
search: "i-lucide-search",
stop: "i-lucide-square",
success: "i-lucide-circle-check",
system: "i-lucide-monitor",
tip: "i-lucide-lightbulb",
upload: "i-lucide-upload",
warning: "i-lucide-triangle-alert"
},
tv: {
twMergeConfig: {}
}
},
icon: {
provider: "iconify",
class: "",
aliases: {},
iconifyApiEndpoint: "https://api.iconify.design",
localApiEndpoint: "/api/_nuxt_icon",
fallbackToApi: true,
cssSelectorPrefix: "i-",
cssWherePseudo: true,
cssLayer: "components",
mode: "css",
attrs: {
"aria-hidden": true
},
collections: [
"academicons",
"akar-icons",
"ant-design",
"arcticons",
"basil",
"bi",
"bitcoin-icons",
"bpmn",
"brandico",
"bx",
"bxl",
"bxs",
"bytesize",
"carbon",
"catppuccin",
"cbi",
"charm",
"ci",
"cib",
"cif",
"cil",
"circle-flags",
"circum",
"clarity",
"codicon",
"covid",
"cryptocurrency",
"cryptocurrency-color",
"dashicons",
"devicon",
"devicon-plain",
"ei",
"el",
"emojione",
"emojione-monotone",
"emojione-v1",
"entypo",
"entypo-social",
"eos-icons",
"ep",
"et",
"eva",
"f7",
"fa",
"fa-brands",
"fa-regular",
"fa-solid",
"fa6-brands",
"fa6-regular",
"fa6-solid",
"fad",
"fe",
"feather",
"file-icons",
"flag",
"flagpack",
"flat-color-icons",
"flat-ui",
"flowbite",
"fluent",
"fluent-emoji",
"fluent-emoji-flat",
"fluent-emoji-high-contrast",
"fluent-mdl2",
"fontelico",
"fontisto",
"formkit",
"foundation",
"fxemoji",
"gala",
"game-icons",
"geo",
"gg",
"gis",
"gravity-ui",
"gridicons",
"grommet-icons",
"guidance",
"healthicons",
"heroicons",
"heroicons-outline",
"heroicons-solid",
"hugeicons",
"humbleicons",
"ic",
"icomoon-free",
"icon-park",
"icon-park-outline",
"icon-park-solid",
"icon-park-twotone",
"iconamoon",
"iconoir",
"icons8",
"il",
"ion",
"iwwa",
"jam",
"la",
"lets-icons",
"line-md",
"logos",
"ls",
"lucide",
"lucide-lab",
"mage",
"majesticons",
"maki",
"map",
"marketeq",
"material-symbols",
"material-symbols-light",
"mdi",
"mdi-light",
"medical-icon",
"memory",
"meteocons",
"mi",
"mingcute",
"mono-icons",
"mynaui",
"nimbus",
"nonicons",
"noto",
"noto-v1",
"octicon",
"oi",
"ooui",
"openmoji",
"oui",
"pajamas",
"pepicons",
"pepicons-pencil",
"pepicons-pop",
"pepicons-print",
"ph",
"pixelarticons",
"prime",
"ps",
"quill",
"radix-icons",
"raphael",
"ri",
"rivet-icons",
"si-glyph",
"simple-icons",
"simple-line-icons",
"skill-icons",
"solar",
"streamline",
"streamline-emojis",
"subway",
"svg-spinners",
"system-uicons",
"tabler",
"tdesign",
"teenyicons",
"token",
"token-branded",
"topcoat",
"twemoji",
"typcn",
"uil",
"uim",
"uis",
"uit",
"uiw",
"unjs",
"vaadin",
"vs",
"vscode-icons",
"websymbol",
"weui",
"whh",
"wi",
"wpf",
"zmdi",
"zondicons"
],
fetchTimeout: 1500
}
}
type ResolvedAppConfig = Defu<typeof inlineConfig, [typeof cfg0]>
type IsAny<T> = 0 extends 1 & T ? true : false
type MergedAppConfig<
Resolved extends Record<string, unknown>,
Custom extends Record<string, unknown>
> = {
[K in keyof (Resolved & Custom)]: K extends keyof Custom
? unknown extends Custom[K]
? Resolved[K]
: IsAny<Custom[K]> extends true
? Resolved[K]
: Custom[K] extends Record<string, any>
? Resolved[K] extends Record<string, any>
? MergedAppConfig<Resolved[K], Custom[K]>
: Exclude<Custom[K], undefined>
: Exclude<Custom[K], undefined>
: Resolved[K]
}
declare module 'nuxt/schema' {
interface AppConfig extends MergedAppConfig<ResolvedAppConfig, CustomAppConfig> {}
}
declare module '@nuxt/schema' {
interface AppConfig extends MergedAppConfig<ResolvedAppConfig, CustomAppConfig> {}
}

23
bo/src/utils/fake.ts Normal file
View File

@@ -0,0 +1,23 @@
export const getLangs = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
'some.key': 'this is translated text',
'key.two': 'this is second key',
'routing.login': 'Routing Translation',
})
}, 200)
})
}
export const getL = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'pl', name: 'Polski', flag: '🇵🇱' },
{ code: 'cs', name: 'Čeština', flag: '🇨🇿' },
])
}, Math.random() * 1000)
})
}

15
bo/src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,15 @@
<script setup lang="ts"></script>
<template>
<main class="flex gap-4">
<RouterLink class="bg-(--color-blue-600) dark:bg-(--color-blue-500) px-2 py-1 rounded text-white flex items-center shadow-md"
:to="{ name: 'login' }">Login
</RouterLink>
<RouterLink class="bg-(--color-blue-600) dark:bg-(--color-blue-500) px-2 py-1 rounded text-white flex items-center shadow-md"
:to="{ name: 'register' }">
Register</RouterLink>
<RouterLink class="bg-(--color-blue-600) dark:bg-(--color-blue-500) px-2 py-1 rounded text-white flex items-center shadow-md"
:to="{ name: 'chart' }">Chart
</RouterLink>
</main>
</template>

159
bo/src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/plugins/i18n'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const showPassword = ref(false)
const validation = useValidation()
async function handleLogin() {
const success = await authStore.login(email.value, password.value)
if (success) {
const redirectTo = route.query.redirect as string
router.push(redirectTo || { name: 'chart' })
}
}
function goToRegister() {
router.push({ name: 'register' })
}
function goToPasswordRecovery() {
router.push({ name: 'password-recovery' })
}
function validate(): FormError[] {
validation.reset()
validation.validateEmail(email, 'email', i18n.t('validate_error.email_required'))
if (!password.value) {
validation.errors.push({ name: 'password', message: i18n.t('validate_error.password_required') })
}
return validation.errors
}
const showTherms = ref(false)
const showPrivacy = ref(false)
const TermsComponent = computed(() =>
defineAsyncComponent(() =>
import(`@/components/terms/${i18n.locale.value}_TermsAndConditionsView.vue`).catch(() => import('@/components/terms/en_TermsAndConditionsView.vue')),
),
)
const PrivacyComponent = computed(() =>
defineAsyncComponent(() =>
import(`@/components/terms/${i18n.locale.value}_PrivacyPolicyView.vue`).catch(() => import('@/components/terms/en_PrivacyPolicyView.vue')),
),
)
</script>
<template>
<UDrawer v-model:open="showTherms" :overlay="false">
<template #body>
<component :is="TermsComponent" />
</template>
<template #footer>
<UButton @click="showTherms = false" class="mx-auto px-12">close</UButton>
</template>
</UDrawer>
<!-- PrivacyPolicyView -->
<UDrawer v-model:open="showPrivacy" :overlay="false">
<template #body>
<component :is="PrivacyComponent" />
</template>
<template #footer>
<UButton @click="showPrivacy = false" class="mx-auto px-12">close</UButton>
</template>
</UDrawer>
<div class="h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md flex flex-col gap-4">
<UForm :validate="validate" @submit="handleLogin" class="space-y-5">
<UAlert v-if="authStore.error" color="error" variant="subtle" :title="authStore.error"
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', color: 'gray', variant: 'link' }"
@close="authStore.clearError" />
<UFormField :label="$t('general.email_address')" name="email" required
class="w-full dark:text-white text-black">
<UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading"
class="w-full dark:text-white text-black" />
</UFormField>
<UFormField :label="$t('general.password')" name="password" required class="w-full dark:text-white text-black">
<UInput v-model="password" :placeholder="$t('general.enter_your_password')"
:type="showPassword ? 'text' : 'password'" class="w-full" :ui="{ trailing: 'pe-1' }">
<template #trailing>
<UIcon color="neutral" variant="link" size="sm"
:name="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="showPassword ? 'Hide password' : 'Show password'" :aria-pressed="showPassword"
aria-controls="password" @click="showPassword = !showPassword" />
</template>
</UInput>
</UFormField>
<div class="flex items-center justify-between w-full dark:text-white text-black">
<button variant="link" size="sm" @click="goToPasswordRecovery" class="text-[15px] w-full flex justify-end text-(--color-blue-600) dark:text-(--color-blue-500)">
{{$t('general.forgot_password')}}?
</button>
</div>
<UButton type="submit" :loading="authStore.loading"
class="w-full flex justify-center text-white bg-(--color-blue-600) dark:bg-(--color-blue-500)">
{{ $t('general.sign_in') }}
</UButton>
</UForm>
<!-- Divider -->
<div class="flex items-center gap-3 my-1">
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
<span class="text-xs text-gray-400 dark:text-gray-500">or</span>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
</div>
<!-- Google Sign In -->
<UButton type="button" color="neutral" variant="outline" size="lg" block :disabled="authStore.loading"
@click="authStore.loginWithGoogle()" class="flex items-center justify-center gap-2 dark:text-white text-black">
<svg class="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4" />
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853" />
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
fill="#FBBC05" />
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335" />
</svg>
{{ $t('general.continue_with_google') }}
</UButton>
<div class="text-center">
<p class="dark:text-white text-black">
{{$t('general.dont_have_an_account')}}?
<button variant="link" size="sm" class="text-[15px] text-(--color-blue-600) dark:text-(--color-blue-500)" @click="goToRegister">{{ $t('general.create_account_now') }}</button>
</p>
</div>
<p class="mt-8 text-center text-xs dark:text-white text-black">
{{ $t('general.by_signing_in_you_agree_to_our') }}
<span @click="showTherms = !showTherms"
class="cursor-pointer underline text-(--color-blue-600) dark:text-(--color-blue-500)">{{
$t('general.terms_of_service') }}</span>
{{ $t('general.and') }}
<span @click="showPrivacy = !showPrivacy"
class="cursor-pointer underline text-(--color-blue-600) dark:text-(--color-blue-500)">{{
$t('general.privacy_policy') }}</span>
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/plugins/i18n'
const { t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
const validation = useValidation()
const email = ref('')
const submitted = ref(false)
async function handleRecover() {
const success = await authStore.requestPasswordReset(email.value)
if (success) {
submitted.value = true
}
}
function goToLogin() {
router.push({ name: 'login' })
}
function goToRegister() {
router.push({ name: 'register' })
}
function validate(): FormError[] {
validation.reset()
validation.validateEmail(email, 'email', i18n.t('validate_error.email_required'))
return validation.errors
}
</script>
<template>
<div class="h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md flex flex-col gap-4">
<!-- Success State -->
<template v-if="submitted">
<div class="text-center flex flex-col gap-4">
<UIcon name="i-heroicons-envelope" class="w-12 h-12 mx-auto text-primary-500" />
<h2 class="text-xl font-semibold dark:text-white text-black">{{ $t('general.check_your_email') }}</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t('general.password_reset_link_sent_notice') }}
</p>
<UButton color="neutral" variant="outline" block @click="goToLogin" class="dark:text-white text-black">
{{ $t('general.back_to_sign_in') }}
</UButton>
</div>
</template>
<!-- Form State -->
<template v-else>
<div class="text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t('general.enter_email_for_password_reset') }}
</p>
</div>
<UForm :validate="validate" @submit="handleRecover" class="flex flex-col gap-3">
<UAlert v-if="authStore.error" color="error" variant="subtle" icon="i-heroicons-exclamation-triangle"
:title="authStore.error" :close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
@close="authStore.clearError" />
<UFormField :label="$t('general.email_address')" name="email" required
class="w-full dark:text-white text-black">
<UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading"
class="w-full dark:text-white text-black" />
</UFormField>
<UButton type="submit" block :loading="authStore.loading"
class="text-white bg-(--color-blue-600) dark:bg-(--color-blue-500)">
{{ $t('general.send_password_reset_link') }}
</UButton>
</UForm>
<div class="text-center flex flex-col gap-3 border-t dark:border-(--border-dark) border-(--border-light) pt-4">
<UButton color="neutral" variant="outline" :loading="authStore.loading"
class="w-full flex justify-center dark:text-white text-black" @click="goToLogin">
{{ $t('general.back_to_sign_in') }}
</UButton>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t('general.dont_have_an_account') }}
<UButton variant="link" size="sm" @click="goToRegister"
class="text-(--color-blue-600) dark:text-(--color-blue-500)">{{ $t('general.create_account_now') }}
</UButton>
</p>
</div>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,155 @@
<template>
<UDrawer v-model:open="showTherms" :overlay="false">
<template #body>
<component :is="TermsComponent" />
</template>
<template #footer>
<UButton @click="showTherms = false" class="mx-auto px-12">close</UButton>
</template>
</UDrawer>
<!-- PrivacyPolicyView -->
<UDrawer v-model:open="showPrivacy" :overlay="false">
<template #body>
<component :is="PrivacyComponent" />
</template>
<template #footer>
<UButton @click="showPrivacy = false" class="mx-auto px-12">close</UButton>
</template>
</UDrawer>
<div class="h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md">
<UForm :validate="validate" @submit="handleRegister" class="flex flex-col gap-3">
<UAlert v-if="authStore.error" color="error" variant="subtle" icon="i-heroicons-exclamation-triangle"
:title="authStore.error" :close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
@close="authStore.clearError" />
<UFormField :label="$t('general.first_name')" name="first_name" required class="w-full dark:text-white text-black ">
<UInput class="w-full" v-model="first_name" type="text" :placeholder="$t('general.first_name')"
:disabled="authStore.loading">
</UInput>
</UFormField>
<UFormField :label="$t('general.last_name')" name="last_name" required class="w-full dark:text-white text-black">
<UInput class="w-full dark:text-white text-black" v-model="last_name" type="text" :placeholder="$t('general.last_name')"
:disabled="authStore.loading">
</UInput>
</UFormField>
<UFormField :label="$t('general.email_address')" name="email" required class="w-full dark:text-white text-black">
<UInput v-model="email" :placeholder="$t('general.enter_your_email')" :disabled="authStore.loading"
class="w-full dark:text-white text-black" />
</UFormField>
<UFormField :label="$t('general.password')" name="password" required class="w-full dark:text-white text-black">
<UInput v-model="password" :placeholder="$t('general.enter_your_password')" :disabled="authStore.loading"
class="w-full dark:text-white text-black" :type="showPassword ? 'text' : 'password'" :ui="{ trailing: 'pe-1' }">
<template #trailing>
<UIcon color="neutral" variant="link" size="sm"
:name="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="showPassword ? 'Hide password' : 'Show password'" :aria-pressed="showPassword"
aria-controls="password" @click="showPassword = !showPassword" />
</template>
</UInput>
</UFormField>
<UFormField :label="$t('general.confirm_password')" name="confirm_password" required class="w-full dark:text-white text-black">
<UInput v-model="confirm_password_ref" :type="showConfirmPassword ? 'text' : 'password'" class="w-full dark:text-white text-black"
:placeholder="$t('general.confirm_your_password')" :disabled="authStore.loading" :ui="{ trailing: 'pe-1' }">
<template #trailing>
<UIcon color="neutral" variant="ghost" size="sm"
:name="showConfirmPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
@click="showConfirmPassword = !showConfirmPassword" />
</template>
</UInput>
</UFormField>
<UCheckbox v-model="acceptTerms" class="label mb-3">
<template #label>
<span class="dark:text-white text-black">
{{ $t('general.i_agree_to_the') }}
<span @click="showTherms = !showTherms" class="cursor-pointer underline text-(--color-blue-600) dark:text-(--color-blue-500)">{{ $t('general.terms_of_service')
}}</span>
{{ $t('general.and') }}
<span @click="showPrivacy = !showPrivacy" class="cursor-pointer underline text-(--color-blue-600) dark:text-(--color-blue-500)">{{ $t('general.privacy_policy')
}}</span>
</span>
</template>
</UCheckbox>
<UButton type="submit" block :loading="authStore.loading" :disabled="!acceptTerms" class="text-white bg-(--color-blue-600) dark:bg-(--color-blue-500)">
{{ $t('general.create_account') }}
</UButton>
<div class="text-center flex flex-col gap-3 border-t dark:border-(--border-dark) border-(--border-light) pt-4">
<p class="dark:text-white text-black">
{{ $t('general.already_have_an_account') }}
</p>
<UButton color="neutral" variant="outline" :loading="authStore.loading" class="w-full flex justify-center dark:text-white hover:text-white hover:bg-(--color-blue-600) dark:hover:bg-(--color-blue-500) border border-(--border-light)! dark:border-(--border-dark)!"
@click="goToLogin">{{ $t('general.sign_in') }}</UButton>
</div>
</UForm>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/plugins/i18n'
const { locale } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
const acceptTerms = ref(false)
const showConfirmPassword = ref(false)
const showPassword = ref(false)
const validation = useValidation()
const first_name = ref('')
const last_name = ref('')
const email = ref('')
const password = ref('')
const confirm_password_ref = ref('')
async function handleRegister() {
const result = await authStore.register(first_name.value, last_name.value, email.value, password.value, confirm_password_ref.value, locale.value)
if (result?.success) {
router.push({ name: 'login', query: { registered: 'true' } })
}
}
function goToLogin() {
router.push({ name: 'login' })
}
function validate(): FormError[] {
validation.reset()
validation.validateFirstName(first_name, 'first_name', i18n.t('validate_error.first_name_required'))
validation.validateLastName(last_name, 'last_name', i18n.t('validate_error.last_name_required'))
validation.validateEmail(email, 'email', i18n.t('validate_error.email_required'))
validation.validatePasswords(password, 'password', confirm_password_ref, 'confirm_password', i18n.t('validate_error.confirm_password_required'))
return validation.errors
}
const showTherms = ref(false)
const showPrivacy = ref(false)
const TermsComponent = computed(() =>
defineAsyncComponent(() =>
import(`@/components/terms/${i18n.locale.value}_TermsAndConditionsView.vue`).catch(() => import('@/components/terms/en_TermsAndConditionsView.vue')),
),
)
const PrivacyComponent = computed(() =>
defineAsyncComponent(() =>
import(`@/components/terms/${i18n.locale.value}_PrivacyPolicyView.vue`).catch(() => import('@/components/terms/en_PrivacyPolicyView.vue')),
),
)
</script>

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, type Ref } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
} from 'chart.js'
import { getRepos, getYears, getQuarters, getIssues, type QuarterData, type IssueTimeSummary } from '@/composable/useRepoApi'
import { useAuthStore } from '@/stores/auth'
import { i18n } from '@/plugins/i18n'
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
const authStore = useAuthStore()
const repos = ref<number[]>([])
const years = ref<number[]>([])
const quarters = ref<QuarterData[]>([])
const issues = ref<IssueTimeSummary[]>([])
const selectedRepo = ref<number | null>(null)
const selectedYear = ref<number | null>(null)
const selectedQuarter = ref<string | null>(null)
const page = ref(1)
const totalItems = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
async function loadData<T>(fetchFn: () => Promise<any>, target: Ref<T[]>, errorMsg: string) {
loading.value = true
error.value = null
try {
const response = await fetchFn()
target.value = response.items || response || []
} catch (e: any) {
error.value = e?.message || errorMsg
} finally {
loading.value = false
}
}
onMounted(() => loadData(() => getRepos(), repos, i18n.t('repo_chart.failed_to_load_repositories')))
watch(selectedRepo, async (newRepo) => {
selectedYear.value = null
selectedQuarter.value = null
quarters.value = []
issues.value = []
if (newRepo) {
await loadData(() => getYears(newRepo), years, i18n.t('repo_chart.failed_to_load_years'))
}
})
watch(selectedYear, async (newYear) => {
selectedQuarter.value = null
issues.value = []
if (newYear && selectedRepo.value) {
await loadData(() => getQuarters(selectedRepo.value!, newYear), quarters, i18n.t('repo_chart.failed_to_load_quarters'))
}
})
watch(selectedQuarter, async (newQuarter) => {
if (newQuarter && selectedRepo.value && selectedYear.value) {
await loadIssues(selectedRepo.value, selectedYear.value, newQuarter)
} else {
issues.value = []
}
})
watch(page, () => {
if (selectedRepo.value && selectedYear.value && selectedQuarter.value) {
loadIssues(selectedRepo.value, selectedYear.value, selectedQuarter.value)
}
})
async function loadIssues(repoID: number, year: number, quarterStr: string) {
const quarterPart = quarterStr.split('_Q')[1]
if (!quarterPart) return
loading.value = true
error.value = null
try {
const response = await getIssues(repoID, year, parseInt(quarterPart), page.value, 50)
issues.value = response.items || []
totalItems.value = response.items_count || 0
} catch (e: any) {
error.value = e?.message || i18n.t('repo_chart.failed_to_load_issues')
} finally {
loading.value = false
}
}
const chartData = computed(() => ({
labels: quarters.value.map((q) => q.quarter),
datasets: [
{
label: i18n.t('repo_chart.hours_worked'),
backgroundColor: '#3b82f6',
data: quarters.value.map((q) => q.time),
},
],
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' as const },
title: { display: true, text: i18n.t('repo_chart.work_by_quarter') },
},
scales: {
y: { beginAtZero: true, title: { display: true, text: i18n.t('repo_chart.hours') } },
},
}
const hasData = computed(() => quarters.value.length > 0)
const hasIssues = computed(() => issues.value.length > 0)
const items = computed(() => repos.value.map(r => ({ value: r, label: `Repo ${r}` })))
const yearItems = computed(() => [
{ value: null, label: i18n.t('repo_chart.select_a_year') },
...years.value.map(y => ({ value: y, label: String(y) }))
])
const quarterItems = computed(() => [
{ value: null, label: i18n.t('repo_chart.all_quarters') },
...quarters.value.map(q => ({
value: q.quarter,
label: `${q.quarter} (${q.time.toFixed(1)}h)`
}))
])
</script>
<template>
<div class="container">
<div class="p-6 bg-white dark:bg-(--black) min-h-screen font-sans">
<h1 class="text-2xl font-bold mb-6 text-black">{{ $t('repo_chart.repository_work_chart') }}</h1>
<div v-if="error" class="mb-4 p-3 bg-red-100 text-red-700 rounded">
{{ error }}
</div>
<div v-if="loading" class="mb-4 p-3 bg-blue-100 text-blue-700 rounded">
{{ $t('repo_chart.loading') }}...
</div>
<div v-if="!authStore.isAuthenticated" class="mb-4 p-3 bg-yellow-100 text-yellow-700 rounded">
<!-- Please log in to view repository work charts. -->
{{ $t('repo_chart.login_to_view_charts') }}
</div>
<div v-if="authStore.isAuthenticated" class="flex flex-wrap gap-4 mb-6">
<div class="flex flex-col min-w-[192px]">
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ $t('repo_chart.repository')
}}</label>
<USelect v-model="selectedRepo" :items="items" :disabled="loading"
:placeholder="$t('repo_chart.select_a_repository')" class="dark:text-white text-black "/>
<!-- Select a repository -->
</div>
<div class="flex flex-col min-w-[160px]">
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ $t('repo_chart.year')
}}</label>
<USelect v-model="selectedYear" :items="yearItems"
:disabled="loading || !selectedRepo || years.length === 0"
:placeholder="$t('repo_chart.select_a_year')" class="dark:text-white text-black "/>
</div>
<div class="flex flex-col min-w-[192px]">
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ $t('repo_chart.quarter')
}}</label>
<USelect v-model="selectedQuarter" :items="quarterItems"
:disabled="loading || !selectedYear || quarters.length === 0"
:placeholder="$t('repo_chart.all_quarters')" class="dark:text-white text-black "/>
</div>
</div>
<div v-if="hasData && authStore.isAuthenticated"
class="mb-6 p-4 border border-(--border-light) dark:border-(--border-dark) rounded">
<h2 class="text-xl font-medium mb-4 text-black dark:text-white">{{
$t('repo_chart.work_done_by_quarter') }}</h2>
<div class="h-80">
<Bar :data="chartData" :options="chartOptions" />
</div>
</div>
<div v-if="hasIssues && authStore.isAuthenticated"
class="p-4 border border-(--border-light) dark:border-(--border-dark) rounded">
<h2 class="text-xl font-medium mb-4 text-black dark:text-white">{{ $t('repo_chart.issues_for') }} {{
selectedQuarter }}
</h2>
<table class="w-full border border-(--border-light) dark:border-(--border-dark)">
<thead>
<tr class="bg-gray-100 dark:bg-(--black)">
<th
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
ID</th>
<th
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
{{ $t('repo_chart.issue_name') }}</th>
<th
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
{{ $t('repo_chart.user_initials') }}</th>
<th
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
{{ $t('repo_chart.created_on') }}</th>
<th
class="p-3 text-left text-xs font-bold text-gray-600 dark:text-white uppercase border border-(--border-light) dark:border-(--border-dark)">
{{ $t('repo_chart.hours_spent') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="issue in issues" :key="issue.IssueID"
class=" border-b border-(--border-light) dark:border-(--border-dark)">
<td class="p-3 text-black dark:text-white">{{ issue.IssueID }}</td>
<td class="p-3 text-black dark:text-white">{{ issue.IssueName }}</td>
<td class="p-3 text-black dark:text-white">{{ issue.Initials }}</td>
<td class="p-3 text-black dark:text-white">{{ issue.CreatedDate }}</td>
<td class="p-3 text-black dark:text-white">{{ issue.TotalHoursSpent }}h</td>
</tr>
</tbody>
</table>
<div class="pt-4 flex justify-center items-center dark:text-white!">
<UPagination v-model:page="page" :total="totalItems" />
</div>
</div>
<div v-else-if="selectedQuarter && !loading && authStore.isAuthenticated && !hasIssues"
class="mt-4 p-3 dark:bg-(--black) bg-white border border-(--border-light) dark:border-(--border-dark) dark:text-white text-black rounded">
{{ $t('validate_error.no_issues_for_quarter') }}.
</div>
<div v-else-if="!loading && authStore.isAuthenticated" class="p-3 dark:bg-(--black) bg-white border border-(--border-light) dark:border-(--border-dark) rounded">
<span v-if="!selectedRepo">{{ $t('repo_chart.select_repo_to_view_data') }}</span>
<span v-else-if="!selectedYear">{{ $t('repo_chart.select_year_to_view_data') }}</span>
<span v-else-if="!selectedQuarter">{{ $t('repo_chart.select_quarter_to_view_issues') }}</span>
<span v-else-if="quarters.length === 0">{{ $t('repo_chart.no_work_data_available') }}</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useValidation } from '@/composable/useValidation'
import type { FormError } from '@nuxt/ui'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/plugins/i18n'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const validation = useValidation()
const new_password = ref('')
const confirm_new_password = ref('')
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
const resetToken = ref('')
const submitted = ref(false)
onMounted(() => {
resetToken.value = (route.query.token as string) || ''
if (!resetToken.value) {
router.push({ name: 'password-recovery' })
}
})
async function handleReset() {
const success = await authStore.resetPassword(resetToken.value, new_password.value)
if (success) {
submitted.value = true
}
}
function goToLogin() {
router.push({ name: 'login' })
}
function validate(): FormError[] {
validation.reset()
validation.validatePasswords(
new_password,
'new_password',
confirm_new_password,
'confirm_new_password',
i18n.t('validate_error.confirm_password_required'),
)
return validation.errors
}
</script>
<template>
<div class="h-[100vh] flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md flex flex-col gap-4">
<!-- Success State -->
<template v-if="submitted">
<div class="text-center flex flex-col gap-4">
<UIcon name="i-heroicons-check-circle" class="w-12 h-12 mx-auto text-green-500" />
<h2 class="text-xl font-semibold dark:text-white text-black">
{{ $t('general.password_updated') }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ $t('general.password_updated_description') }}
</p>
<UButton block @click="goToLogin" class="dark:text-white text-black">
{{ $t('general.back_to_sign_in') }}
</UButton>
</div>
</template>
<!-- Form State -->
<template v-else>
<UForm :validate="validate" @submit="handleReset" class="flex flex-col gap-3">
<UAlert v-if="authStore.error" color="error" variant="subtle"
icon="i-heroicons-exclamation-triangle" :title="authStore.error"
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', variant: 'link' }"
@close="authStore.clearError" />
<UFormField :label="$t('general.new_password')" name="new_password" required class="w-full dark:text-white text-black">
<UInput v-model="new_password" :type="showNewPassword ? 'text' : 'password'"
:placeholder="$t('general.enter_your_new_password')" :disabled="authStore.loading"
class="w-full dark:text-white text-black" :ui="{ trailing: 'pe-1' }">
<template #trailing>
<UIcon color="neutral" variant="link" size="sm"
:name="showNewPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="showNewPassword ? 'Hide password' : 'Show password'"
:aria-pressed="showNewPassword" aria-controls="new_password"
@click="showNewPassword = !showNewPassword" />
</template>
</UInput>
</UFormField>
<UFormField :label="$t('general.confirm_password')" name="confirm_new_password" required
class="w-full dark:text-white text-black">
<UInput v-model="confirm_new_password" :type="showConfirmPassword ? 'text' : 'password'"
:placeholder="$t('general.confirm_your_new_password')" :disabled="authStore.loading"
class="w-full dark:text-white text-black" :ui="{ trailing: 'pe-1' }">
<template #trailing>
<UIcon color="neutral" variant="ghost" size="sm"
:name="showConfirmPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
@click="showConfirmPassword = !showConfirmPassword" />
</template>
</UInput>
</UFormField>
<UButton type="submit" block :loading="authStore.loading" class="text-white! bg-(--color-blue-600) dark:bg-(--color-blue-500)">
{{ $t('general.reset_password') }}
</UButton>
<div class="text-center border-t dark:border-(--border-dark) border-(--border-light) pt-4">
<UButton color="neutral" variant="ghost" @click="goToLogin" class="dark:text-white text-black">
{{ $t('general.back_to_sign_in') }}
</UButton>
</div>
</UForm>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useFetchJson } from '@/composable/useFetchJson'
const { t, te } = useI18n()
const router = useRouter()
const route = useRoute()
// Helper function to get translation with fallback
function tt(key: string, fallback: string): string {
return te(key) ? t(key) : fallback
}
const token = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const success = ref(false)
const verificationInProgress = ref(true)
onMounted(() => {
// Get token from URL query params
token.value = (route.query.token as string) || ''
if (!token.value) {
error.value = tt('verify_email.invalid_token', 'Invalid or missing verification token')
verificationInProgress.value = false
return
}
// Automatically verify email on page load
handleVerifyEmail()
})
async function handleVerifyEmail() {
if (!token.value) {
error.value = tt('verify_email.invalid_token', 'Invalid or missing verification token')
return
}
loading.value = true
error.value = null
try {
await useFetchJson('/api/v1/auth/complete-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.value }),
})
success.value = true
verificationInProgress.value = false
// Redirect to login after 3 seconds
setTimeout(() => {
router.push({ name: 'login' })
}, 3000)
} catch (e: any) {
error.value = e?.message ?? tt('verify_email.verification_failed', 'Email verification failed')
verificationInProgress.value = false
} finally {
loading.value = false
}
}
function goToLogin() {
router.push({ name: 'login' })
}
</script>
<template>
<div
class="min-h-screen bg-gradient-to-br from-primary-50 via-white to-primary-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div class="pt-20 pb-8 flex items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md">
<!-- Logo/Brand Section -->
<div class="text-center mb-8">
<div
class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-500 text-white mb-4 shadow-lg shadow-primary-500/30">
<UIcon name="i-heroicons-envelope-check" class="w-8 h-8" />
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">TimeTracker</h1>
</div>
<!-- Verify Email Card -->
<UCard class="shadow-xl shadow-gray-200/50 dark:shadow-gray-900/50">
<template #header>
<div class="text-center">
<!-- Loading State -->
<div v-if="verificationInProgress && loading">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-primary-500 mx-auto mb-4" />
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ tt('verify_email.verifying', 'Verifying your email...') }}
</h2>
</div>
<!-- Success State -->
<div v-else-if="success">
<div
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-100 text-green-600 mb-4">
<UIcon name="i-heroicons-check-circle" class="w-6 h-6" />
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ tt('verify_email.success_title', 'Email Verified!') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ tt('verify_email.success_message', 'Your email has been verified successfully.') }}
</p>
</div>
<!-- Error State -->
<div v-else-if="error">
<div
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 text-red-600 mb-4">
<UIcon name="i-heroicons-exclamation-circle" class="w-6 h-6" />
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ tt('verify_email.error_title', 'Verification Failed') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ tt('verify_email.error_message', 'We could not verify your email.') }}
</p>
</div>
</div>
</template>
<!-- Success State Content -->
<div v-if="success" class="text-center py-4">
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ tt('verify_email.redirect_message', 'You will be redirected to login page...') }}</p>
<UButton color="primary" @click="goToLogin">{{ tt('verify_email.go_to_login', 'Go to Login') }}</UButton>
</div>
<!-- Error State Content -->
<div v-else-if="error" class="text-center py-4">
<UAlert :color="'error'" variant="subtle" icon="i-heroicons-exclamation-triangle" :title="error"
class="mb-4" />
<UButton color="primary" @click="goToLogin">{{ tt('verify_email.go_to_login', 'Go to Login') }}</UButton>
</div>
<!-- Loading State Content -->
<div v-else-if="verificationInProgress && loading" class="text-center py-4">
<p class="text-gray-500 dark:text-gray-400">{{ tt('verify_email.please_wait', 'Please wait while we verify your email address.') }}</p>
</div>
<template #footer>
<div class="text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ tt('verify_email.already_registered', 'Already have an account?') }}
<UButton variant="link" size="sm" @click="goToLogin"> {{ tt('verify_email.sign_in', 'Sign in') }}
</UButton>
</p>
</div>
</template>
</UCard>
</div>
</div>
</div>
</template>