initial commit. Cloned timetracker repository
This commit is contained in:
8
bo/.editorconfig
Normal file
8
bo/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 200
|
||||
1
bo/.gitattributes
vendored
Normal file
1
bo/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
39
bo/.gitignore
vendored
Normal file
39
bo/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
10
bo/.oxlintrc.json
Normal file
10
bo/.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
7
bo/.prettierrc.json
Normal file
7
bo/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 160,
|
||||
"singleAttributePerLine": false
|
||||
}
|
||||
48
bo/README.md
Normal file
48
bo/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# bo
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
bun lint
|
||||
```
|
||||
74
bo/auto-imports.d.ts
vendored
Normal file
74
bo/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const avatarGroupInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js').avatarGroupInjectionKey
|
||||
const defineLocale: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js').defineLocale
|
||||
const defineShortcuts: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js').defineShortcuts
|
||||
const extendLocale: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js').extendLocale
|
||||
const extractShortcuts: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js').extractShortcuts
|
||||
const fieldGroupInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js').fieldGroupInjectionKey
|
||||
const formBusInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js').formBusInjectionKey
|
||||
const formErrorsInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js').formErrorsInjectionKey
|
||||
const formFieldInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js').formFieldInjectionKey
|
||||
const formInputsInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js').formInputsInjectionKey
|
||||
const formLoadingInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js').formLoadingInjectionKey
|
||||
const formOptionsInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js').formOptionsInjectionKey
|
||||
const formStateInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js').formStateInjectionKey
|
||||
const inputIdInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js').inputIdInjectionKey
|
||||
const kbdKeysMap: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js').kbdKeysMap
|
||||
const localeContextInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js').localeContextInjectionKey
|
||||
const portalTargetInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js').portalTargetInjectionKey
|
||||
const provideThemeContext: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useComponentUI.js').provideThemeContext
|
||||
const toastMaxInjectionKey: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useToast.js').toastMaxInjectionKey
|
||||
const useAppConfig: typeof import('./node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js').useAppConfig
|
||||
const useAvatarGroup: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js').useAvatarGroup
|
||||
const useComponentIcons: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.js').useComponentIcons
|
||||
const useComponentUI: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useComponentUI.js').useComponentUI
|
||||
const useContentSearch: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch.js').useContentSearch
|
||||
const useEditorMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useEditorMenu.js').useEditorMenu
|
||||
const useFieldGroup: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js').useFieldGroup
|
||||
const useFileUpload: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.js').useFileUpload
|
||||
const useFormField: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js').useFormField
|
||||
const useKbd: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js').useKbd
|
||||
const useLocale: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js').useLocale
|
||||
const useOverlay: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.js').useOverlay
|
||||
const usePortal: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js').usePortal
|
||||
const useResizable: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useResizable.js').useResizable
|
||||
const useScrollspy: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy.js').useScrollspy
|
||||
const useToast: typeof import('./node_modules/@nuxt/ui/dist/runtime/composables/useToast.js').useToast
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { ShortcutConfig, ShortcutsConfig, ShortcutsOptions } from './node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d'
|
||||
import('./node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d')
|
||||
// @ts-ignore
|
||||
export type { UseComponentIconsProps } from './node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d'
|
||||
import('./node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d')
|
||||
// @ts-ignore
|
||||
export type { ThemeUI, ThemeRootContext } from './node_modules/@nuxt/ui/dist/runtime/composables/useComponentUI.d'
|
||||
import('./node_modules/@nuxt/ui/dist/runtime/composables/useComponentUI.d')
|
||||
// @ts-ignore
|
||||
export type { EditorMenuOptions } from './node_modules/@nuxt/ui/dist/runtime/composables/useEditorMenu.d'
|
||||
import('./node_modules/@nuxt/ui/dist/runtime/composables/useEditorMenu.d')
|
||||
// @ts-ignore
|
||||
export type { UseFileUploadOptions } from './node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d'
|
||||
import('./node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d')
|
||||
// @ts-ignore
|
||||
export type { KbdKey, KbdKeySpecific } from './node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d'
|
||||
import('./node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d')
|
||||
// @ts-ignore
|
||||
export type { OverlayOptions, Overlay } from './node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d'
|
||||
import('./node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d')
|
||||
// @ts-ignore
|
||||
export type { UseResizableProps, UseResizableReturn } from './node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d'
|
||||
import('./node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d')
|
||||
// @ts-ignore
|
||||
export type { Toast } from './node_modules/@nuxt/ui/dist/runtime/composables/useToast.d'
|
||||
import('./node_modules/@nuxt/ui/dist/runtime/composables/useToast.d')
|
||||
}
|
||||
1281
bo/bun.lock
Normal file
1281
bo/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
41
bo/components.d.ts
vendored
Normal file
41
bo/components.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Button: typeof import('./src/components/custom/Button.vue')['default']
|
||||
Cs_PrivacyPolicyView: typeof import('./src/components/terms/cs_PrivacyPolicyView.vue')['default']
|
||||
Cs_TermsAndConditionsView: typeof import('./src/components/terms/cs_TermsAndConditionsView.vue')['default']
|
||||
En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default']
|
||||
En_TermsAndConditionsView: typeof import('./src/components/terms/en_TermsAndConditionsView.vue')['default']
|
||||
Input: typeof import('./src/components/custom/Input.vue')['default']
|
||||
LangSwitch: typeof import('./src/components/inner/langSwitch.vue')['default']
|
||||
Pl_PrivacyPolicyView: typeof import('./src/components/terms/pl_PrivacyPolicyView.vue')['default']
|
||||
Pl_TermsAndConditionsView: typeof import('./src/components/terms/pl_TermsAndConditionsView.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ThemeSwitch: typeof import('./src/components/inner/themeSwitch.vue')['default']
|
||||
TopBarLogin: typeof import('./src/components/TopBarLogin.vue')['default']
|
||||
UAlert: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Alert.vue')['default']
|
||||
UButton: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
|
||||
UCard: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
|
||||
UCheckbox: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
|
||||
UContainer: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Container.vue')['default']
|
||||
UDrawer: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
|
||||
UForm: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Form.vue')['default']
|
||||
UFormField: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
|
||||
UIcon: typeof import('./node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
|
||||
UInput: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
|
||||
UPagination: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Pagination.vue')['default']
|
||||
USelect: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default']
|
||||
USelectMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
|
||||
}
|
||||
}
|
||||
1
bo/env.d.ts
vendored
Normal file
1
bo/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
24
bo/index.html
Normal file
24
bo/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TimeTracker</title>
|
||||
<script>
|
||||
if (localStorage.getItem('vueuse-color-scheme') === 'dark' || (!('vueuse-color-scheme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
var theme = 'dark'
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
var theme = 'light'
|
||||
}
|
||||
var pageName = "default";
|
||||
globalThis.appInit = [];
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
bo/package.json
Normal file
59
bo/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "bo",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^4.5.0",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"vue": "beta",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-i18n": "11",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.10.13",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"jiti": "^2.6.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.47.0",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "beta",
|
||||
"vite-plugin-vue-devtools": "^8.0.6",
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vue": "beta",
|
||||
"@vue/compiler-core": "beta",
|
||||
"@vue/compiler-dom": "beta",
|
||||
"@vue/compiler-sfc": "beta",
|
||||
"@vue/compiler-ssr": "beta",
|
||||
"@vue/compiler-vapor": "beta",
|
||||
"@vue/reactivity": "beta",
|
||||
"@vue/runtime-core": "beta",
|
||||
"@vue/runtime-dom": "beta",
|
||||
"@vue/runtime-vapor": "beta",
|
||||
"@vue/server-renderer": "beta",
|
||||
"@vue/shared": "beta",
|
||||
"@vue/compat": "beta"
|
||||
}
|
||||
}
|
||||
BIN
bo/public/favicon.ico
Normal file
BIN
bo/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
9
bo/src/App.vue
Normal file
9
bo/src/App.vue
Normal 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
141
bo/src/app.config.ts
Normal 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
87
bo/src/assets/main.css
Normal 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;
|
||||
}
|
||||
|
||||
38
bo/src/components/TopBarLogin.vue
Normal file
38
bo/src/components/TopBarLogin.vue
Normal 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>
|
||||
15
bo/src/components/custom/Button.vue
Normal file
15
bo/src/components/custom/Button.vue
Normal 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>
|
||||
0
bo/src/components/custom/Input.vue
Normal file
0
bo/src/components/custom/Input.vue
Normal file
71
bo/src/components/inner/langSwitch.vue
Normal file
71
bo/src/components/inner/langSwitch.vue
Normal 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>
|
||||
12
bo/src/components/inner/themeSwitch.vue
Normal file
12
bo/src/components/inner/themeSwitch.vue
Normal 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>
|
||||
124
bo/src/components/terms/cs_PrivacyPolicyView.vue
Normal file
124
bo/src/components/terms/cs_PrivacyPolicyView.vue
Normal 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>
|
||||
103
bo/src/components/terms/cs_TermsAndConditionsView.vue
Normal file
103
bo/src/components/terms/cs_TermsAndConditionsView.vue
Normal 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>
|
||||
122
bo/src/components/terms/en_PrivacyPolicyView.vue
Normal file
122
bo/src/components/terms/en_PrivacyPolicyView.vue
Normal 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>
|
||||
103
bo/src/components/terms/en_TermsAndConditionsView.vue
Normal file
103
bo/src/components/terms/en_TermsAndConditionsView.vue
Normal 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>
|
||||
125
bo/src/components/terms/pl_PrivacyPolicyView.vue
Normal file
125
bo/src/components/terms/pl_PrivacyPolicyView.vue
Normal 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 są 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>
|
||||
104
bo/src/components/terms/pl_TermsAndConditionsView.vue
Normal file
104
bo/src/components/terms/pl_TermsAndConditionsView.vue
Normal 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 są
|
||||
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>
|
||||
66
bo/src/composable/useCookie.ts
Normal file
66
bo/src/composable/useCookie.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
77
bo/src/composable/useFetchJson.ts
Normal file
77
bo/src/composable/useFetchJson.ts
Normal 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}`
|
||||
}
|
||||
87
bo/src/composable/useRepoApi.ts
Normal file
87
bo/src/composable/useRepoApi.ts
Normal 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();
|
||||
98
bo/src/composable/useValidation.ts
Normal file
98
bo/src/composable/useValidation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
14
bo/src/layouts/default.vue
Normal file
14
bo/src/layouts/default.vue
Normal 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
11
bo/src/layouts/empty.vue
Normal 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
17
bo/src/main.ts
Normal 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
47
bo/src/plugins/i18n.ts
Normal 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
86
bo/src/router/index.ts
Normal 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
30
bo/src/router/langs.ts
Normal 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
11
bo/src/router/settings.ts
Normal 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
204
bo/src/stores/auth.ts
Normal 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
14
bo/src/stores/settings.ts
Normal 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
47
bo/src/stores/theme.ts
Normal 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
2
bo/src/types/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '@types/lang'
|
||||
export * from '@types/response'
|
||||
14
bo/src/types/lang.d.ts
vendored
Normal file
14
bo/src/types/lang.d.ts
vendored
Normal 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
5
bo/src/types/response.d.ts
vendored
Normal 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
35
bo/src/types/settings.d.ts
vendored
Normal 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
293
bo/src/types/ui-app.config.d.ts
vendored
Normal 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
23
bo/src/utils/fake.ts
Normal 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
15
bo/src/views/HomeView.vue
Normal 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
159
bo/src/views/LoginView.vue
Normal 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>
|
||||
101
bo/src/views/PasswordRecoveryView.vue
Normal file
101
bo/src/views/PasswordRecoveryView.vue
Normal 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>
|
||||
155
bo/src/views/RegisterView.vue
Normal file
155
bo/src/views/RegisterView.vue
Normal 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>
|
||||
250
bo/src/views/RepoChartView.vue
Normal file
250
bo/src/views/RepoChartView.vue
Normal 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>
|
||||
123
bo/src/views/ResetPasswordForm.vue
Normal file
123
bo/src/views/ResetPasswordForm.vue
Normal 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>
|
||||
159
bo/src/views/VerifyEmailView.vue
Normal file
159
bo/src/views/VerifyEmailView.vue
Normal 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>
|
||||
13
bo/tsconfig.app.json
Normal file
13
bo/tsconfig.app.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/app.config.ts"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@types/*": ["./src/types/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
8
bo/tsconfig.json
Normal file
8
bo/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
46
bo/vite.config.ts
Normal file
46
bo/vite.config.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import ui from '@nuxt/ui/vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { uiOptions } from './src/app.config'
|
||||
|
||||
var isDev = process.env.NODE_ENV == 'development'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss(), isDev ? vueDevTools() : false, ui(uiOptions)],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
base: '/',
|
||||
publicDir: './public',
|
||||
build: {
|
||||
outDir: '../assets/public/dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/swagger': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/openapi.json': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user