diff --git a/apps/README.md b/apps/README.md
new file mode 100644
index 00000000..d3b3166f
--- /dev/null
+++ b/apps/README.md
@@ -0,0 +1,44 @@
+# Core
+
+> [!NOTE]
+> Нere are the main parts of our app
+
+## ✍️ Usage
+
+### Frontend
+
+```sh
+cd client
+```
+
+#### Dev
+
+```sh
+# Install dependencies
+pnpm i
+
+# Development
+pnpm run dev
+```
+
+#### UI Kit
+
+```sh
+pnpm run storybook
+```
+
+### Backend
+
+#### Dev
+
+```sh
+cd server
+```
+
+```sh
+# Install dependencies
+pnpm i
+
+# Development
+pnpm run start
+```
diff --git a/apps/client/.gitignore b/apps/client/.gitignore
new file mode 100644
index 00000000..70342655
--- /dev/null
+++ b/apps/client/.gitignore
@@ -0,0 +1,32 @@
+# 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
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo
+
+*storybook.log
\ No newline at end of file
diff --git a/apps/client/.storybook/-static/logo.png b/apps/client/.storybook/-static/logo.png
new file mode 100644
index 00000000..c3823631
Binary files /dev/null and b/apps/client/.storybook/-static/logo.png differ
diff --git a/apps/client/.storybook/main.ts b/apps/client/.storybook/main.ts
new file mode 100644
index 00000000..b833aaf7
--- /dev/null
+++ b/apps/client/.storybook/main.ts
@@ -0,0 +1,23 @@
+import type { StorybookConfig } from '@storybook/vue3-vite'
+
+const config: StorybookConfig = {
+ stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
+ addons: [
+ '@storybook/addon-links',
+ '@storybook/addon-essentials',
+ '@storybook/addon-interactions',
+ '@storybook/addon-themes',
+ ],
+ framework: {
+ name: '@storybook/vue3-vite',
+ options: {
+ docgen: 'vue-component-meta',
+ },
+ },
+ viteFinal: async config => ({
+ ...config,
+ ...(await import('unocss/vite')).default,
+ }),
+ staticDirs: ['./-static'],
+}
+export default config
diff --git a/apps/client/.storybook/manager-head.html b/apps/client/.storybook/manager-head.html
new file mode 100644
index 00000000..fb396b0c
--- /dev/null
+++ b/apps/client/.storybook/manager-head.html
@@ -0,0 +1,5 @@
+
diff --git a/apps/client/.storybook/manager.ts b/apps/client/.storybook/manager.ts
new file mode 100644
index 00000000..e3d2c70a
--- /dev/null
+++ b/apps/client/.storybook/manager.ts
@@ -0,0 +1,12 @@
+import { addons } from '@storybook/manager-api'
+import { themes } from '@storybook/theming'
+// @ts-ignore
+import logo from './-static/logo.png'
+
+addons.setConfig({
+ theme: {
+ ...themes.light,
+ brandImage: logo,
+ brandUrl: 'https://jenda.vercel.app/',
+ },
+})
diff --git a/apps/client/.storybook/preview.ts b/apps/client/.storybook/preview.ts
new file mode 100644
index 00000000..85d9eecd
--- /dev/null
+++ b/apps/client/.storybook/preview.ts
@@ -0,0 +1,52 @@
+import { withThemeByClassName } from '@storybook/addon-themes'
+import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'
+import { themes } from '@storybook/theming'
+import { useColorMode } from '@vueuse/core'
+import type { Preview } from '@storybook/vue3'
+
+import 'virtual:uno.css'
+import '@unocss/reset/tailwind-compat.css'
+import '@/app/styles/primary/index.css'
+
+const preview: Preview = {
+ decorators: [
+ withThemeByClassName({
+ themes: {
+ light: 'light',
+ dark: 'dark',
+ },
+ defaultTheme: 'light',
+ }),
+ ],
+ parameters: {
+ options: {
+ storySort: (a, b) =>
+ a.id === b.id
+ ? 0
+ : a.id.localeCompare(b.id, undefined, { numeric: true }),
+ },
+ layout: 'centered',
+ viewport: {
+ viewports: INITIAL_VIEWPORTS,
+ defaultViewport: 'desktop',
+ },
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ docs: {
+ theme: themes.light,
+ },
+ backgrounds: {
+ disable: true,
+ },
+ },
+}
+
+useColorMode({
+ initialValue: 'light',
+})
+
+export default preview
diff --git a/apps/client/LICENSE b/apps/client/LICENSE
new file mode 100644
index 00000000..d4a0c079
--- /dev/null
+++ b/apps/client/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Alex Peshkov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/apps/client/README.md b/apps/client/README.md
new file mode 100644
index 00000000..cd61ba0a
--- /dev/null
+++ b/apps/client/README.md
@@ -0,0 +1,4 @@
+# @jenda/client
+
+> [!WARNING]
+> 🚧 Client currently on dev mode rn
diff --git a/apps/client/components.json b/apps/client/components.json
new file mode 100644
index 00000000..2453e3fe
--- /dev/null
+++ b/apps/client/components.json
@@ -0,0 +1,16 @@
+{
+ "style": "new-york",
+ "typescript": true,
+ "tsConfigPath": "./tsconfig.json",
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "@/app/styles/primary/index.scss",
+ "baseColor": "neutral",
+ "cssVariables": false
+ },
+ "framework": "vite",
+ "aliases": {
+ "components": "@/shared",
+ "utils": "@/shared/lib/shadcn/utils"
+ }
+}
diff --git a/apps/client/env.d.ts b/apps/client/env.d.ts
new file mode 100644
index 00000000..dabd0deb
--- /dev/null
+++ b/apps/client/env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/apps/client/eslint.config.js b/apps/client/eslint.config.js
new file mode 100644
index 00000000..8a44eac7
--- /dev/null
+++ b/apps/client/eslint.config.js
@@ -0,0 +1,116 @@
+import jendaEslintConfig from '@jenda/eslint-config'
+import vitest from '@vitest/eslint-plugin'
+import storybook from 'eslint-plugin-storybook'
+
+export default jendaEslintConfig(
+ {
+ vue: true,
+ typescript: true,
+ formatters: {
+ css: true,
+ },
+ },
+ {
+ files: ['**/*.vue'],
+ rules: {
+ 'vue/multi-word-component-names': 'warn',
+ 'vue/block-order': [
+ 'error',
+ {
+ order: ['script', 'template', 'style'],
+ },
+ ],
+ 'vue/component-name-in-template-casing': [
+ 'error',
+ 'PascalCase',
+ {
+ registeredComponentsOnly: false,
+ },
+ ],
+ 'vue/html-self-closing': [
+ 'error',
+ {
+ html: {
+ void: 'always',
+ },
+ },
+ ],
+ 'vue/max-attributes-per-line': [
+ 'error',
+ {
+ singleline: { max: 10 },
+ multiline: { max: 1 },
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/pages/**/*.vue'],
+ rules: {
+ 'vue/multi-word-component-names': 'off',
+ },
+ },
+ {
+ rules: {
+ 'import/order': [
+ 'error',
+ {
+ groups: [
+ 'builtin',
+ ['external', 'internal'],
+ 'parent',
+ 'sibling',
+ 'index',
+ 'object',
+ 'type',
+ ],
+ pathGroups: [
+ {
+ pattern: 'vue',
+ group: 'external',
+ position: 'before',
+ },
+ ],
+ pathGroupsExcludedImportTypes: ['type'],
+ },
+ ],
+ 'perfectionist/sort-imports': 'off',
+ },
+ },
+ {
+ files: ['__tests__/*'],
+ plugins: {
+ vitest,
+ },
+ rules: {
+ ...vitest.configs.recommended.rules,
+ 'vitest/expect-expect': 'off',
+ 'vitest/valid-expect': 'error',
+ },
+ settings: {
+ vitest: {
+ typecheck: true,
+ },
+ },
+ languageOptions: {
+ globals: {
+ ...vitest.environments.env.globals,
+ definePage: 'readonly',
+ },
+ },
+ },
+ {
+ files: ['**/*.stories.*'],
+ plugins: {
+ storybook,
+ },
+ rules: {
+ ...storybook.configs['flat/recommended'].rules,
+ },
+ },
+ {
+ settings: {
+ 'import/core-modules': ['vue-router/auto-routes'],
+ },
+ },
+)
diff --git a/apps/client/index.html b/apps/client/index.html
new file mode 100644
index 00000000..1cd4cb94
--- /dev/null
+++ b/apps/client/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+ Jenda - cloud program for project and task management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/package.json b/apps/client/package.json
new file mode 100644
index 00000000..e1806af9
--- /dev/null
+++ b/apps/client/package.json
@@ -0,0 +1,102 @@
+{
+ "name": "@jenda/core-client",
+ "type": "module",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "run-p type-check \"build-only {@}\" --",
+ "preview": "vite preview",
+ "test:unit": "vitest",
+ "build-only": "vite build",
+ "debug:build": "NODE_OPTIONS='--inspect-brk' pnpm run build",
+ "type-check": "vue-tsc --build --force",
+ "lint": "eslint . --fix",
+ "stylelint": "npx stylelint **/*.scss --fix",
+ "format": "prettier --write src/",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build"
+ },
+ "dependencies": {
+ "@formkit/auto-animate": "^0.8.2",
+ "@tanstack/vue-table": "8.20.5",
+ "@unhead/vue": "^1.11.15",
+ "@unocss/core": "^65.4.0",
+ "@unocss/reset": "^65.4.0",
+ "@vee-validate/zod": "^4.15.0",
+ "@vitest/eslint-plugin": "^1.1.7",
+ "@vueuse/core": "^12.4.0",
+ "@vueuse/integrations": "^12.4.0",
+ "axios": "^1.7.2",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "floating-vue": "^5.2.2",
+ "pinia": "^2.1.7",
+ "radix-vue": "^1.9.12",
+ "splitpanes": "^3.1.5",
+ "tailwind-merge": "^2.5.5",
+ "universal-cookie": "^6.1.3",
+ "unocss": "^65.4.0",
+ "unplugin-vue-router-extend": "0.1.15",
+ "vee-validate": "^4.15.0",
+ "vue": "^3.5.8",
+ "vue-data-ui": "^2.3.44",
+ "vue-i18n": "^11.0.1",
+ "vue-router": "^4.3.3",
+ "vue-sonner": "^1.2.1",
+ "vue-writer": "^2.0.2",
+ "vue3-marquee": "^4.2.2",
+ "vuedraggable": "^4.1.0",
+ "zod": "^3.24.0"
+ },
+ "devDependencies": {
+ "@iconify-json/hugeicons": "1.2.1",
+ "@iconify-json/lucide": "1.2.17",
+ "@iconify/utils": "2.2.1",
+ "@iconify/vue": "4.1.2",
+ "@mnenie/prettier": "^1.0.4",
+ "@rushstack/eslint-patch": "^1.8.0",
+ "@storybook/addon-essentials": "^8.1.10",
+ "@storybook/addon-interactions": "^8.1.10",
+ "@storybook/addon-links": "^8.1.10",
+ "@storybook/addon-themes": "^8.4.1",
+ "@storybook/addon-viewport": "8.4.6",
+ "@storybook/blocks": "^8.1.10",
+ "@storybook/manager-api": "8.4.6",
+ "@storybook/test": "^8.1.10",
+ "@storybook/theming": "8.4.6",
+ "@storybook/vue3": "^8.1.10",
+ "@storybook/vue3-vite": "^8.1.10",
+ "@tsconfig/node20": "^20.1.4",
+ "@types/jsdom": "^21.1.7",
+ "@types/node": "^22.5.2",
+ "@vitejs/plugin-vue": "^5.0.5",
+ "@vue/eslint-config-prettier": "^10.0.0",
+ "@vue/eslint-config-typescript": "^14.1.1",
+ "@vue/test-utils": "^2.4.6",
+ "@vue/tsconfig": "^0.5.1",
+ "eslint": "^9.13.0",
+ "eslint-define-config": "^2.1.0",
+ "eslint-plugin-antfu": "^2.7.0",
+ "eslint-plugin-format": "0.1.3",
+ "eslint-plugin-storybook": "^0.10.2",
+ "eslint-plugin-vue": "^9.29.0",
+ "husky": "^9.0.11",
+ "jsdom": "^24.1.0",
+ "lint-staged": "^15.2.7",
+ "npm-run-all2": "^6.2.0",
+ "prettier": "^3.2.5",
+ "sonda": "0.7.0",
+ "storybook": "^8.1.10",
+ "stylelint": "^16.6.1",
+ "stylelint-config-standard-scss": "^13.1.0",
+ "typescript": "~5.4.0",
+ "unocss-preset-animations": "1.1.0",
+ "unocss-preset-shadcn": "0.3.1",
+ "unplugin-vue-router": "0.10.9",
+ "vite": "^5.3.1",
+ "vite-plugin-vue-devtools": "^7.7.0",
+ "vitest": "^1.6.0",
+ "vue-tsc": "^2.0.21"
+ }
+}
diff --git a/apps/client/public/dev/dev-card-dark.png b/apps/client/public/dev/dev-card-dark.png
new file mode 100644
index 00000000..f5292dc6
Binary files /dev/null and b/apps/client/public/dev/dev-card-dark.png differ
diff --git a/apps/client/public/dev/dev-card-section-dark.png b/apps/client/public/dev/dev-card-section-dark.png
new file mode 100644
index 00000000..2bf77344
Binary files /dev/null and b/apps/client/public/dev/dev-card-section-dark.png differ
diff --git a/apps/client/public/dev/dev-card-section.png b/apps/client/public/dev/dev-card-section.png
new file mode 100644
index 00000000..9469542d
Binary files /dev/null and b/apps/client/public/dev/dev-card-section.png differ
diff --git a/apps/client/public/dev/dev-card.png b/apps/client/public/dev/dev-card.png
new file mode 100644
index 00000000..4871754d
Binary files /dev/null and b/apps/client/public/dev/dev-card.png differ
diff --git a/apps/client/public/dev/dev-dark.png b/apps/client/public/dev/dev-dark.png
new file mode 100644
index 00000000..59b71128
Binary files /dev/null and b/apps/client/public/dev/dev-dark.png differ
diff --git a/apps/client/public/dev/dev.png b/apps/client/public/dev/dev.png
new file mode 100644
index 00000000..cf5d443b
Binary files /dev/null and b/apps/client/public/dev/dev.png differ
diff --git a/apps/client/public/favicon.ico b/apps/client/public/favicon.ico
new file mode 100644
index 00000000..b79451b5
Binary files /dev/null and b/apps/client/public/favicon.ico differ
diff --git a/apps/client/public/icons/arrow-back.svg b/apps/client/public/icons/arrow-back.svg
new file mode 100644
index 00000000..93ed7629
--- /dev/null
+++ b/apps/client/public/icons/arrow-back.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/client/public/icons/arrows.svg b/apps/client/public/icons/arrows.svg
new file mode 100644
index 00000000..62b1a62a
--- /dev/null
+++ b/apps/client/public/icons/arrows.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/apps/client/public/icons/blockquote-dark.png b/apps/client/public/icons/blockquote-dark.png
new file mode 100644
index 00000000..579ffcc2
Binary files /dev/null and b/apps/client/public/icons/blockquote-dark.png differ
diff --git a/apps/client/public/icons/blockquote-light.png b/apps/client/public/icons/blockquote-light.png
new file mode 100644
index 00000000..fdf567a8
Binary files /dev/null and b/apps/client/public/icons/blockquote-light.png differ
diff --git a/apps/client/public/icons/collaborative.svg b/apps/client/public/icons/collaborative.svg
new file mode 100644
index 00000000..cacc60ea
--- /dev/null
+++ b/apps/client/public/icons/collaborative.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/client/public/icons/github-d.png b/apps/client/public/icons/github-d.png
new file mode 100644
index 00000000..4bb2db90
Binary files /dev/null and b/apps/client/public/icons/github-d.png differ
diff --git a/apps/client/public/icons/github.png b/apps/client/public/icons/github.png
new file mode 100644
index 00000000..9490ffc6
Binary files /dev/null and b/apps/client/public/icons/github.png differ
diff --git a/apps/client/public/icons/google.png b/apps/client/public/icons/google.png
new file mode 100644
index 00000000..6b7ea547
Binary files /dev/null and b/apps/client/public/icons/google.png differ
diff --git a/apps/client/public/icons/kanban-dark.png b/apps/client/public/icons/kanban-dark.png
new file mode 100644
index 00000000..9613e15c
Binary files /dev/null and b/apps/client/public/icons/kanban-dark.png differ
diff --git a/apps/client/public/icons/kanban.png b/apps/client/public/icons/kanban.png
new file mode 100644
index 00000000..a0b97755
Binary files /dev/null and b/apps/client/public/icons/kanban.png differ
diff --git a/apps/client/public/icons/telegram.webp b/apps/client/public/icons/telegram.webp
new file mode 100644
index 00000000..f6b2b7c0
Binary files /dev/null and b/apps/client/public/icons/telegram.webp differ
diff --git a/apps/client/src/App.vue b/apps/client/src/App.vue
new file mode 100644
index 00000000..c0d95e34
--- /dev/null
+++ b/apps/client/src/App.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/__tests__/FooterWelcome.spec.ts b/apps/client/src/core/__tests__/FooterWelcome.spec.ts
new file mode 100644
index 00000000..661908ef
--- /dev/null
+++ b/apps/client/src/core/__tests__/FooterWelcome.spec.ts
@@ -0,0 +1,26 @@
+import { shallowMount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import FooterWelcome from '../components/footer/AppFooter.vue'
+import i18n from '@/shared/libs/i18n'
+
+describe('tests for FooterWelcome.vue', () => {
+ const wrapper = shallowMount(FooterWelcome, {
+ global: {
+ plugins: [i18n],
+
+ mocks: {
+ t: (key: string) => {
+ const translations: Record = {
+ 'welcome.footer': 'footer',
+ }
+ return translations[key]
+ },
+ },
+ },
+ })
+
+ // snapshot is covered needs for i18n I think ;)
+ it('should be render correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+})
diff --git a/apps/client/src/core/__tests__/HeaderWelcome.spec.ts b/apps/client/src/core/__tests__/HeaderWelcome.spec.ts
new file mode 100644
index 00000000..73a43297
--- /dev/null
+++ b/apps/client/src/core/__tests__/HeaderWelcome.spec.ts
@@ -0,0 +1,71 @@
+import { type ComponentPublicInstance, nextTick } from 'vue'
+import { shallowMount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import HeaderWelcome from '../components/headers/HeaderWelcome.vue'
+import type { headerLinks } from '../constants/header'
+import type { VueWrapper } from '@vue/test-utils'
+import i18n from '@/shared/libs/i18n'
+import { UiButton } from '@/shared/ui'
+
+type HeaderWelcomeInstance = ComponentPublicInstance<
+ {},
+ {},
+ {
+ isDark: boolean
+ width: number
+ headerLinks: typeof headerLinks
+ }
+>
+
+const mockRouter = {
+ push: vi.fn(),
+ beforeEach: vi.fn(),
+}
+
+describe('tests for HeaderWelcome.vue', () => {
+ const wrapper = shallowMount(HeaderWelcome, {
+ global: {
+ plugins: [i18n],
+
+ mocks: {
+ t: (key: string) => {
+ const translations: Record = {
+ 'welcome.header.login': 'Log In',
+ 'welcome.header.reg': 'Registration',
+ }
+ return translations[key]
+ },
+ tm: (key: string) => {
+ const translationsArr: Record = {
+ 'welcome.header.links': [],
+ }
+ return translationsArr[key]
+ },
+ $router: mockRouter,
+ },
+ },
+ }) as VueWrapper
+
+ it('should be render correctly', async () => {
+ wrapper.vm.width = 1600
+ await nextTick()
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('should redirect correctly to login', async () => {
+ const loginButton = wrapper.find('.btns').findAllComponents(UiButton).at(0)
+
+ await loginButton.trigger('click')
+ expect(mockRouter.push).toHaveBeenCalledWith({ name: 'sign-in' })
+ })
+
+ it('should redirect correctly to registration', async () => {
+ const registrationButton = wrapper
+ .find('.btns')
+ .findAllComponents(UiButton)
+ .at(1)
+
+ await registrationButton.trigger('click')
+ expect(mockRouter.push).toHaveBeenCalledWith({ name: 'sign-up' })
+ })
+})
diff --git a/apps/client/src/core/__tests__/UserMenu.spec.ts b/apps/client/src/core/__tests__/UserMenu.spec.ts
new file mode 100644
index 00000000..22e29df1
--- /dev/null
+++ b/apps/client/src/core/__tests__/UserMenu.spec.ts
@@ -0,0 +1,43 @@
+import { nextTick } from 'vue'
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import UserMenu from '../components/headers/UserMenu.vue'
+import i18n from '@/shared/libs/i18n'
+import { UiDropdownMenuItem, UiDropdownMenuTrigger } from '@/shared/ui'
+
+const mockRouter = {
+ push: vi.fn(),
+ beforeEach: vi.fn(),
+}
+
+describe('tests for UserMenu.vue', () => {
+ const wrapper = mount(UserMenu, {
+ global: {
+ plugins: [i18n],
+ mocks: {
+ t: (key: string) => {
+ const translations: Record = {
+ 'header.user.welcome': 'welcome',
+ 'header.user.logout': 'logout',
+ }
+ return translations[key]
+ },
+ router: mockRouter,
+ },
+ },
+ })
+
+ it('should redirect correctly to welcome', async () => {
+ await wrapper.findComponent(UiDropdownMenuTrigger).trigger('click')
+ await nextTick()
+ const items = wrapper.findAllComponents(UiDropdownMenuItem)
+ expect(items.length).toBeGreaterThan(0)
+ const welcomeTrigger = items.at(0)
+ await welcomeTrigger?.trigger('click')
+ expect(mockRouter.push).toHaveBeenCalledWith({ name: 'welcome' })
+ })
+
+ it('should redirect correctly to logout', async () => {
+ // needs to be fixed
+ })
+})
diff --git a/apps/client/src/core/__tests__/WorkSpace.spec.ts b/apps/client/src/core/__tests__/WorkSpace.spec.ts
new file mode 100644
index 00000000..ea735536
--- /dev/null
+++ b/apps/client/src/core/__tests__/WorkSpace.spec.ts
@@ -0,0 +1,58 @@
+import { mount, RouterLinkStub } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import { useRoute } from 'vue-router/auto'
+import WorkSpace from '../components/sidebar/WorkSpace.vue'
+import i18n from '@/shared/libs/i18n'
+
+vi.mock('vue-router/auto')
+
+vi.mock('@/shared/composables/expanded', () => ({
+ useExpandedContext: vi.fn(() => ({
+ isExpanded: {
+ value: true,
+ },
+ })),
+}))
+
+describe('tests for WorkSpace.vue', () => {
+ // @ts-expect-error mock types
+ useRoute.mockReturnValue({ name: 'boards' })
+ const wrapper = mount(WorkSpace, {
+ global: {
+ plugins: [i18n],
+ mocks: {
+ t: (key: string) => {
+ const translations: Record = {
+ 'sidebar.boards': 'Boards',
+ 'sidebar.section': 'Workspace',
+ }
+ return translations[key]
+ },
+ },
+ stubs: {
+ RouterLink: RouterLinkStub,
+ },
+ directives: {
+ tooltip() {},
+ },
+ },
+ props: {
+ links: [
+ {
+ id: 0,
+ name: 'boards',
+ pathName: 'boards',
+ icon: 'i-hugeicons-trello',
+ },
+ ],
+ },
+ })
+
+ it('should be render correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('should be redirect with RouterLink correctly', () => {
+ expect(wrapper.findComponent(RouterLinkStub).props('to')).toEqual('/boards')
+ })
+})
diff --git a/apps/client/src/core/__tests__/__snapshots__/FooterWelcome.spec.ts.snap b/apps/client/src/core/__tests__/__snapshots__/FooterWelcome.spec.ts.snap
new file mode 100644
index 00000000..e27e7c1a
--- /dev/null
+++ b/apps/client/src/core/__tests__/__snapshots__/FooterWelcome.spec.ts.snap
@@ -0,0 +1,9 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for FooterWelcome.vue > should be render correctly 1`] = `
+""
+`;
diff --git a/apps/client/src/core/__tests__/__snapshots__/HeaderWelcome.spec.ts.snap b/apps/client/src/core/__tests__/__snapshots__/HeaderWelcome.spec.ts.snap
new file mode 100644
index 00000000..3692f472
--- /dev/null
+++ b/apps/client/src/core/__tests__/__snapshots__/HeaderWelcome.spec.ts.snap
@@ -0,0 +1,31 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for HeaderWelcome.vue > should be render correctly 1`] = `
+""
+`;
diff --git a/apps/client/src/core/__tests__/__snapshots__/WorkSpace.spec.ts.snap b/apps/client/src/core/__tests__/__snapshots__/WorkSpace.spec.ts.snap
new file mode 100644
index 00000000..35d141b0
--- /dev/null
+++ b/apps/client/src/core/__tests__/__snapshots__/WorkSpace.spec.ts.snap
@@ -0,0 +1,9 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for WorkSpace.vue > should be render correctly 1`] = `
+""
+`;
diff --git a/apps/client/src/core/components/footer/AppFooter.vue b/apps/client/src/core/components/footer/AppFooter.vue
new file mode 100644
index 00000000..8ad00b89
--- /dev/null
+++ b/apps/client/src/core/components/footer/AppFooter.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/apps/client/src/core/components/headers/BurgerMenu.vue b/apps/client/src/core/components/headers/BurgerMenu.vue
new file mode 100644
index 00000000..46779ebd
--- /dev/null
+++ b/apps/client/src/core/components/headers/BurgerMenu.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/components/headers/HeaderMain.vue b/apps/client/src/core/components/headers/HeaderMain.vue
new file mode 100644
index 00000000..2785873f
--- /dev/null
+++ b/apps/client/src/core/components/headers/HeaderMain.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+ {{ active.name }}
+
+
+
+
+
+
+ +{{ remaining }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('header.share') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/components/headers/HeaderWelcome.vue b/apps/client/src/core/components/headers/HeaderWelcome.vue
new file mode 100644
index 00000000..9f17ea63
--- /dev/null
+++ b/apps/client/src/core/components/headers/HeaderWelcome.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/components/headers/UserMenu.vue b/apps/client/src/core/components/headers/UserMenu.vue
new file mode 100644
index 00000000..be3a5d4c
--- /dev/null
+++ b/apps/client/src/core/components/headers/UserMenu.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+ test@gmail.com
+
+
+
+
+ {{ $t('header.user.welcome') }}
+ ⇧P
+
+
+ {{ $t('header.user.logout') }}
+ ⌘X
+
+
+
+
+
diff --git a/apps/client/src/core/components/headers/system/LanguageSelect.vue b/apps/client/src/core/components/headers/system/LanguageSelect.vue
new file mode 100644
index 00000000..2eb4da0d
--- /dev/null
+++ b/apps/client/src/core/components/headers/system/LanguageSelect.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+ {{ i18nLanguage }}
+
+
+
+
+ {{ item.name }}
+
+
+
+
diff --git a/apps/client/src/core/components/headers/system/ThemeSwitcher.vue b/apps/client/src/core/components/headers/system/ThemeSwitcher.vue
new file mode 100644
index 00000000..bc37020e
--- /dev/null
+++ b/apps/client/src/core/components/headers/system/ThemeSwitcher.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/components/sidebar/AppSidebar.vue b/apps/client/src/core/components/sidebar/AppSidebar.vue
new file mode 100644
index 00000000..e96080a0
--- /dev/null
+++ b/apps/client/src/core/components/sidebar/AppSidebar.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/apps/client/src/core/components/sidebar/InfoMenu.vue b/apps/client/src/core/components/sidebar/InfoMenu.vue
new file mode 100644
index 00000000..0a5e322c
--- /dev/null
+++ b/apps/client/src/core/components/sidebar/InfoMenu.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+ {{ $t('sidebar.help.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/components/sidebar/IntegrationItems.vue b/apps/client/src/core/components/sidebar/IntegrationItems.vue
new file mode 100644
index 00000000..2a2b67a9
--- /dev/null
+++ b/apps/client/src/core/components/sidebar/IntegrationItems.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+ {{ $t('sidebar.integrations') }}
+
+
+
+
+
+
+ {{ name }}
+
+
+ {{ $t('sidebar.soon') }}
+
+
+
+
diff --git a/apps/client/src/core/components/sidebar/ProjectsList.vue b/apps/client/src/core/components/sidebar/ProjectsList.vue
new file mode 100644
index 00000000..9ab89442
--- /dev/null
+++ b/apps/client/src/core/components/sidebar/ProjectsList.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+ {{ $t('sidebar.projects') }}
+
+
+
+
+
+
+
+
+
+ {{ project.name }}
+
+
+
+
+
diff --git a/apps/client/src/core/components/sidebar/WorkSpace.vue b/apps/client/src/core/components/sidebar/WorkSpace.vue
new file mode 100644
index 00000000..4f73f839
--- /dev/null
+++ b/apps/client/src/core/components/sidebar/WorkSpace.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+ {{ $t(`sidebar.${link.name}`) }}
+
+
+ 3
+
+
+
+
+
diff --git a/apps/client/src/core/components/sidebar/WorkSpaceChooser.vue b/apps/client/src/core/components/sidebar/WorkSpaceChooser.vue
new file mode 100644
index 00000000..878c5b0a
--- /dev/null
+++ b/apps/client/src/core/components/sidebar/WorkSpaceChooser.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ workspace.plan }}
+
+
+ {{ $t('sidebar.section') }}
+
+
+
+ {{ workspace.name }}
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/components/sidebar/plan/PlanCard.vue b/apps/client/src/core/components/sidebar/plan/PlanCard.vue
new file mode 100644
index 00000000..c7be99fc
--- /dev/null
+++ b/apps/client/src/core/components/sidebar/plan/PlanCard.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+ 5
+
+ {{ $t('sidebar.plan.description') }}
+
+
+
+
+
+
diff --git a/apps/client/src/core/components/sidebar/plan/SubscribePlan.vue b/apps/client/src/core/components/sidebar/plan/SubscribePlan.vue
new file mode 100644
index 00000000..46468d66
--- /dev/null
+++ b/apps/client/src/core/components/sidebar/plan/SubscribePlan.vue
@@ -0,0 +1,13 @@
+
+
+
+
+ {{ $t('sidebar.plan.btn') }}
+
+
diff --git a/apps/client/src/core/constants/header.ts b/apps/client/src/core/constants/header.ts
new file mode 100644
index 00000000..0499f62f
--- /dev/null
+++ b/apps/client/src/core/constants/header.ts
@@ -0,0 +1,42 @@
+import type { HeaderNavLink, MenuLink, SectionItem } from '../types'
+
+export const headerLinks: HeaderNavLink[] = [
+ { id: 0, name: 'About', pagePrefix: 'about' },
+ { id: 1, name: 'Kanban', pagePrefix: 'kanban' },
+ { id: 2, name: 'Collaborative', pagePrefix: 'collaborative' },
+ { id: 3, name: 'Activity', pagePrefix: 'activity' },
+ { id: 4, name: 'Members', pagePrefix: 'members' },
+ { id: 5, name: 'Chats', pagePrefix: 'chats' },
+]
+
+export const menuLinks: MenuLink[] = [
+ { url: 'https://github.com/mnenie/jenda' },
+ { url: 'https://t.me/youngjuicycashrussia' },
+]
+
+export const items: SectionItem[] = [
+ {
+ icon: '✨',
+ urlPrefix: 'about',
+ },
+ {
+ icon: '🧑💻',
+ urlPrefix: 'kanban',
+ },
+ {
+ icon: '👥',
+ urlPrefix: 'collaborative',
+ },
+ {
+ icon: '🌐',
+ urlPrefix: 'members',
+ },
+ {
+ icon: '👔',
+ urlPrefix: 'activity',
+ },
+ {
+ icon: '💬',
+ urlPrefix: 'activity',
+ },
+]
diff --git a/apps/client/src/core/constants/layouts.ts b/apps/client/src/core/constants/layouts.ts
new file mode 100644
index 00000000..a34c9c8a
--- /dev/null
+++ b/apps/client/src/core/constants/layouts.ts
@@ -0,0 +1,8 @@
+import type { LayoutsEnum } from '@/shared/constants/layouts'
+
+export const LayoutToFileMap: Record = {
+ default: 'DefaultLayout.vue',
+ auth: 'AuthLayout.vue',
+ welcome: 'WelcomeLayout.vue',
+ empty: 'EmptyLayout.vue',
+}
diff --git a/apps/client/src/core/injections/index.ts b/apps/client/src/core/injections/index.ts
new file mode 100644
index 00000000..cda42e23
--- /dev/null
+++ b/apps/client/src/core/injections/index.ts
@@ -0,0 +1 @@
+// with seo
diff --git a/apps/client/src/core/layouts/AppLayout.vue b/apps/client/src/core/layouts/AppLayout.vue
new file mode 100644
index 00000000..4cbbcbfa
--- /dev/null
+++ b/apps/client/src/core/layouts/AppLayout.vue
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/layouts/AuthLayout.vue b/apps/client/src/core/layouts/AuthLayout.vue
new file mode 100644
index 00000000..ec8e7e3b
--- /dev/null
+++ b/apps/client/src/core/layouts/AuthLayout.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/layouts/DefaultLayout.vue b/apps/client/src/core/layouts/DefaultLayout.vue
new file mode 100644
index 00000000..4b6d6c91
--- /dev/null
+++ b/apps/client/src/core/layouts/DefaultLayout.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/core/layouts/EmptyLayout.vue b/apps/client/src/core/layouts/EmptyLayout.vue
new file mode 100644
index 00000000..14351cce
--- /dev/null
+++ b/apps/client/src/core/layouts/EmptyLayout.vue
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/client/src/core/layouts/WelcomeLayout.vue b/apps/client/src/core/layouts/WelcomeLayout.vue
new file mode 100644
index 00000000..3724b8b9
--- /dev/null
+++ b/apps/client/src/core/layouts/WelcomeLayout.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/apps/client/src/core/pages/[...path].vue b/apps/client/src/core/pages/[...path].vue
new file mode 100644
index 00000000..cca43902
--- /dev/null
+++ b/apps/client/src/core/pages/[...path].vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+ 404
+
+
+ {{ $t('not_found.title') }}
+
+
+ {{ $t('not_found.description') }}
+
+
+ {{ $t('not_found.btn') }}
+
+
+
+
diff --git a/apps/client/src/core/pages/index.vue b/apps/client/src/core/pages/index.vue
new file mode 100644
index 00000000..353d0a1c
--- /dev/null
+++ b/apps/client/src/core/pages/index.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
diff --git a/apps/client/src/core/types/header.ts b/apps/client/src/core/types/header.ts
new file mode 100644
index 00000000..42c2b74f
--- /dev/null
+++ b/apps/client/src/core/types/header.ts
@@ -0,0 +1,23 @@
+export interface SectionItem {
+ title?: string
+ description?: string
+ icon: string
+ urlPrefix:
+ | 'kanban'
+ | 'about'
+ | 'collaborative'
+ | 'activity'
+ | 'members'
+ | 'chats'
+}
+
+export interface MenuLink {
+ title?: string
+ url: string
+}
+
+export interface HeaderNavLink {
+ id: number
+ name: string
+ pagePrefix: string
+}
diff --git a/apps/client/src/core/types/index.ts b/apps/client/src/core/types/index.ts
new file mode 100644
index 00000000..d153d3b4
--- /dev/null
+++ b/apps/client/src/core/types/index.ts
@@ -0,0 +1,2 @@
+export * from './header'
+export * from './sidebar'
diff --git a/apps/client/src/core/types/sidebar.ts b/apps/client/src/core/types/sidebar.ts
new file mode 100644
index 00000000..5ba0a4c1
--- /dev/null
+++ b/apps/client/src/core/types/sidebar.ts
@@ -0,0 +1,16 @@
+import type { IconifyIcon } from '@iconify/vue'
+import type { RouterLinkProps } from 'vue-router/auto'
+
+export interface Link {
+ id: number
+ name: string
+ pathName: RouterLinkProps['to']
+ // string with unocss update
+ icon: IconifyIcon | string
+}
+
+export interface IntegrationItem {
+ name: string
+ link: string
+ icon: string
+}
diff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
new file mode 100644
index 00000000..e8503ab0
--- /dev/null
+++ b/apps/client/src/main.ts
@@ -0,0 +1,35 @@
+import { createApp } from 'vue'
+import { createHead } from '@unhead/vue'
+import { vTooltip } from 'floating-vue'
+import Vue3Marquee from 'vue3-marquee'
+import { VueUiRadar } from 'vue-data-ui'
+
+// @ts-expect-error: unresolved type definitions for vue-writer
+import VueWriter from 'vue-writer'
+import App from './App.vue'
+import autoAnimatePlugin from './plugins/formkit'
+
+import { pinia } from './store'
+import { router } from './router'
+import i18n from '@/shared/libs/i18n'
+
+import './styles/index.css'
+import '@unocss/reset/tailwind-compat.css'
+import 'virtual:uno.css'
+import 'floating-vue/dist/style.css'
+import 'vue-data-ui/style.css'
+
+const app = createApp(App)
+// head plugin
+const head = createHead()
+
+app.use(pinia)
+app.use(router)
+app.use(i18n)
+app.use(head)
+app.use(autoAnimatePlugin)
+app.use(Vue3Marquee)
+app.use(VueWriter)
+app.directive('tooltip', vTooltip)
+app.component('VueUiRadar', VueUiRadar)
+app.mount('#app')
diff --git a/apps/client/src/modules/auth/components/AuthContainer.vue b/apps/client/src/modules/auth/components/AuthContainer.vue
new file mode 100644
index 00000000..3ce9f7e7
--- /dev/null
+++ b/apps/client/src/modules/auth/components/AuthContainer.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/ConfirmSection.vue b/apps/client/src/modules/auth/components/ConfirmSection.vue
new file mode 100644
index 00000000..5dcd2b22
--- /dev/null
+++ b/apps/client/src/modules/auth/components/ConfirmSection.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/UserAvatar.vue b/apps/client/src/modules/auth/components/UserAvatar.vue
new file mode 100644
index 00000000..a2d12403
--- /dev/null
+++ b/apps/client/src/modules/auth/components/UserAvatar.vue
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/WorkspaceChoosing.vue b/apps/client/src/modules/auth/components/WorkspaceChoosing.vue
new file mode 100644
index 00000000..d91ed445
--- /dev/null
+++ b/apps/client/src/modules/auth/components/WorkspaceChoosing.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/WorkspaceCreating.vue b/apps/client/src/modules/auth/components/WorkspaceCreating.vue
new file mode 100644
index 00000000..84f46f14
--- /dev/null
+++ b/apps/client/src/modules/auth/components/WorkspaceCreating.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/common/BgPanel.vue b/apps/client/src/modules/auth/components/common/BgPanel.vue
new file mode 100644
index 00000000..81810587
--- /dev/null
+++ b/apps/client/src/modules/auth/components/common/BgPanel.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+ {{ reviews[currentIndex].text }}
+
+
+
+
+
+ {{ reviews[currentIndex].author }}
+
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/common/LogoFile.vue b/apps/client/src/modules/auth/components/common/LogoFile.vue
new file mode 100644
index 00000000..7e1afed4
--- /dev/null
+++ b/apps/client/src/modules/auth/components/common/LogoFile.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ Jenda
+
+
+
+
+
+
+ {{ t('authentication.back') }}
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/common/PrivacyPolicy.vue b/apps/client/src/modules/auth/components/common/PrivacyPolicy.vue
new file mode 100644
index 00000000..75e458fe
--- /dev/null
+++ b/apps/client/src/modules/auth/components/common/PrivacyPolicy.vue
@@ -0,0 +1,38 @@
+
+
+
+
+ {{ privacyItems[0] }}
+
+ {{ privacyItems[2] }}
+
+ {{ privacyItems[3] }}
+
+ {{ privacyItems[4] }}
+
+
+
diff --git a/apps/client/src/modules/auth/components/features/GoogleOauth.vue b/apps/client/src/modules/auth/components/features/GoogleOauth.vue
new file mode 100644
index 00000000..a29e33a1
--- /dev/null
+++ b/apps/client/src/modules/auth/components/features/GoogleOauth.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Google
+
+
diff --git a/apps/client/src/modules/auth/components/features/HandleLogo.vue b/apps/client/src/modules/auth/components/features/HandleLogo.vue
new file mode 100644
index 00000000..05bec823
--- /dev/null
+++ b/apps/client/src/modules/auth/components/features/HandleLogo.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+ {{ $t('authentication.workspace.creating.logo.btn', 1) }}
+
+
+ {{ $t('authentication.workspace.creating.logo.btn', 2) }}
+
+
+
diff --git a/apps/client/src/modules/auth/components/features/WorkspaceLogoChooser.vue b/apps/client/src/modules/auth/components/features/WorkspaceLogoChooser.vue
new file mode 100644
index 00000000..e7fb7695
--- /dev/null
+++ b/apps/client/src/modules/auth/components/features/WorkspaceLogoChooser.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+ {{ $t('authentication.workspace.creating.logo.label') }}
+
+
+
+ {{ $t('authentication.workspace.creating.logo.description') }}
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/forms/ConfirmForm.vue b/apps/client/src/modules/auth/components/forms/ConfirmForm.vue
new file mode 100644
index 00000000..1990c625
--- /dev/null
+++ b/apps/client/src/modules/auth/components/forms/ConfirmForm.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/forms/SignInForm.vue b/apps/client/src/modules/auth/components/forms/SignInForm.vue
new file mode 100644
index 00000000..e51598cc
--- /dev/null
+++ b/apps/client/src/modules/auth/components/forms/SignInForm.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+ {{ $t(`authentication.form.${field}`) }}
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/forms/SignUpForm.vue b/apps/client/src/modules/auth/components/forms/SignUpForm.vue
new file mode 100644
index 00000000..c6bba9a5
--- /dev/null
+++ b/apps/client/src/modules/auth/components/forms/SignUpForm.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+ {{ $t(`authentication.form.${field}`) }}
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/forms/WorkspaceChanger.vue b/apps/client/src/modules/auth/components/forms/WorkspaceChanger.vue
new file mode 100644
index 00000000..b1d84244
--- /dev/null
+++ b/apps/client/src/modules/auth/components/forms/WorkspaceChanger.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/components/forms/WorkspaceForm.vue b/apps/client/src/modules/auth/components/forms/WorkspaceForm.vue
new file mode 100644
index 00000000..3cd18488
--- /dev/null
+++ b/apps/client/src/modules/auth/components/forms/WorkspaceForm.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+ {{ $t(`authentication.workspace.creating.form.${field}.label`) }}
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/composables/auth-workspace.ts b/apps/client/src/modules/auth/composables/auth-workspace.ts
new file mode 100644
index 00000000..4f9c85e9
--- /dev/null
+++ b/apps/client/src/modules/auth/composables/auth-workspace.ts
@@ -0,0 +1,30 @@
+import { inject, type InjectionKey, provide } from 'vue'
+
+interface AuthWorkspace {
+ reset: () => void
+ openFileChooser: (event: any) => void
+}
+
+type T = AuthWorkspace
+
+const key: InjectionKey = Symbol('auth-workspace-chooser')
+
+export function provideWorkspaceChooserContext(value: T) {
+ provide(key, value)
+ return value
+}
+
+export function useWorkspaceChooserContext<
+ U extends T | undefined = T,
+>(
+ fallback?: U,
+): U extends null ? T | null : T {
+ const expanded = inject(key, fallback)
+ if (expanded)
+ return expanded as T
+
+ if (expanded === null)
+ return expanded as any
+
+ throw new Error('not provided')
+}
diff --git a/apps/client/src/modules/auth/composables/panel.ts b/apps/client/src/modules/auth/composables/panel.ts
new file mode 100644
index 00000000..3a267d8d
--- /dev/null
+++ b/apps/client/src/modules/auth/composables/panel.ts
@@ -0,0 +1,21 @@
+import { ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+import type { Review } from '../types'
+
+export function useTextChanging(reviews: Review[]) {
+ // so, maybe Ill do with live data
+ const currentIndex = ref(0)
+ const route = useRoute()
+
+ const changeReviewText = () => {
+ currentIndex.value = Math.floor(Math.random() * reviews.length)
+ }
+ watch(() => route.path, () => {
+ changeReviewText()
+ }, { immediate: true })
+
+ return {
+ currentIndex,
+ changeReviewText,
+ }
+}
diff --git a/apps/client/src/modules/auth/fixtures/reviews.ts b/apps/client/src/modules/auth/fixtures/reviews.ts
new file mode 100644
index 00000000..b048cd0f
--- /dev/null
+++ b/apps/client/src/modules/auth/fixtures/reviews.ts
@@ -0,0 +1,20 @@
+import type { Review } from '../types'
+
+// mocks -> after data from backend
+export const reviews: Review[] = [
+ {
+ id: '0',
+ author: '@alexpeshkov',
+ text: `Y'all Jenda is amazing! 🙌 Barely an hour since I've been here and I don't want to leave my workspace. 🤯🤯🤯`,
+ },
+ {
+ id: '1',
+ author: '@airvt1x',
+ text: `I just learned about Jenda and im in love 💫 Jenda is a Trello/Jira alternative!💡so, simple for understanding !!!`,
+ },
+ {
+ id: '2',
+ author: '@alexpeshkov',
+ text: `So, Jenda is just 🔥 Now I understand why many people like to use it.`,
+ },
+]
diff --git a/apps/client/src/modules/auth/pages/auth/sign-in/index.vue b/apps/client/src/modules/auth/pages/auth/sign-in/index.vue
new file mode 100644
index 00000000..496acd71
--- /dev/null
+++ b/apps/client/src/modules/auth/pages/auth/sign-in/index.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/pages/auth/sign-in/workspace.vue b/apps/client/src/modules/auth/pages/auth/sign-in/workspace.vue
new file mode 100644
index 00000000..88984ad9
--- /dev/null
+++ b/apps/client/src/modules/auth/pages/auth/sign-in/workspace.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/pages/auth/sign-up/confirm.vue b/apps/client/src/modules/auth/pages/auth/sign-up/confirm.vue
new file mode 100644
index 00000000..2d9bb2ab
--- /dev/null
+++ b/apps/client/src/modules/auth/pages/auth/sign-up/confirm.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/pages/auth/sign-up/index.vue b/apps/client/src/modules/auth/pages/auth/sign-up/index.vue
new file mode 100644
index 00000000..3049d759
--- /dev/null
+++ b/apps/client/src/modules/auth/pages/auth/sign-up/index.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/pages/auth/sign-up/workspace.vue b/apps/client/src/modules/auth/pages/auth/sign-up/workspace.vue
new file mode 100644
index 00000000..dccbb414
--- /dev/null
+++ b/apps/client/src/modules/auth/pages/auth/sign-up/workspace.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/auth/stores/auth.ts b/apps/client/src/modules/auth/stores/auth.ts
new file mode 100644
index 00000000..bd4c5c9b
--- /dev/null
+++ b/apps/client/src/modules/auth/stores/auth.ts
@@ -0,0 +1,11 @@
+import { ref } from 'vue'
+import { defineStore } from 'pinia'
+import type { User } from '../types'
+
+export const useUserStore = defineStore('user', () => {
+ const user = ref({} as User)
+
+ return {
+ user,
+ }
+})
diff --git a/apps/client/src/modules/auth/types/index.ts b/apps/client/src/modules/auth/types/index.ts
new file mode 100644
index 00000000..ae71d87c
--- /dev/null
+++ b/apps/client/src/modules/auth/types/index.ts
@@ -0,0 +1,2 @@
+export * from './review'
+export * from './user'
diff --git a/apps/client/src/modules/auth/types/review.ts b/apps/client/src/modules/auth/types/review.ts
new file mode 100644
index 00000000..6d40c411
--- /dev/null
+++ b/apps/client/src/modules/auth/types/review.ts
@@ -0,0 +1,5 @@
+export interface Review {
+ id: string
+ author: string
+ text: string
+}
diff --git a/apps/client/src/modules/auth/types/user.ts b/apps/client/src/modules/auth/types/user.ts
new file mode 100644
index 00000000..7514dafe
--- /dev/null
+++ b/apps/client/src/modules/auth/types/user.ts
@@ -0,0 +1,16 @@
+interface DateParams {
+ createdAt?: Date
+ updatedAt?: Date
+ deletedAt?: Date
+}
+
+export interface User extends DateParams {
+ _id?: string
+ email: string
+ photoUrl?: string
+ role?: string
+}
+
+export interface UserAuth extends User {
+ token: string
+}
diff --git a/apps/client/src/modules/boards/components/BoardsActionsPanel.vue b/apps/client/src/modules/boards/components/BoardsActionsPanel.vue
new file mode 100644
index 00000000..83776500
--- /dev/null
+++ b/apps/client/src/modules/boards/components/BoardsActionsPanel.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/BoardsDataTable.vue b/apps/client/src/modules/boards/components/BoardsDataTable.vue
new file mode 100644
index 00000000..306cd8f7
--- /dev/null
+++ b/apps/client/src/modules/boards/components/BoardsDataTable.vue
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+ {{ name }}
+
+
+
+ -
+
+
+
+
+
+ {{ t(`kanban.${cell.row.original.status}`) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ +{{ remainingUsers(cell) }}
+
+
+ -
+
+
+
diff --git a/apps/client/src/modules/boards/components/EmptyBoards.vue b/apps/client/src/modules/boards/components/EmptyBoards.vue
new file mode 100644
index 00000000..f98ba0e1
--- /dev/null
+++ b/apps/client/src/modules/boards/components/EmptyBoards.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ {{ $t('boards.empty.title') }}
+
+
+ {{ $t('boards.empty.description') }}
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/NewBoardDialog.vue b/apps/client/src/modules/boards/components/NewBoardDialog.vue
new file mode 100644
index 00000000..622b781e
--- /dev/null
+++ b/apps/client/src/modules/boards/components/NewBoardDialog.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+ {{ $t('boards.forms.creating.title') }}
+
+
+ {{ $t('boards.forms.creating.description') }}
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/PaginationItems.vue b/apps/client/src/modules/boards/components/PaginationItems.vue
new file mode 100644
index 00000000..facaeee8
--- /dev/null
+++ b/apps/client/src/modules/boards/components/PaginationItems.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/TableControls.vue b/apps/client/src/modules/boards/components/TableControls.vue
new file mode 100644
index 00000000..ece095ae
--- /dev/null
+++ b/apps/client/src/modules/boards/components/TableControls.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+ {{ table?.getFilteredSelectedRowModel().rows.length }}
+ {{ $t('table.row_from_all') }}
+ {{ table?.getFilteredRowModel().rows.length }}
+ {{ $t('table.row_selected') }}
+
+
+
+ {{ $t('table.rows_on_page') }}
+
+
+
+ {{ $t('table.page') }}
+ {{ (table?.getState().pagination.pageIndex ?? 0) + 1 }}
+ {{ $t('table.row_from_all') }}
+ {{ table?.getPageCount().toLocaleString() }}
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/features/ChooseRowsCount.vue b/apps/client/src/modules/boards/components/features/ChooseRowsCount.vue
new file mode 100644
index 00000000..51374022
--- /dev/null
+++ b/apps/client/src/modules/boards/components/features/ChooseRowsCount.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ {{ table?.getState().pagination.pageSize }}
+
+
+
+
+
+ {{ pageSize }}
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/features/CreateNewBoard.vue b/apps/client/src/modules/boards/components/features/CreateNewBoard.vue
new file mode 100644
index 00000000..b82a0b71
--- /dev/null
+++ b/apps/client/src/modules/boards/components/features/CreateNewBoard.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/features/handlers/AddBoard.vue b/apps/client/src/modules/boards/components/features/handlers/AddBoard.vue
new file mode 100644
index 00000000..2b3756d9
--- /dev/null
+++ b/apps/client/src/modules/boards/components/features/handlers/AddBoard.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+ {{ $t('boards.create', plural) }}
+
+
diff --git a/apps/client/src/modules/boards/components/features/handlers/RemoveBoard.vue b/apps/client/src/modules/boards/components/features/handlers/RemoveBoard.vue
new file mode 100644
index 00000000..3a151297
--- /dev/null
+++ b/apps/client/src/modules/boards/components/features/handlers/RemoveBoard.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+ Удалить
+
+
diff --git a/apps/client/src/modules/boards/components/filters/BoardsAdvancedFilter.vue b/apps/client/src/modules/boards/components/filters/BoardsAdvancedFilter.vue
new file mode 100644
index 00000000..1e5cdacf
--- /dev/null
+++ b/apps/client/src/modules/boards/components/filters/BoardsAdvancedFilter.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+ {{ i.label }}
+
+
+ {{ i.label }}
+
+
+
+
+
+
+
+
+ {{ t('boards.filters.advanced.title') }}
+
+ {{ advancedModel.length }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('boards.filters.advanced.empty') }}
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/filters/BoardsSort.vue b/apps/client/src/modules/boards/components/filters/BoardsSort.vue
new file mode 100644
index 00000000..f4d2075d
--- /dev/null
+++ b/apps/client/src/modules/boards/components/filters/BoardsSort.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+ {{ $t('boards.filters.sort.title') }}
+
+ {{ $t(`boards.filters.sort.${sortModel}`) }}
+
+
+
+
+ {{ $t(`boards.filters.sort.${value}`) }}
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/filters/SearchBoards.vue b/apps/client/src/modules/boards/components/filters/SearchBoards.vue
new file mode 100644
index 00000000..6cf3b11b
--- /dev/null
+++ b/apps/client/src/modules/boards/components/filters/SearchBoards.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/forms/CreateBoardForm.vue b/apps/client/src/modules/boards/components/forms/CreateBoardForm.vue
new file mode 100644
index 00000000..bb4f6f4e
--- /dev/null
+++ b/apps/client/src/modules/boards/components/forms/CreateBoardForm.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+ {{ $t(`boards.forms.creating.${field}.label`) }}
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/components/forms/TagsChooser.vue b/apps/client/src/modules/boards/components/forms/TagsChooser.vue
new file mode 100644
index 00000000..cdb92555
--- /dev/null
+++ b/apps/client/src/modules/boards/components/forms/TagsChooser.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/composables/filtered.ts b/apps/client/src/modules/boards/composables/filtered.ts
new file mode 100644
index 00000000..a897b375
--- /dev/null
+++ b/apps/client/src/modules/boards/composables/filtered.ts
@@ -0,0 +1,78 @@
+import { computed, inject, provide, toValue } from 'vue'
+import type { InjectionKey, Ref } from 'vue'
+import type { BoardRow } from '../types'
+
+export function useFilteredBoards(
+ boards: Ref,
+ sortRefValue: Ref,
+ advancedFilterRefValues: Ref,
+) {
+ const filterBoards = (boards: U[], filterValues: string[]): U[] => {
+ return boards.filter((board) => {
+ if (filterValues.length === 0)
+ return true
+ const status = filterValues.includes(board.status)
+ const labels = Array.isArray(board.labels)
+ ? board.labels.some(label => filterValues.includes(label.name))
+ : filterValues.includes(board.labels)
+ return status || labels
+ })
+ }
+ const sortBoards = (boards: U[], sortValue: string): U[] => {
+ return [...boards].sort((a, b): number => {
+ if (sortValue === 'name') {
+ return a.name.localeCompare(b.name)
+ }
+ if (sortValue === 'tasks') {
+ return (b.tasks || 0) - (a.tasks || 0)
+ }
+ if (sortValue === 'date') {
+ return (b.date ? +new Date(b.date) : 0) - (a.date ? +new Date(a.date) : 0)
+ }
+ return 0
+ })
+ }
+ const filteredBoards = computed(() => {
+ const sortValue = toValue(sortRefValue)
+ const filterValues = toValue(advancedFilterRefValues)
+
+ let result = filterBoards(boards.value, filterValues)
+ if (sortValue !== 'default') {
+ result = sortBoards(result, sortValue)
+ }
+ return result
+ })
+
+ return {
+ filteredBoards,
+ }
+}
+
+interface FilteredContext {
+ sortModel: Ref
+ advancedModel: Ref
+}
+
+type T = FilteredContext
+
+const key: InjectionKey = Symbol('filtered-boards')
+
+export function provideFilteredContext(value: T) {
+ provide(key, value)
+ return value
+}
+
+export function useFilteredContext<
+ U extends T | undefined = T,
+>(
+ fallback?: U,
+): U extends null ? T | null : T {
+ const expanded = inject(key, fallback)
+ if (expanded)
+ return expanded as T
+
+ if (expanded === null)
+ return expanded as any
+
+ throw new Error('not provided')
+}
diff --git a/apps/client/src/modules/boards/pages/boards.vue b/apps/client/src/modules/boards/pages/boards.vue
new file mode 100644
index 00000000..db7ec7e3
--- /dev/null
+++ b/apps/client/src/modules/boards/pages/boards.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/pages/boards/[id].vue b/apps/client/src/modules/boards/pages/boards/[id].vue
new file mode 100644
index 00000000..049cab60
--- /dev/null
+++ b/apps/client/src/modules/boards/pages/boards/[id].vue
@@ -0,0 +1,47 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/pages/boards/new.vue b/apps/client/src/modules/boards/pages/boards/new.vue
new file mode 100644
index 00000000..80f01e90
--- /dev/null
+++ b/apps/client/src/modules/boards/pages/boards/new.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/boards/stores/board.ts b/apps/client/src/modules/boards/stores/board.ts
new file mode 100644
index 00000000..78fd2211
--- /dev/null
+++ b/apps/client/src/modules/boards/stores/board.ts
@@ -0,0 +1,32 @@
+import { ref } from 'vue'
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import type { Board, BoardRow } from '../types'
+
+export const useBoardsStore = defineStore('boards', () => {
+ const boards = ref([])
+ const board = ref()
+
+ function removeBoards(idxs: string[]) {
+ idxs.forEach((id) => {
+ const index = boards.value.findIndex(board => board._id === id)
+ if (index !== -1) {
+ boards.value.splice(index, 1)
+ }
+ })
+ }
+
+ function addBoard(board: Board) {
+ boards.value.push(board)
+ }
+
+ return {
+ boards,
+ board,
+ addBoard,
+ removeBoards,
+ }
+})
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useBoardsStore, import.meta.hot))
+}
diff --git a/apps/client/src/modules/boards/types/index.ts b/apps/client/src/modules/boards/types/index.ts
new file mode 100644
index 00000000..2a57bc23
--- /dev/null
+++ b/apps/client/src/modules/boards/types/index.ts
@@ -0,0 +1,60 @@
+import type { User } from '@/modules/auth/types'
+
+interface DateParams {
+ createdAt?: Date
+ updatedAt?: Date
+ deletedAt?: Date
+}
+
+export interface Label {
+ name: string
+ color: string
+}
+
+export type Status = 'active' | 'archived'
+
+type Priority = 'none' | 'low' | 'medium' | 'high'
+
+export interface BoardRow extends Omit {
+ tasks?: number
+}
+
+export interface Board extends DateParams {
+ _id: string
+ name: string
+ columns?: Column[]
+ status: Status
+ labels: Label[]
+ users: User[]
+ color?: string
+ estimate?: number
+ // fix
+ date?: string
+}
+
+interface Tag {
+ _id: string
+ name: string
+}
+
+export interface Card extends DateParams {
+ _id: string
+ title: string
+ priority: Priority
+ tags?: Tag[]
+ chat?: boolean
+ chatCount?: number
+ users: User[]
+}
+
+export interface Column extends DateParams {
+ _id: string
+ title: string
+ cards?: Card[]
+}
+
+export interface StatusBadge {
+ _id: string
+ indicator: string
+ status: Status
+}
diff --git a/apps/client/src/modules/charts/components/ChartItemWrapper.vue b/apps/client/src/modules/charts/components/ChartItemWrapper.vue
new file mode 100644
index 00000000..334489b5
--- /dev/null
+++ b/apps/client/src/modules/charts/components/ChartItemWrapper.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ {{ $t(`analytics.charts.${chart.key}.name`) }}
+
+
+ {{ chart.section }}
+
+
+
+
+
+
+
+
+ {{ $t(`analytics.charts.${chart.key}.description`) }}
+
+
+
diff --git a/apps/client/src/modules/charts/components/SharedSection.vue b/apps/client/src/modules/charts/components/SharedSection.vue
new file mode 100644
index 00000000..f5fa9d6a
--- /dev/null
+++ b/apps/client/src/modules/charts/components/SharedSection.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ {{ $t('analytics.share.btn') }}
+
+
+ dev
+
+
+
- {{ $t('analytics.share.description') }} 😉
+
+
diff --git a/apps/client/src/modules/charts/components/charts/BoardsChart.vue b/apps/client/src/modules/charts/components/charts/BoardsChart.vue
new file mode 100644
index 00000000..16e9e776
--- /dev/null
+++ b/apps/client/src/modules/charts/components/charts/BoardsChart.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/charts/components/charts/OnlineChart.vue b/apps/client/src/modules/charts/components/charts/OnlineChart.vue
new file mode 100644
index 00000000..ea207ca8
--- /dev/null
+++ b/apps/client/src/modules/charts/components/charts/OnlineChart.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/charts/components/charts/TasksChart.vue b/apps/client/src/modules/charts/components/charts/TasksChart.vue
new file mode 100644
index 00000000..5b777102
--- /dev/null
+++ b/apps/client/src/modules/charts/components/charts/TasksChart.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/charts/components/charts/UsersChart.vue b/apps/client/src/modules/charts/components/charts/UsersChart.vue
new file mode 100644
index 00000000..0b9e7478
--- /dev/null
+++ b/apps/client/src/modules/charts/components/charts/UsersChart.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/charts/composables/charts.ts b/apps/client/src/modules/charts/composables/charts.ts
new file mode 100644
index 00000000..b6c6a536
--- /dev/null
+++ b/apps/client/src/modules/charts/composables/charts.ts
@@ -0,0 +1,38 @@
+import { computed } from 'vue'
+import { useBreakpoints } from '@/shared/composables/breakpoints'
+import { useExpandedContext } from '@/shared/composables/expanded'
+
+export function useCharts() {
+ const { breakpoints } = useBreakpoints()
+
+ const { isExpanded } = useExpandedContext()
+
+ const isBetweenIntermediateDesktopAnd4K = breakpoints.between(
+ 'intermediateDesktop',
+ 'desktop4K',
+ ).value
+
+ const chartOnlineInWorkspaceValue = computed(() => {
+ if (isBetweenIntermediateDesktopAnd4K) {
+ return isExpanded.value ? 7.7 : 6.5
+ }
+ return isExpanded.value ? 10 : 8
+ })
+
+ const chartBoardsValue = computed(() => {
+ if (isBetweenIntermediateDesktopAnd4K) {
+ return isExpanded.value ? 10 : 9
+ }
+ return isExpanded.value ? 13 : 11
+ })
+
+ const chartTasksValue = computed(() =>
+ isBetweenIntermediateDesktopAnd4K ? 14 : 18,
+ )
+
+ return {
+ chartOnlineInWorkspaceValue,
+ chartBoardsValue,
+ chartTasksValue,
+ }
+}
diff --git a/apps/client/src/modules/charts/pages/analytics.vue b/apps/client/src/modules/charts/pages/analytics.vue
new file mode 100644
index 00000000..f5a5addb
--- /dev/null
+++ b/apps/client/src/modules/charts/pages/analytics.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+ {{ $t('analytics.description') }}
+
+
+
+
+ {{ badge }}
+
+
+
+
+
+
diff --git a/apps/client/src/modules/charts/types/index.ts b/apps/client/src/modules/charts/types/index.ts
new file mode 100644
index 00000000..6644478f
--- /dev/null
+++ b/apps/client/src/modules/charts/types/index.ts
@@ -0,0 +1,4 @@
+export interface Chart {
+ key: 'users' | 'boards' | 'tasks' | 'online'
+ section: string
+}
diff --git a/apps/client/src/modules/common/components/ShareLink.vue b/apps/client/src/modules/common/components/ShareLink.vue
new file mode 100644
index 00000000..bcef5a8d
--- /dev/null
+++ b/apps/client/src/modules/common/components/ShareLink.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/common/components/controls/ViewControl.vue b/apps/client/src/modules/common/components/controls/ViewControl.vue
new file mode 100644
index 00000000..2c805def
--- /dev/null
+++ b/apps/client/src/modules/common/components/controls/ViewControl.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/common/components/dialogs/HotkeysDialog.vue b/apps/client/src/modules/common/components/dialogs/HotkeysDialog.vue
new file mode 100644
index 00000000..45ced5ce
--- /dev/null
+++ b/apps/client/src/modules/common/components/dialogs/HotkeysDialog.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+ {{ data }}
+
+
+
+
+
+
+
+
+ {{ $t('dialogs.hotkeys.title') }}
+
+
+
+
+
+ cmd/ctrl
+
+
+
+
+
+
+
+ +
+
+ - {{ $t('dialogs.hotkeys.badges', idx) }}
+
+
+
+
+
diff --git a/apps/client/src/modules/common/components/dialogs/ShareDialog.vue b/apps/client/src/modules/common/components/dialogs/ShareDialog.vue
new file mode 100644
index 00000000..a7e8f956
--- /dev/null
+++ b/apps/client/src/modules/common/components/dialogs/ShareDialog.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('dialogs.share.title') }}
+
+ {{ $t('dialogs.share.description') }}
+
+
+
+
+
+
+ {{ $t('dialogs.share.close') }}
+
+
+
+
+
+
diff --git a/apps/client/src/modules/common/readme.md b/apps/client/src/modules/common/readme.md
new file mode 100644
index 00000000..db88aeed
--- /dev/null
+++ b/apps/client/src/modules/common/readme.md
@@ -0,0 +1 @@
+shared module
diff --git a/apps/client/src/modules/members/pages/members.vue b/apps/client/src/modules/members/pages/members.vue
new file mode 100644
index 00000000..a85813cb
--- /dev/null
+++ b/apps/client/src/modules/members/pages/members.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ {{ $t('members.description') }}
+
+
+
+
diff --git a/apps/client/src/modules/notes/.gitkeep b/apps/client/src/modules/notes/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/client/src/modules/notes/pages/notes.vue b/apps/client/src/modules/notes/pages/notes.vue
new file mode 100644
index 00000000..9e8e7fae
--- /dev/null
+++ b/apps/client/src/modules/notes/pages/notes.vue
@@ -0,0 +1,20 @@
+
+
+
+
+ notes
+
+
diff --git a/apps/client/src/modules/search/components/PanelItems.vue b/apps/client/src/modules/search/components/PanelItems.vue
new file mode 100644
index 00000000..39f15f5b
--- /dev/null
+++ b/apps/client/src/modules/search/components/PanelItems.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ {{ $t(`search.${item.tPrefix}`) }}
+
+
+
diff --git a/apps/client/src/modules/search/components/SearchBox.vue b/apps/client/src/modules/search/components/SearchBox.vue
new file mode 100644
index 00000000..519d6b2b
--- /dev/null
+++ b/apps/client/src/modules/search/components/SearchBox.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/search/components/SearchFilter.vue b/apps/client/src/modules/search/components/SearchFilter.vue
new file mode 100644
index 00000000..6926e45c
--- /dev/null
+++ b/apps/client/src/modules/search/components/SearchFilter.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+ {{ $t('sidebar.input') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/search/components/SearchItem.vue b/apps/client/src/modules/search/components/SearchItem.vue
new file mode 100644
index 00000000..2aec06be
--- /dev/null
+++ b/apps/client/src/modules/search/components/SearchItem.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ {{ route.name }}
+
+
+
+
diff --git a/apps/client/src/modules/search/components/SearchList.vue b/apps/client/src/modules/search/components/SearchList.vue
new file mode 100644
index 00000000..62a34cea
--- /dev/null
+++ b/apps/client/src/modules/search/components/SearchList.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('search.empty') }}
+
+
+
+
+
diff --git a/apps/client/src/modules/search/types/index.ts b/apps/client/src/modules/search/types/index.ts
new file mode 100644
index 00000000..17d0a134
--- /dev/null
+++ b/apps/client/src/modules/search/types/index.ts
@@ -0,0 +1,11 @@
+export interface SearchRoute {
+ name: string
+ path: string
+ icon?: string
+ color?: string
+}
+
+export interface PanelItem {
+ icon: string
+ tPrefix: string
+}
diff --git a/apps/client/src/modules/settings/pages/settings.vue b/apps/client/src/modules/settings/pages/settings.vue
new file mode 100644
index 00000000..7b57f72a
--- /dev/null
+++ b/apps/client/src/modules/settings/pages/settings.vue
@@ -0,0 +1,20 @@
+
+
+
+
+ settings
+
+
diff --git a/apps/client/src/modules/templates/__tests__/TemplateItem.spec.ts b/apps/client/src/modules/templates/__tests__/TemplateItem.spec.ts
new file mode 100644
index 00000000..89a2d61c
--- /dev/null
+++ b/apps/client/src/modules/templates/__tests__/TemplateItem.spec.ts
@@ -0,0 +1,28 @@
+import { shallowMount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import { templatesInfo } from '../constants/templates'
+import TemplateItem from '../components/TemplateItem.vue'
+import i18n from '@/shared/libs/i18n'
+
+describe('tests for TemplateItem.vue', () => {
+ const wrapper = shallowMount(TemplateItem, {
+ global: {
+ plugins: [i18n],
+ mocks: {
+ t: (key: string) => {
+ const translations: Record = {
+ 'templates.user': 'test user',
+ }
+ return translations[key]
+ },
+ },
+ },
+ props: {
+ template: templatesInfo[0],
+ },
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+})
diff --git a/apps/client/src/modules/templates/__tests__/__snapshots__/TemplateItem.spec.ts.snap b/apps/client/src/modules/templates/__tests__/__snapshots__/TemplateItem.spec.ts.snap
new file mode 100644
index 00000000..6533970c
--- /dev/null
+++ b/apps/client/src/modules/templates/__tests__/__snapshots__/TemplateItem.spec.ts.snap
@@ -0,0 +1,13 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for TemplateItem.vue > should render correctly 1`] = `
+"
+
+
+
+
Base Kanban
Create a basic project with "Base Kanban" template
+
+
June 12, 2024
+
+
"
+`;
diff --git a/apps/client/src/modules/templates/components/AllTemplates.vue b/apps/client/src/modules/templates/components/AllTemplates.vue
new file mode 100644
index 00000000..e7c03a2e
--- /dev/null
+++ b/apps/client/src/modules/templates/components/AllTemplates.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/templates/components/ImportTemplate.vue b/apps/client/src/modules/templates/components/ImportTemplate.vue
new file mode 100644
index 00000000..099e0734
--- /dev/null
+++ b/apps/client/src/modules/templates/components/ImportTemplate.vue
@@ -0,0 +1,12 @@
+
+
+
+
+ {{ $t('templates.import') }}
+ .json
+
+
+
diff --git a/apps/client/src/modules/templates/components/TemplateItem.vue b/apps/client/src/modules/templates/components/TemplateItem.vue
new file mode 100644
index 00000000..10092c8a
--- /dev/null
+++ b/apps/client/src/modules/templates/components/TemplateItem.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ {{ template.tag }}
+
+
+
+ {{ template.title }}
+
+
+ {{ template.description }}
+
+
+
+
{{ template.date }}
+
+
+
+
+
diff --git a/apps/client/src/modules/templates/constants/templates.ts b/apps/client/src/modules/templates/constants/templates.ts
new file mode 100644
index 00000000..b0f86d4d
--- /dev/null
+++ b/apps/client/src/modules/templates/constants/templates.ts
@@ -0,0 +1,29 @@
+import type { Template } from '../types'
+
+// mocks -> after data from backend
+export const templatesInfo: Template[] = [
+ {
+ id: '0',
+ title: 'Base Kanban',
+ tag: 'Recommended',
+ description: `Create a basic project with "Base Kanban" template`,
+ date: 'June 12, 2024',
+ user: 'https://avatars.githubusercontent.com/u/121057011?v=4',
+ },
+ {
+ id: '1',
+ title: 'Roadmap',
+ tag: 'New',
+ description: `Create a project based on our "Roadmap" template`,
+ date: 'June 18, 2024',
+ user: 'https://avatars.githubusercontent.com/u/121057011?v=4',
+ },
+ {
+ id: '2',
+ title: 'Web Development',
+ tag: 'New',
+ description: `Create a basic project with "Web Development" template`,
+ date: 'July 16, 2024',
+ user: 'https://avatars.githubusercontent.com/u/121057011?v=4',
+ },
+]
diff --git a/apps/client/src/modules/templates/pages/templates.vue b/apps/client/src/modules/templates/pages/templates.vue
new file mode 100644
index 00000000..77b0b32e
--- /dev/null
+++ b/apps/client/src/modules/templates/pages/templates.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+ {{ $t('templates.description') }}
+
+
+
+
+
+ {{ $t('templates.community') }}
+
+
+
+
+
+
+
+ {{ $t('templates.create') }}
+
+
+
+
+
+
diff --git a/apps/client/src/modules/templates/pages/templates/community.vue b/apps/client/src/modules/templates/pages/templates/community.vue
new file mode 100644
index 00000000..2a092c67
--- /dev/null
+++ b/apps/client/src/modules/templates/pages/templates/community.vue
@@ -0,0 +1,14 @@
+
+
+
+
+ community
+
+
diff --git a/apps/client/src/modules/templates/types/index.ts b/apps/client/src/modules/templates/types/index.ts
new file mode 100644
index 00000000..b1b7628b
--- /dev/null
+++ b/apps/client/src/modules/templates/types/index.ts
@@ -0,0 +1,8 @@
+export interface Template {
+ id: string
+ title: string
+ tag: string
+ description: string
+ date: string
+ user: string
+}
diff --git a/apps/client/src/modules/welcome/__tests__/AboutSection.spec.ts b/apps/client/src/modules/welcome/__tests__/AboutSection.spec.ts
new file mode 100644
index 00000000..eb5ea15d
--- /dev/null
+++ b/apps/client/src/modules/welcome/__tests__/AboutSection.spec.ts
@@ -0,0 +1,31 @@
+import { shallowMount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import AboutSection from '../components/sections/AboutSection.vue'
+import { UiButton } from '@/shared/ui'
+import i18n from '@/shared/libs/i18n'
+
+const mockRouter = {
+ push: vi.fn(),
+ beforeEach: vi.fn(),
+}
+
+describe('tests for AboutSection.vue', () => {
+ const wrapper = shallowMount(AboutSection, {
+ global: {
+ plugins: [i18n],
+ mocks: {
+ $router: mockRouter,
+ },
+ },
+ })
+
+ it('should be render correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('should redirect correctly', async () => {
+ const btn = wrapper.find('.btns').findAllComponents(UiButton).at(0)
+ await btn.trigger('click')
+ expect(mockRouter.push).toHaveBeenCalledWith({ name: 'sign-up' })
+ })
+})
diff --git a/apps/client/src/modules/welcome/__tests__/MarketingCards.spec.ts b/apps/client/src/modules/welcome/__tests__/MarketingCards.spec.ts
new file mode 100644
index 00000000..2ce393e3
--- /dev/null
+++ b/apps/client/src/modules/welcome/__tests__/MarketingCards.spec.ts
@@ -0,0 +1,65 @@
+import { type ComponentPublicInstance, nextTick } from 'vue'
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import MarketingCards from '../components/MarketingCards.vue'
+import type { VueWrapper } from '@vue/test-utils'
+import i18n from '@/shared/libs/i18n'
+import { UiCard } from '@/shared/ui'
+
+// TODO: `ComponentPublicInstance` needs to be fixed with certain types
+type MarketingCardsInstance = ComponentPublicInstance<
+ {},
+ {},
+ {
+ cards: any[]
+ width: number
+ getCurrentColor: (id: number) => string
+ currentWidth: (width: string | undefined) => string
+ currentHeight: (height: string | undefined) => string
+ }
+>
+
+describe('tests for MarketingCards.vue', () => {
+ const wrapper = mount(MarketingCards, {
+ global: {
+ plugins: [i18n],
+ },
+ }) as VueWrapper
+
+ it('should be render correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('should render subcomponents', () => {
+ expect(wrapper.findComponent(UiCard).exists()).toBe(true)
+ })
+
+ it('should count cards correctly', async () => {
+ const cards = wrapper.vm.cards
+ wrapper.vm.width = 1900
+ await nextTick()
+ expect(cards).toHaveLength(5)
+ })
+
+ it('returns correct width and height', () => {
+ expect(wrapper.vm.currentWidth('500')).toBe('500px')
+ expect(wrapper.vm.currentWidth(undefined)).toBe('100%')
+ expect(wrapper.vm.currentHeight('100')).toBe('100px')
+ expect(wrapper.vm.currentHeight(undefined)).toBe('100%')
+ })
+
+ it('renders card titles and descriptions correctly', () => {
+ const _titles = wrapper.findAll('h3')
+ const _descriptions = wrapper.findAll('p')
+
+ expect(_titles.length).toBe(5)
+ expect(_descriptions.length).toBe(5)
+
+ _titles.forEach((title, index) => {
+ expect(title.text()).toBe(wrapper.vm.cards[index].title)
+ })
+ _descriptions.forEach((desc, index) => {
+ expect(desc.text()).toBe(wrapper.vm.cards[index].description)
+ })
+ })
+})
diff --git a/apps/client/src/modules/welcome/__tests__/__snapshots__/AboutSection.spec.ts.snap b/apps/client/src/modules/welcome/__tests__/__snapshots__/AboutSection.spec.ts.snap
new file mode 100644
index 00000000..1ed21b14
--- /dev/null
+++ b/apps/client/src/modules/welcome/__tests__/__snapshots__/AboutSection.spec.ts.snap
@@ -0,0 +1,13 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for AboutSection.vue > should be render correctly 1`] = `
+"
+ ✨Core: Task Boards, Real-Time Tracking
+ Your path to perfection
+ Jenda is a cloud-based program for efficient collaborative and individual project and task management.
+
+
+
+
+"
+`;
diff --git a/apps/client/src/modules/welcome/__tests__/__snapshots__/MarketingCards.spec.ts.snap b/apps/client/src/modules/welcome/__tests__/__snapshots__/MarketingCards.spec.ts.snap
new file mode 100644
index 00000000..3ac86d5f
--- /dev/null
+++ b/apps/client/src/modules/welcome/__tests__/__snapshots__/MarketingCards.spec.ts.snap
@@ -0,0 +1,44 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for MarketingCards.vue > should be render correctly 1`] = `
+"
+
+
All convenience in one place
+
+
+
+
+
+
Kanban 🧑💻
+
Organize and visualize your projects with our intuitive Kanban board. Enhance productivity with ease.
+
+
+
+
+
+
Collaborative 👥
+
Improve your teamwork!
+
+
+
+
+
+
+
+
Activity 👔
+
Analyze your activity and move forward.
+
+
+
+
+
+
Chats 💬
+
Communicate, share, discuss. Our convenience for you 😉
+
+
+
+
+
+
+
"
+`;
diff --git a/apps/client/src/modules/welcome/components/MarketingCards.vue b/apps/client/src/modules/welcome/components/MarketingCards.vue
new file mode 100644
index 00000000..7c07ce2c
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/MarketingCards.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+ {{ t('welcome.marketing.heading') }}
+
+
+
+
+
+
+
+ {{ card.title }}
+
+
+ {{ card.description }}
+
+
+
+
+
+
+
+
+
+ {{ card.title }}
+
+
+ {{ card.description }}
+
+
+
+
+
+
+
+
+
+ {{ cards[4].title }}
+
+
+ {{ cards[4].description }}
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/welcome/components/MarqueeList.vue b/apps/client/src/modules/welcome/components/MarqueeList.vue
new file mode 100644
index 00000000..1db99f74
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/MarqueeList.vue
@@ -0,0 +1,15 @@
+
+
+
diff --git a/apps/client/src/modules/welcome/components/ProjectDemo.vue b/apps/client/src/modules/welcome/components/ProjectDemo.vue
new file mode 100644
index 00000000..5247af27
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/ProjectDemo.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/welcome/components/reviews/ReviewWrapper.vue b/apps/client/src/modules/welcome/components/reviews/ReviewWrapper.vue
new file mode 100644
index 00000000..d5b4d9fd
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/reviews/ReviewWrapper.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ review?.name }}
+
+
{{ review?.status }}
+
+
+
+ {{ `“${review?.comment}”` }}
+
+
+
+
diff --git a/apps/client/src/modules/welcome/components/sections/AboutSection.vue b/apps/client/src/modules/welcome/components/sections/AboutSection.vue
new file mode 100644
index 00000000..88b87673
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/sections/AboutSection.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+ ✨
+ {{ badge }}
+
+
+
+ {{ $t('welcome.about.tagline') }}
+
+
+ {{ $t('welcome.about.description') }}
+
+
+
+ {{ $t('welcome.about.btn') }}
+
+
+
+ GitHub
+
+
+
+
diff --git a/apps/client/src/modules/welcome/components/sections/ActivitySection.vue b/apps/client/src/modules/welcome/components/sections/ActivitySection.vue
new file mode 100644
index 00000000..d2c16831
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/sections/ActivitySection.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+ {{ $t('welcome.activity.heading') }}
+
+
+ {{ $t('welcome.activity.about') }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/welcome/components/sections/ChatsSection.vue b/apps/client/src/modules/welcome/components/sections/ChatsSection.vue
new file mode 100644
index 00000000..d01411c7
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/sections/ChatsSection.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/welcome/components/sections/CollaborativeSection.vue b/apps/client/src/modules/welcome/components/sections/CollaborativeSection.vue
new file mode 100644
index 00000000..39bee09f
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/sections/CollaborativeSection.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/welcome/components/sections/KanbanSection.vue b/apps/client/src/modules/welcome/components/sections/KanbanSection.vue
new file mode 100644
index 00000000..5833abe3
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/sections/KanbanSection.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/welcome/components/sections/MembersSection.vue b/apps/client/src/modules/welcome/components/sections/MembersSection.vue
new file mode 100644
index 00000000..4f7f06e6
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/sections/MembersSection.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/welcome/components/sections/SectionWrapper.vue b/apps/client/src/modules/welcome/components/sections/SectionWrapper.vue
new file mode 100644
index 00000000..c7fb5845
--- /dev/null
+++ b/apps/client/src/modules/welcome/components/sections/SectionWrapper.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+ {{ section.name }}
+
+
+
+
+
+
+ {{ section.heading }}
+
+
+ {{ section.about }}
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/welcome/composables/cards.ts b/apps/client/src/modules/welcome/composables/cards.ts
new file mode 100644
index 00000000..695afcfa
--- /dev/null
+++ b/apps/client/src/modules/welcome/composables/cards.ts
@@ -0,0 +1,23 @@
+import { computed, triggerRef, watch } from 'vue'
+import { useDark } from '@vueuse/core'
+import { cardsInfo as cards } from '../constants/cards'
+
+export function useCards() {
+ const isDark = useDark()
+ const urlImg = computed(() =>
+ isDark.value ? '/dev/dev-card-dark.png' : '/dev/dev-card.png',
+ )
+
+ watch(
+ isDark,
+ () => {
+ cards.value.forEach(c => (c.url = urlImg.value))
+ triggerRef(cards)
+ },
+ { immediate: true, flush: 'sync' },
+ )
+
+ return {
+ cards,
+ }
+}
diff --git a/apps/client/src/modules/welcome/composables/sections.ts b/apps/client/src/modules/welcome/composables/sections.ts
new file mode 100644
index 00000000..a5cd438a
--- /dev/null
+++ b/apps/client/src/modules/welcome/composables/sections.ts
@@ -0,0 +1,42 @@
+import { computed, type MaybeRefOrGetter } from 'vue'
+import { useDark } from '@vueuse/core'
+import { useI18n } from 'vue-i18n'
+import type { SectionWrapperType } from '../types'
+
+export function useSection(
+ _section: MaybeRefOrGetter,
+ imgPrefix: string,
+ userImg: string,
+) {
+ const { t } = useI18n()
+ const isDark = useDark()
+
+ const img = computed(() =>
+ isDark.value
+ ? `/dev/${imgPrefix}-section-dark.png`
+ : `/dev/${imgPrefix}-section.png`,
+ )
+
+ const parentKeys = ['name', 'heading', 'about', 'writer'] as const
+ const childReviewKeys = ['name', 'status', 'comment'] as const
+
+ const section = computed(() => {
+ return {
+ ...(Object.fromEntries(
+ parentKeys.map(key => [key, t(`welcome.${_section}.${key}`)]),
+ ) as Pick),
+ img: img.value,
+ review: {
+ ...Object.fromEntries(
+ childReviewKeys.map(key => [
+ key,
+ t(`welcome.${_section}.review.${key}`),
+ ]),
+ ),
+ img: userImg,
+ },
+ }
+ })
+
+ return { section }
+}
diff --git a/apps/client/src/modules/welcome/constants/cards.ts b/apps/client/src/modules/welcome/constants/cards.ts
new file mode 100644
index 00000000..ebf8dfcf
--- /dev/null
+++ b/apps/client/src/modules/welcome/constants/cards.ts
@@ -0,0 +1,57 @@
+import { shallowRef } from 'vue'
+import type { MarketingCard } from '../types'
+
+export const cardsInfo = shallowRef([
+ {
+ id: 0,
+ pagePrefix: 'kanban',
+ height: '270',
+ width: '640',
+ // img
+ bottom: '-260px',
+ right: '-360px',
+ imgWidth: '720px',
+ maxWidth: '400px',
+ },
+ {
+ id: 1,
+ pagePrefix: 'collaborative',
+ height: '300',
+ width: '640',
+ // img
+ bottom: '-160px',
+ right: '20px',
+ imgWidth: '600px',
+ maxWidth: '360px',
+ },
+ {
+ id: 2,
+ pagePrefix: 'members',
+ width: '500',
+ height: '460',
+ // img
+ bottom: '-230px',
+ right: '-290px',
+ imgWidth: '600px',
+ maxWidth: '240px',
+ },
+ {
+ id: 3,
+ pagePrefix: 'activity',
+ width: '500',
+ // img
+ bottom: '-200px',
+ right: '-500px',
+ imgWidth: '700px',
+ maxWidth: '300px',
+ },
+ {
+ id: 4,
+ pagePrefix: 'chats',
+ // img
+ bottom: '0px',
+ right: '-420px',
+ imgWidth: '700px',
+ maxWidth: '300px',
+ },
+])
diff --git a/apps/client/src/modules/welcome/types/index.ts b/apps/client/src/modules/welcome/types/index.ts
new file mode 100644
index 00000000..c4c5340d
--- /dev/null
+++ b/apps/client/src/modules/welcome/types/index.ts
@@ -0,0 +1,34 @@
+export interface MarketingCard extends ImgInCard {
+ id: number
+ title?: string
+ description?: string
+ width?: string
+ height?: string
+ pagePrefix: string
+ maxWidth?: string
+}
+
+interface ImgInCard {
+ url?: string
+ bottom?: string
+ right?: string
+ left?: string
+ top?: string
+ imgWidth?: string
+}
+
+export interface SectionWrapperType {
+ name: string
+ heading: string
+ about: string
+ writer: string
+ img: string
+ review?: Partial
+}
+
+export interface ReviewWrapper {
+ img: string
+ name: string
+ status: string
+ comment: string
+}
diff --git a/apps/client/src/modules/workspace/components/ChooseImg.vue b/apps/client/src/modules/workspace/components/ChooseImg.vue
new file mode 100644
index 00000000..4b0cef35
--- /dev/null
+++ b/apps/client/src/modules/workspace/components/ChooseImg.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/workspace/components/ChoosingButton.vue b/apps/client/src/modules/workspace/components/ChoosingButton.vue
new file mode 100644
index 00000000..3d785290
--- /dev/null
+++ b/apps/client/src/modules/workspace/components/ChoosingButton.vue
@@ -0,0 +1,30 @@
+
+
+
+
+ {{ item.name }}
+
+
+ {{ item.plan }}
+
+
+ {{ item.status }}
+
+
+
+
diff --git a/apps/client/src/modules/workspace/components/ShareLink.vue b/apps/client/src/modules/workspace/components/ShareLink.vue
new file mode 100644
index 00000000..bcef5a8d
--- /dev/null
+++ b/apps/client/src/modules/workspace/components/ShareLink.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/apps/client/src/modules/workspace/components/WorkspaceMenu.vue b/apps/client/src/modules/workspace/components/WorkspaceMenu.vue
new file mode 100644
index 00000000..c10fdd9a
--- /dev/null
+++ b/apps/client/src/modules/workspace/components/WorkspaceMenu.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t(`workspace.popover.${data}`) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ workspace.name }}
+
+
+
+ {{ workspace.plan }}
+
+
+
+ {{ $t(`workspace.popover.${workspace.status}`) }}
+
+
+
+ •
+
+
+ {{ $t('workspace.popover.members', workspace.members.length) }}
+
+
+
+
+
+
+
+
+
+ {{ $t('workspace.popover.section') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/modules/workspace/stores/workspace.ts b/apps/client/src/modules/workspace/stores/workspace.ts
new file mode 100644
index 00000000..304d2546
--- /dev/null
+++ b/apps/client/src/modules/workspace/stores/workspace.ts
@@ -0,0 +1,19 @@
+import { ref } from 'vue'
+import { defineStore } from 'pinia'
+import type { BaseTestWorkspace } from '../types'
+
+export const useWorkspaceStore = defineStore('workspace', () => {
+ const workspace = ref({
+ _id: '0',
+ name: 'Example.io',
+ img: 'https://avatars.githubusercontent.com/u/185750893?s=100&v=4',
+ link: 'https://jenda-app-mnenie.com/example.io',
+ status: 'active',
+ members: [],
+ plan: 'FREE',
+ })
+
+ return {
+ workspace,
+ }
+})
diff --git a/apps/client/src/modules/workspace/types/index.ts b/apps/client/src/modules/workspace/types/index.ts
new file mode 100644
index 00000000..ef2f9645
--- /dev/null
+++ b/apps/client/src/modules/workspace/types/index.ts
@@ -0,0 +1,13 @@
+import type { User } from "@/modules/auth/types"
+
+export interface BaseTestWorkspace {
+ _id: string
+ name: string
+ img: string | Blob
+ link: string
+ status: 'active' | 'archive'
+ members: User[]
+ plan: 'FREE' | 'PREMIUM'
+}
+
+export type ChoosingWorkspaceItem = Pick
diff --git a/apps/client/src/plugins/formkit.ts b/apps/client/src/plugins/formkit.ts
new file mode 100644
index 00000000..498723b6
--- /dev/null
+++ b/apps/client/src/plugins/formkit.ts
@@ -0,0 +1,46 @@
+import autoAnimate from '@formkit/auto-animate'
+import type { AutoAnimateOptions } from '@formkit/auto-animate'
+import type { App } from 'vue'
+
+export default {
+ install: (app: App, options?: Partial) => {
+ app.directive('auto-animate', {
+ mounted(el, binding) {
+ const config: Partial = {
+ duration: binding.value?.duration || 200,
+ easing: binding.value?.easing || 'ease-in',
+ disrespectUserMotionPreference: false,
+ ...binding.value,
+ }
+ autoAnimate(el, (el, action, oldCoords, newCoords) => {
+ let keyframes: Keyframe[]
+ if (action === 'add') {
+ keyframes = [
+ { transform: 'translateY(100%)', opacity: 0 },
+ { transform: 'translateY(0)', opacity: 1 },
+ ]
+ }
+ if (action === 'remain') {
+ const deltaY = oldCoords!.top - newCoords!.top
+
+ const start = { transform: `translate(0, ${deltaY}px)` }
+ const mid = {
+ transform: `translate(0, ${deltaY * 0.4}px)`,
+ offset: 0.7,
+ }
+ const end = { transform: `translate(0, 0)` }
+
+ keyframes = [start, mid, end]
+ }
+ if (action === 'remove') {
+ keyframes = [
+ { transform: 'translateY(0)', opacity: 1 },
+ { transform: 'translateY(-100%)', opacity: 0 },
+ ]
+ }
+ return new KeyframeEffect(el, keyframes!, config)
+ })
+ },
+ })
+ },
+}
diff --git a/apps/client/src/router/guards/index.ts b/apps/client/src/router/guards/index.ts
new file mode 100644
index 00000000..16275878
--- /dev/null
+++ b/apps/client/src/router/guards/index.ts
@@ -0,0 +1 @@
+export * from './layoutResolver'
diff --git a/apps/client/src/router/guards/layoutResolver.ts b/apps/client/src/router/guards/layoutResolver.ts
new file mode 100644
index 00000000..63855ab1
--- /dev/null
+++ b/apps/client/src/router/guards/layoutResolver.ts
@@ -0,0 +1,11 @@
+import type { RouteLocationNormalized } from 'vue-router/auto'
+import { LayoutToFileMap } from '@/core/constants/layouts'
+import { LayoutsEnum } from '@/shared/constants/layouts'
+
+export async function layoutResolverGuard(route: RouteLocationNormalized) {
+ const { layout } = route.meta
+ const normalizedLayoutName = layout as LayoutsEnum || LayoutsEnum.default
+ const fileName = LayoutToFileMap[normalizedLayoutName].split('.vue')[0]
+ const component = await import(`@/core/layouts/${fileName}.vue`)
+ route.meta.layoutComponent = component.default
+}
diff --git a/apps/client/src/router/index.ts b/apps/client/src/router/index.ts
new file mode 100644
index 00000000..c74f77a3
--- /dev/null
+++ b/apps/client/src/router/index.ts
@@ -0,0 +1,22 @@
+import { createRouter, createWebHistory } from 'vue-router/auto'
+import { handleHotUpdate, routes } from 'vue-router/auto-routes'
+import { layoutResolverGuard } from './guards'
+
+export const router = createRouter({
+ history: createWebHistory(import.meta.env.BASE_URL),
+ // resolved: https://github.com/mnenie/jenda/issues/56
+ routes,
+})
+
+router.beforeEach((to, from) => {
+ // Needs to add guard auth logic in router
+ // if (to.meta.requiresAuth === true) {
+ // return router.push({ name: 'sign-in' })
+ // }
+})
+
+router.beforeEach(layoutResolverGuard)
+
+if (import.meta.hot) {
+ handleHotUpdate(router)
+}
diff --git a/apps/client/src/router/types.ts b/apps/client/src/router/types.ts
new file mode 100644
index 00000000..e9279f2d
--- /dev/null
+++ b/apps/client/src/router/types.ts
@@ -0,0 +1,15 @@
+import type { RouteRecordRaw } from 'vue-router'
+import type { LayoutsEnum } from '@/shared/constants/layouts'
+import 'vue-router'
+
+declare module 'vue-router' {
+ interface RouteMeta {
+ layout?: LayoutsEnum
+ requiresAuth?: boolean
+ }
+}
+
+export type RouterRecord = Omit & {
+ name: string
+ children?: RouterRecord[]
+}
diff --git a/apps/client/src/shared/assets/fonts/geist/GeistVariableVF.woff2 b/apps/client/src/shared/assets/fonts/geist/GeistVariableVF.woff2
new file mode 100644
index 00000000..328a020b
Binary files /dev/null and b/apps/client/src/shared/assets/fonts/geist/GeistVariableVF.woff2 differ
diff --git a/apps/client/src/shared/assets/icons/ProjectGrid.vue b/apps/client/src/shared/assets/icons/ProjectGrid.vue
new file mode 100644
index 00000000..6d099df4
--- /dev/null
+++ b/apps/client/src/shared/assets/icons/ProjectGrid.vue
@@ -0,0 +1,281 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/assets/icons/custom-jenda/import.svg b/apps/client/src/shared/assets/icons/custom-jenda/import.svg
new file mode 100644
index 00000000..c66a3e26
--- /dev/null
+++ b/apps/client/src/shared/assets/icons/custom-jenda/import.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/client/src/shared/assets/icons/custom-jenda/project.svg b/apps/client/src/shared/assets/icons/custom-jenda/project.svg
new file mode 100644
index 00000000..893fe35b
--- /dev/null
+++ b/apps/client/src/shared/assets/icons/custom-jenda/project.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/client/src/shared/assets/icons/index.ts b/apps/client/src/shared/assets/icons/index.ts
new file mode 100644
index 00000000..e79b92a0
--- /dev/null
+++ b/apps/client/src/shared/assets/icons/index.ts
@@ -0,0 +1,3 @@
+import ProjectGrid from './ProjectGrid.vue'
+
+export { ProjectGrid }
diff --git a/apps/client/src/shared/composables/breakpoints.ts b/apps/client/src/shared/composables/breakpoints.ts
new file mode 100644
index 00000000..9e2dccca
--- /dev/null
+++ b/apps/client/src/shared/composables/breakpoints.ts
@@ -0,0 +1,24 @@
+import {
+ type Breakpoints,
+ breakpointsSematic,
+ useBreakpoints as VueUseBreakpoints,
+} from '@vueuse/core'
+
+interface CustomBreakpoints extends Breakpoints {
+ intermediateDesktop: string
+ intermediateLaptop: string
+}
+
+export function useBreakpoints() {
+ const breakpoints: CustomBreakpoints = {
+ ...breakpointsSematic,
+ intermediateDesktop: '1820px',
+ intermediateLaptop: '1600px',
+ }
+
+ const breakpointsInstance = VueUseBreakpoints(breakpoints)
+
+ return {
+ breakpoints: breakpointsInstance,
+ }
+}
diff --git a/apps/client/src/shared/composables/expanded.ts b/apps/client/src/shared/composables/expanded.ts
new file mode 100644
index 00000000..f0ee0b0c
--- /dev/null
+++ b/apps/client/src/shared/composables/expanded.ts
@@ -0,0 +1,30 @@
+import { inject, type InjectionKey, provide, type Ref } from 'vue'
+
+interface Expanded {
+ isExpanded: Ref
+ onToggleArea: () => void
+}
+
+type T = Expanded
+
+const key: InjectionKey = Symbol('expanded')
+
+export function provideExpandedContext(value: T) {
+ provide(key, value)
+ return value
+}
+
+export function useExpandedContext<
+ U extends T | undefined = T,
+>(
+ fallback?: U,
+): U extends null ? T | null : T {
+ const expanded = inject(key, fallback)
+ if (expanded)
+ return expanded as T
+
+ if (expanded === null)
+ return expanded as any
+
+ throw new Error('not provided')
+}
diff --git a/apps/client/src/shared/composables/language.ts b/apps/client/src/shared/composables/language.ts
new file mode 100644
index 00000000..dd442f2d
--- /dev/null
+++ b/apps/client/src/shared/composables/language.ts
@@ -0,0 +1,33 @@
+import { type Ref, watch } from 'vue'
+import { useLocalStorage } from '@vueuse/core'
+import { useI18n } from 'vue-i18n'
+
+interface Value {
+ name: string
+ value: string
+}
+
+export function useLanguage(values: Value[], language: Ref) {
+ const { locale } = useI18n()
+ const storage = useLocalStorage('i18n', null)
+
+ const setLanguage = (value: string) => {
+ locale.value = value
+ storage.value = value
+ }
+
+ watch(
+ () => locale.value,
+ (newLocale) => {
+ const selectedOption = values.find(option => option.value === newLocale)
+ if (selectedOption) {
+ language.value = selectedOption.value
+ }
+ },
+ { immediate: true },
+ )
+
+ return {
+ setLanguage,
+ }
+}
diff --git a/apps/client/src/shared/composables/layout-paths.ts b/apps/client/src/shared/composables/layout-paths.ts
new file mode 100644
index 00000000..39f67feb
--- /dev/null
+++ b/apps/client/src/shared/composables/layout-paths.ts
@@ -0,0 +1,66 @@
+import { computed, type MaybeRefOrGetter, toValue } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { type RouterLinkProps, useRoute } from 'vue-router/auto'
+import type {
+ CombinedLink,
+ ProjectLink,
+ WorkspaceLink,
+} from '@/shared/config/types-shared'
+
+export function useLayoutPaths(
+ links: MaybeRefOrGetter,
+ projects: MaybeRefOrGetter,
+) {
+ const route = useRoute()
+ const { t } = useI18n()
+
+ const combinedArr = computed(() => {
+ return [
+ ...toValue(links).map(link => ({
+ ...link,
+ name: t(`sidebar.${link.name}`),
+ icon: link.icon,
+ })),
+ ...toValue(projects).map(project => ({
+ _id: project._id,
+ name: project.name,
+ pathName: `/boards/${project._id}`,
+ color: project.color,
+ })),
+ ]
+ })
+ function isProject(
+ item: CombinedLink,
+ ): item is ProjectLink & { pathName: RouterLinkProps['to'] } {
+ return '_id' in item
+ }
+
+ function isCurrentPath(item: CombinedLink): boolean {
+ if (isProject(item)) {
+ return route.path === `/boards/${item._id}`
+ }
+ if (route.name === 'boards-new') {
+ return item.pathName === 'boards'
+ }
+ if (route.name === 'community') {
+ return item.pathName === 'templates'
+ }
+ return route.path === `/${item.pathName}`
+ }
+
+ const active = computed(() => {
+ const activeItem = toValue(combinedArr).find(item => isCurrentPath(item))
+ return {
+ ...activeItem,
+ name: activeItem!.name,
+ extendedAttrs: {
+ color: 'color' in activeItem! ? activeItem.color : undefined,
+ icon: 'icon' in activeItem! ? activeItem.icon : undefined,
+ },
+ }
+ })
+
+ return {
+ active,
+ }
+}
diff --git a/apps/client/src/shared/composables/scroll.ts b/apps/client/src/shared/composables/scroll.ts
new file mode 100644
index 00000000..9dceff03
--- /dev/null
+++ b/apps/client/src/shared/composables/scroll.ts
@@ -0,0 +1,19 @@
+import { nextTick } from 'vue'
+
+export function useScroll(arr: T) {
+ const keys = [...arr] as const
+
+ const scrollToEl = async (key: (typeof keys)[number]) => {
+ const el = document.getElementById(key)
+ if (!el) {
+ throw new Error(`${key} is undefined`)
+ }
+ await nextTick(() => {
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' })
+ })
+ }
+
+ return {
+ scrollToEl,
+ }
+}
diff --git a/apps/client/src/shared/config/shared-types.ts b/apps/client/src/shared/config/shared-types.ts
new file mode 100644
index 00000000..915fe5ff
--- /dev/null
+++ b/apps/client/src/shared/config/shared-types.ts
@@ -0,0 +1,18 @@
+import type { IconifyIcon } from '@iconify/vue'
+
+interface Path {
+ pathName: string
+}
+
+export interface WorkspaceLink extends Path {
+ id: number
+ name: string
+ icon: IconifyIcon | string
+}
+export interface ProjectLink extends Path {
+ _id: string
+ name: string
+ color?: string
+}
+
+export type CombinedLink = WorkspaceLink | ProjectLink
diff --git a/apps/client/src/shared/constants/layouts.ts b/apps/client/src/shared/constants/layouts.ts
new file mode 100644
index 00000000..014c2266
--- /dev/null
+++ b/apps/client/src/shared/constants/layouts.ts
@@ -0,0 +1,6 @@
+export enum LayoutsEnum {
+ default = 'default',
+ auth = 'auth',
+ welcome = 'welcome',
+ empty = 'empty',
+}
diff --git a/apps/client/src/shared/constants/links.ts b/apps/client/src/shared/constants/links.ts
new file mode 100644
index 00000000..b4d0cbf7
--- /dev/null
+++ b/apps/client/src/shared/constants/links.ts
@@ -0,0 +1,20 @@
+import type { WorkspaceLink } from '../config/shared-types'
+
+export const links: WorkspaceLink[] = [
+ { id: 0, name: 'boards', pathName: 'boards', icon: 'hugeicons:trello' },
+ { id: 1, name: 'notes', pathName: 'notes', icon: 'hugeicons:checkmark-square-03' },
+ {
+ id: 2,
+ name: 'templates',
+ pathName: 'templates',
+ icon: 'hugeicons:dashboard-square-add',
+ },
+ {
+ id: 3,
+ name: 'analytics',
+ pathName: 'analytics',
+ icon: 'hugeicons:analytics-01',
+ },
+ { id: 4, name: 'members', pathName: 'members', icon: 'hugeicons:user-multiple-02' },
+ { id: 5, name: 'settings', pathName: 'settings', icon: 'hugeicons:setting-07' },
+]
diff --git a/apps/client/src/shared/helpers/helperColor.ts b/apps/client/src/shared/helpers/helperColor.ts
new file mode 100644
index 00000000..9908a53e
--- /dev/null
+++ b/apps/client/src/shared/helpers/helperColor.ts
@@ -0,0 +1,11 @@
+export function formatLabelColor(hex: string, amount: number): string {
+ let color = hex.replace('#', '')
+ if (color.length === 3) {
+ color = color.split('').map(c => c + c).join('')
+ }
+ const r = Math.max(0, Math.min(255, Number.parseInt(color.slice(0, 2), 16) - amount))
+ const g = Math.max(0, Math.min(255, Number.parseInt(color.slice(2, 4), 16) - amount))
+ const b = Math.max(0, Math.min(255, Number.parseInt(color.slice(4, 6), 16) - amount))
+
+ return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`
+}
diff --git a/apps/client/src/shared/helpers/redirectBlank.ts b/apps/client/src/shared/helpers/redirectBlank.ts
new file mode 100644
index 00000000..3b043a40
--- /dev/null
+++ b/apps/client/src/shared/helpers/redirectBlank.ts
@@ -0,0 +1,3 @@
+export function redirect(url: string) {
+ window.open(url, '_blank')
+}
diff --git a/apps/client/src/shared/libs/i18n/index.ts b/apps/client/src/shared/libs/i18n/index.ts
new file mode 100644
index 00000000..50d6465b
--- /dev/null
+++ b/apps/client/src/shared/libs/i18n/index.ts
@@ -0,0 +1,54 @@
+import { computed } from 'vue'
+import { createI18n } from 'vue-i18n'
+import { enZod, ruZod, zhZod } from '../vee-validate/rules'
+import { enLocale, ruLocale, zhLocale } from './locales'
+import { customPluralRule } from './plurals'
+
+export type MessageSchema = typeof enLocale
+
+const messages = {
+ 'en-US': {
+ ...enLocale,
+ errors: {
+ ...enZod,
+ },
+ },
+ 'ru-RU': {
+ ...ruLocale,
+ errors: {
+ ...ruZod,
+ },
+ },
+ 'zh-CN': {
+ ...zhLocale,
+ errors: {
+ ...zhZod,
+ },
+ },
+}
+
+const getCurrentLocale = computed(() => {
+ const storageLanguage = localStorage.getItem('i18n')
+ if (storageLanguage) {
+ return storageLanguage
+ }
+ if (navigator.language.split('-')[0] === 'ru') {
+ return 'ru-RU'
+ }
+ return 'en-US'
+})
+
+const i18n = createI18n<[MessageSchema], 'en-US' | 'ru-RU' | 'zh-CN'>({
+ legacy: false,
+ locale: getCurrentLocale.value,
+ globalInjection: true,
+ pluralRules: {
+ 'ru-RU': {
+ // @ts-expect-error i18n shema
+ customPluralRule,
+ },
+ },
+ messages,
+})
+
+export default i18n
diff --git a/apps/client/src/shared/libs/i18n/locales/en-US.ts b/apps/client/src/shared/libs/i18n/locales/en-US.ts
new file mode 100644
index 00000000..7dd5df15
--- /dev/null
+++ b/apps/client/src/shared/libs/i18n/locales/en-US.ts
@@ -0,0 +1,528 @@
+export default {
+ sidebar: {
+ badge: 'free',
+ input: 'Search',
+ section: 'workspace',
+ projects: 'projects',
+ boards: 'Boards',
+ notes: 'Notes',
+ templates: 'Templates',
+ analytics: 'Analytics',
+ members: 'Members',
+ settings: 'Settings',
+ integrations: 'Integraions',
+ help: {
+ title: 'Help',
+ items: 'Support developers | Keyboard shortcuts | Join us',
+ },
+ plan: {
+ btn: 'Buy a subscription',
+ title: 'Premium Plan',
+ description:
+ 'boards left',
+ tooltip: '1/3 free workspaces',
+ },
+ soon: 'soon',
+ },
+ header: {
+ navigator: {
+ question: 'Hotkeys',
+ messages: 'Messages',
+ },
+ user: {
+ welcome: 'Go to Welcome',
+ logout: 'Log out',
+ },
+ share: 'Share link',
+ },
+ boards: {
+ create: 'Add board | Add new board',
+ filters: {
+ sort: {
+ title: 'Sort by',
+ name: 'Name',
+ tasks: 'Tasks',
+ date: 'Creation date',
+ default: 'Default',
+ },
+ advanced: {
+ title: 'Advanced filter',
+ empty: 'No such filters available :/',
+ statuses: {
+ title: 'Statuses',
+ arr: [
+ {
+ value: 'active',
+ label: 'Active',
+ },
+ {
+ value: 'archived',
+ label: 'Archived',
+ },
+ ],
+ },
+ labels: {
+ title: 'Labels',
+ },
+ },
+ search: 'Find a board...',
+ },
+ forms: {
+ creating: {
+ title: 'Creating a board',
+ description: 'You can create a new board to manage tasks',
+ name: {
+ label: 'Name',
+ placeholder: 'Enter the name of the board',
+ },
+ labels: {
+ label: 'Labels',
+ placeholder: 'Enter a label',
+ description: 'Start typing a label and press \'Enter\' to save it to your existing labels',
+ alert: '📝 How to change the label color? Specify the label name followed by [#desired color]. For example: jenda [#000000].',
+ },
+ btns: 'Cancel | Create',
+ },
+ },
+ empty: {
+ title: 'No board',
+ description: 'Create your first board and start working',
+ },
+ card: {
+ date_updated: 'Updated',
+ },
+ chart: {
+ title_boards: 'Сreated boards',
+ description_boards:
+ 'Create, share, work, and then analyze your activity on Jenda',
+ title_tasks: 'Your tasks',
+ description_tasks: 'Solve and complete the tasks you started',
+ },
+ columns: ['Kanban name', 'Status', 'Labels', 'Participants', 'Tasks', 'Estimate', 'Creation date'],
+ },
+ picker: {
+ placeholder: 'Custom color',
+ tabs: 'Solid | Gradients',
+ },
+ templates: {
+ title: 'Templates',
+ description: 'Choose one of the available templates to create your project',
+ import: 'Click or drag files to import. Supports files',
+ community: 'Comunity templates',
+ create: 'Create new template',
+ user: 'creator',
+ items: [
+ {
+ title: 'Base Kanban',
+ tag: 'Recommended',
+ description: 'Create a basic project with "Base Kanban" template',
+ date: 'June 12, 2024',
+ },
+ {
+ title: 'Roadmap',
+ tag: 'New',
+ description: 'Create a project based on our "Roadmap" special template',
+ date: 'June 18, 2024',
+ },
+ {
+ title: 'Web Development',
+ tag: 'New',
+ description: 'Create a basic project with "Web Development" template',
+ date: 'July 16, 2024',
+ },
+ ],
+ },
+ members: {
+ title: 'Workspace participants',
+ description:
+ 'Members can view and join whiteboards for the workspace, as well as create new whiteboards in that space.',
+ content: {
+ all: {
+ title: 'Invite users',
+ description:
+ 'Anyone who has an invitation link can join this free workspace. The link can be disabled and recreated at any time. Pending invitations count towards a limit of 10 participants.',
+ btn: 'All participants',
+ },
+ guests: {
+ title: 'Guests',
+ description:
+ 'Guests can only view and edit the boards they have been added to.',
+ btn: 'Guests',
+ },
+ },
+ role: {
+ member: 'member',
+ admin: 'admin',
+ },
+ btns: {
+ leave: 'Leave',
+ view: 'View the boards',
+ },
+ },
+ analytics: {
+ title: 'Workspace Analytics',
+ description: 'View and analyze your activity on Jenda',
+ charts: {
+ users: {
+ name: 'Total users',
+ description: 'Number of users in the workspace',
+ },
+ online: {
+ name: 'Workspace activity',
+ },
+ tasks: {
+ name: 'Total tasks',
+ dataset: {
+ names: ['Uncompleted', 'Completed'],
+ },
+ },
+ boards: {
+ name: 'Total boards',
+ },
+ },
+ badges: ['Convenient', 'Informative', 'Anytime'],
+ share: {
+ btn: 'Share',
+ description: 'you can easily take a screenshot and send it to anyone',
+ },
+ },
+ settings: {
+ title: 'Settings',
+ description: 'Manage your account settings and set some preferences.',
+ lang: {
+ label: 'Language',
+ about: 'This is the language that will be used in the dashboard.',
+ },
+ theme: {
+ label: 'Theme',
+ about: 'Select the theme for the dashboard.',
+ variants: {
+ light: 'Light',
+ dark: 'Dark',
+ auto: 'System',
+ },
+ btn: 'Update preferences',
+ },
+ },
+ sheet: {
+ title: 'Create board',
+ description:
+ 'The new board will allow you to create tasks for solving them.',
+ form: {
+ name: {
+ label: 'Board name',
+ placeholder: 'e.g. "Jenda"',
+ },
+ description: {
+ label: 'Board description',
+ placeholder: 'e.g. development board for our company',
+ },
+ submit: 'Create board',
+ },
+ },
+ authentication: {
+ login: {
+ title: 'Welcome back',
+ description: 'Enter your info below to sign in your account',
+ btn: 'Continue',
+ proposal: 'Don\'t have an account?',
+ route: 'Sign Up Now',
+ },
+ registration: {
+ title: 'Get started',
+ description: 'Enter your info below to create your account',
+ btn: 'Continue',
+ proposal: 'Have an account?',
+ route: 'Sign In Now',
+ },
+ confirm: {
+ title: 'Confirm your email',
+ description: 'Enter the confirmation code sent to your email',
+ btn: 'Confirm',
+ alert: 'We care about your security. You can trust us to keep your information safe and secure!',
+ proposal: 'Didn\'t receive the code?',
+ route: 'Resend',
+ },
+ workspace: {
+ creating: {
+ title: 'Create your workspace',
+ logo: {
+ label: 'Choose a logo for your workspace',
+ btn: 'Choose image | Delete',
+ description: '*.png, *.jpeg files up to 10MB at least 400px by 400px',
+ },
+ alert: 'Remember, if you don’t choose a logo, jenda will automatically generate one for you',
+ form: {
+ name: {
+ label: 'Name',
+ placeholder: 'Enter your workspace name',
+ },
+ url: {
+ label: 'Custom link',
+ },
+ },
+ },
+ choosing: {
+ title: 'Choose a workspace',
+ description: 'The more your team engages with Jenda, the more dynamic your workspaces become. | Jenda transforms collaboration in shared workspaces—from organizing projects and managing tasks to centralizing discussions and building efficient workflows.',
+ label: 'Available workspaces',
+ },
+ route: 'Continue',
+ },
+ form: {
+ email: 'Email',
+ password: 'Password',
+ otp: 'You need to enter a sequence of single-digit numerical values.',
+ },
+ line: 'Or continue with',
+ privacy: [
+ 'By clicking continue, you agree to our',
+ 'By clicking sign in, you agree to our',
+ 'Terms of Service',
+ 'and',
+ 'Privacy Policy.',
+ ],
+ back: 'To Home',
+ auth_alert:
+ 'authorization via google will be replaced with gitlab after 28.02.2025',
+ },
+ kanban: {
+ sorting: {
+ all: 'All Tasks',
+ activity: 'By activity',
+ workload: 'By workload',
+ },
+ active: 'active',
+ archived: 'archived',
+ new: 'Add new column',
+ cards: {
+ add: 'Add card',
+ },
+ sheet: {
+ column: {
+ title: 'Add new column',
+ description:
+ 'When creating a new column, you can create cards and use the kanban board.',
+ form: {
+ label: 'Column name',
+ placeholder: 'Enter the column name',
+ submit: 'Create column',
+ },
+ },
+ },
+ configuration: {
+ title: 'Configure your board',
+ description: 'Customize the board for your convenience',
+ name: 'Here the name and the color indicator of your project',
+ users: 'Participants',
+ status: 'Select the status of your project',
+ clear: 'Clear the board',
+ update: 'Apply changes',
+ },
+ },
+ welcome: {
+ header: {
+ links: [
+ 'About',
+ 'Kanban',
+ 'Collaborative',
+ 'Activity',
+ 'Members',
+ 'Chats',
+ ],
+ login: 'Log In',
+ reg: 'Get started',
+ },
+ about: {
+ tagline: 'Your path to perfection',
+ description:
+ 'Jenda is a cloud-based program for efficient collaborative and individual project and task management.',
+ badge: 'Core: Task Boards, Real-Time Tracking',
+ badge_mobile: 'Core: Boards, Real-Time and etc.',
+ btn: 'Get started',
+ },
+ marketing: {
+ heading: 'All convenience in one place',
+ cards: [
+ {
+ title: 'Kanban 🧑💻',
+ description:
+ 'Organize and visualize your projects with our intuitive Kanban board. Enhance productivity with ease.',
+ },
+ {
+ title: 'Collaborative 👥',
+ description: 'Improve your teamwork!',
+ },
+ {
+ title: 'Members 🌐',
+ description:
+ 'Invite new participants to your projects. Enjoy the development together!',
+ },
+ {
+ title: 'Activity 👔',
+ description: 'Analyze your activity and move forward.',
+ },
+ {
+ title: 'Chats 💬',
+ description:
+ 'Communicate, share, discuss. Our convenience for you 😉',
+ },
+ ],
+ },
+ marquee: [
+ 'We know',
+ 'You enjoy',
+ '👑 The best alternative in Russia',
+ 'Open source',
+ '👻 It\'s easier with us',
+ 'No ads',
+ 'Perfection is close 🤟',
+ ],
+ kanban: {
+ name: 'Use Kanban',
+ heading: 'What do you know about our Kanban? 👀',
+ about:
+ 'Kanban is a simple and effective way to manage projects, tasks, and time. By visualizing your tasks on a board, you can easily track progress and see what needs to be done next.',
+ writer: 'Try to conquer the world fully with our Kanban',
+ review: {
+ name: 'Alex Peshkov',
+ status: 'Frontend Developer, Open Source Enthusiast',
+ comment:
+ 'I enjoy kanban every day and I advise everyone to try it. This is amazing!',
+ },
+ },
+ collaborative: {
+ name: 'Easier Together',
+ heading: 'Every task is a step towards 🤝 collective success!',
+ about:
+ 'Our service is designed for teams striving for success through collaboration. With it, every participant can easily track tasks, share ideas, and move towards a common goal together with colleagues.',
+ writer: 'Join us and move towards success as a team!',
+ review: {
+ name: 'Bagaudtinov Airat',
+ status: 'Backend Developer',
+ comment:
+ 'Working together is great! Jenda provides such a wonderful opportunity.',
+ },
+ },
+ activity: {
+ heading: 'Progress in action',
+ about:
+ 'You can view statistics on completed tasks and open boards. This allows you to track team progress, see completed tasks, and manage current tasks and projects.',
+ },
+ members: {
+ name: 'Team of participants',
+ heading: 'Work with a friendly 😉 team of developers',
+ about:
+ 'Our service allows you to create and manage teams, add participants, share tasks, and receive real-time feedback.',
+ writer: 'Join and be in the spotlight!',
+ review: {
+ name: 'Alex Peshkov',
+ status: 'Frontend Developer, Open Source Enthusiast',
+ comment:
+ 'A great tool for collaboration. Convenient interface and useful features! 👍 Working with friends',
+ },
+ },
+ chats: {
+ name: 'Ease of communication',
+ heading: 'How often do you communicate with your team? 🤔',
+ about: `Our chats allow you to easily exchange messages, share ideas, and stay in sync with your team. Stay connected and work more efficiently!`,
+ writer: 'Share your thoughts and ideas in our convenient chats!',
+ review: {
+ name: 'Ayrat Bagaoutdinov',
+ status: 'Backend Developer',
+ comment:
+ 'The chats here are really convenient! It\'s easy to exchange ideas and quickly resolve issues with the team.',
+ },
+ },
+ footer:
+ 'cloud-based program for task management. The source code is available on',
+ mobile: {
+ section_blocks: [
+ {
+ title: 'About Us',
+ description: 'Get to know Jenda',
+ },
+ {
+ title: 'Kanban',
+ description: 'Organize projects with Kanban',
+ },
+ {
+ title: 'Collaboration',
+ description: 'Improve your teamwork!',
+ },
+ {
+ title: 'Members',
+ description: 'Invite members to projects',
+ },
+ {
+ title: 'Activity',
+ description: 'Analyze your activity',
+ },
+ {
+ title: 'Chats',
+ description: 'Communicate, share, discuss',
+ },
+ ],
+ links: [
+ {
+ title: 'product',
+ },
+ {
+ title: 'contact us on Telegram',
+ },
+ ],
+ },
+ },
+ not_found: {
+ title: 'Not Found',
+ description: 'The page you were looking for can\'t be found',
+ btn: 'Go to Home',
+ },
+ dialogs: {
+ share: {
+ title: 'Share Link',
+ description: 'Anyone who has this link will be able to join',
+ close: 'Cancel',
+ },
+ hotkeys: {
+ title: 'Hotkeys',
+ badges: 'Open a search in the workspace | Go to the main page | Log out',
+ alert: 'You\'ll see { badge } used a lot below. This indicates command on Mac and control on Windows and Linux.',
+ },
+ },
+ validations: {
+ email: 'Email',
+ password: 'Password',
+ pin: 'Confirmation code',
+ name: 'Name',
+ url: 'URL handle',
+ },
+ search: {
+ placeholder: 'Type smth for search...',
+ empty: 'No results found',
+ recently: 'Recently',
+ links: 'Links',
+ select: 'Select',
+ open: 'Open',
+ close: 'Close',
+ },
+ workspace: {
+ popover: {
+ members: '{n} members',
+ section: 'Management',
+ pay: 'Plans and Billing',
+ invite: 'Invite to Project',
+ team: 'Team',
+ settings: 'Change Information',
+ active: 'Active',
+ archive: 'Archived',
+ },
+ },
+ table: {
+ row_from_all: 'from',
+ row_selected: 'rows selected',
+ rows_on_page: 'Rows per page',
+ page: 'Page',
+ empty: 'The table is empty',
+ },
+}
diff --git a/apps/client/src/shared/libs/i18n/locales/index.ts b/apps/client/src/shared/libs/i18n/locales/index.ts
new file mode 100644
index 00000000..7dc8bfe2
--- /dev/null
+++ b/apps/client/src/shared/libs/i18n/locales/index.ts
@@ -0,0 +1,3 @@
+export { default as enLocale } from './en-US'
+export { default as ruLocale } from './ru-RU'
+export { default as zhLocale } from './zh-CN'
diff --git a/apps/client/src/shared/libs/i18n/locales/ru-RU.ts b/apps/client/src/shared/libs/i18n/locales/ru-RU.ts
new file mode 100644
index 00000000..b943b3cb
--- /dev/null
+++ b/apps/client/src/shared/libs/i18n/locales/ru-RU.ts
@@ -0,0 +1,529 @@
+export default {
+ sidebar: {
+ badge: 'free',
+ input: 'Поиск',
+ section: 'воркспейс',
+ projects: 'проекты',
+ boards: 'Канбаны',
+ templates: 'Шаблоны',
+ notes: 'Заметки',
+ analytics: 'Аналитика',
+ members: 'Участники',
+ settings: 'Настройки',
+ integrations: 'Интеграции',
+ help: {
+ title: 'Помощь',
+ items: 'Поддержать разработчиков | Сочетания клавиш | Присоединяйтесь к нам',
+ },
+ plan: {
+ btn: 'Приобрести',
+ title: 'Премиум план',
+ description: 'проектов осталось',
+ tooltip: '1/3 бесплатных workspaces',
+ },
+ soon: 'скоро',
+ },
+ header: {
+ navigator: {
+ question: 'Хоткеи',
+ messages: 'Сообщения',
+ },
+ user: {
+ welcome: 'Перейти на главную',
+ logout: 'Выйти',
+ },
+ share: 'Поделиться',
+ },
+ boards: {
+ create: 'Добавить канбан | Добавить новый канбан',
+ filters: {
+ sort: {
+ title: 'Отсортированно по',
+ name: 'Названию',
+ tasks: 'Задачам',
+ date: 'Дате создания',
+ default: 'Умолчанию',
+ },
+ advanced: {
+ title: 'Расширенный фильтр',
+ empty: 'Таких фильтров нет :/',
+ statuses: {
+ title: 'Статусы',
+ arr: [
+ {
+ value: 'active',
+ label: 'Активно',
+ },
+ {
+ value: 'archived',
+ label: 'В архиве',
+ },
+ ],
+ },
+ labels: {
+ title: 'Лейблы',
+ },
+ },
+ search: 'Найти канбан...',
+ },
+ forms: {
+ creating: {
+ title: 'Создание канбана',
+ description: 'Вы можете создать новый канбан чтобы управлять задачами',
+ name: {
+ label: 'Название',
+ placeholder: 'Введите название канбана',
+ },
+ labels: {
+ label: 'Лейблы',
+ placeholder: 'Введите лейбл',
+ description: 'Начните писать лейбл и нажмите \'Enter\', чтобы он записался в ваши существующие лейблы',
+ alert: '📝 Как изменить цвет лейбла? Укажите название лейбла, а затем — [#нужный цвет]. Например: jenda [#000000].',
+ },
+ btns: 'Отмена | Создать',
+ },
+ },
+ empty: {
+ title: 'Нет канбанов',
+ description: 'Создайте свой первый канбан и начните работать',
+ },
+ card: {
+ date_updated: 'Обновлено',
+ },
+ chart: {
+ title_boards: 'Созданные доски',
+ description_boards:
+ 'Создавайте, делитесь, а затем анализируйте свою активность на Jenda',
+ title_tasks: 'Ваши задачи',
+ description_tasks: 'Решайте и завершайте начатые задачи',
+ },
+ columns: ['Название канбана', 'Статус', 'Лейблы', 'Участники', 'Задачи', 'Оценка', 'Дата создания'],
+ },
+ picker: {
+ placeholder: 'Кастомный цвет',
+ tabs: 'Однородные | Градиенты',
+ },
+ templates: {
+ title: 'Шаблоны',
+ description:
+ 'Выберите один из доступных шаблонов, чтобы создать ваш проект',
+ import: 'Нажмите или перетащите файлы для импорта. Поддерживает файлы',
+ community: 'Шаблоны сообщества',
+ create: 'Создать шаблон',
+ user: 'создатель',
+ items: [
+ {
+ title: 'Базовый шаблон',
+ tag: 'Рекомендуемый',
+ description:
+ 'Создайте базовый проект с помощью шаблона "Базовый шаблон"',
+ date: '12 июня 2024',
+ },
+ {
+ title: 'Дорожная карта',
+ tag: 'Новый',
+ description:
+ 'Создайте проект на основе нашего шаблона "Дорожная карта"',
+ date: '18 июня 2024',
+ },
+ {
+ title: 'Веб-разработка',
+ tag: 'Новый',
+ description:
+ 'Создайте базовый проект с помощью шаблона "Веб-разработка"',
+ date: '16 июля 2024',
+ },
+ ],
+ },
+ members: {
+ title: 'Участники рабочего пространства',
+ description:
+ 'Участники могут просматривать и присоединяться к доскам рабочего пространства, а также создавать новые доски в этом пространстве.',
+ content: {
+ all: {
+ title: 'Пригласить пользователей',
+ description:
+ 'Каждый, у кого есть ссылка-приглашение, может присоединиться к этому бесплатному рабочему пространству. Ссылка может быть отключена и в любой момент воссоздана. Ожидающие приглашения учитываются в лимите в 10 участников.',
+ btn: 'Все участники',
+ },
+ guests: {
+ title: 'Гости',
+ description:
+ 'Гости могут только просматривать и редактировать доски, к которым они были добавлены.',
+ btn: 'Гости',
+ },
+ },
+ role: {
+ member: 'участник',
+ admin: 'админ',
+ },
+ btns: {
+ leave: 'Покинуть',
+ view: 'Просмотреть доски',
+ },
+ },
+ analytics: {
+ title: 'Аналитика рабочего пространства',
+ description: 'Просматривайте и анализируйте свою активность на Jenda',
+ charts: {
+ users: {
+ name: 'Всего пользователей',
+ description: 'Количество пользователей в рабочем пространстве',
+ },
+ online: {
+ name: 'Активность в рабочем пространстве',
+ },
+ tasks: {
+ name: 'Сводка по задачам',
+ dataset: {
+ names: ['Не выполнено', 'Выполнено'],
+ },
+ },
+ boards: {
+ name: 'Созданные доски',
+ },
+ },
+ badges: ['Удобно', 'Информативно', 'В любое время'],
+ share: {
+ btn: 'Поделиться',
+ description: 'вы можете легко сделать скриншот и отправить кому угодно',
+ },
+ },
+ settings: {
+ title: 'Настройки',
+ description:
+ 'Управляйте настройками своей учетной записи и установите предпочтения.',
+ lang: {
+ label: 'Язык',
+ about: 'Это язык, который будет использоваться в панели инструментов.',
+ },
+ theme: {
+ label: 'Тема',
+ about: 'Выберите тему для панели инструментов.',
+ variants: {
+ light: 'Светлая',
+ dark: 'Темная',
+ auto: 'Системная',
+ },
+ btn: 'Обновить настройки',
+ },
+ },
+ sheet: {
+ title: 'Создать доску',
+ description: 'Новая доска позволит вам создавать задачи для их решения.',
+ form: {
+ name: {
+ label: 'Название доски',
+ placeholder: 'например, "Jenda"',
+ },
+ description: {
+ label: 'Описание доски',
+ placeholder: 'например, доска для разработки нашей компании',
+ },
+ submit: 'Создать доску',
+ },
+ },
+ authentication: {
+ login: {
+ title: 'С возвращением',
+ description: 'Введите свои данные ниже, чтобы войти в свой аккаунт',
+ btn: 'Продолжить',
+ proposal: 'Ещё нет аккаунта?',
+ route: 'Зарегистрируйтесь',
+ },
+ registration: {
+ title: 'Создайте аккаунт',
+ description: 'Введите свои данные ниже, чтобы создать аккаунт',
+ btn: 'Продолжить',
+ proposal: 'Уже есть аккаунт?',
+ route: 'Войдите',
+ },
+ confirm: {
+ title: 'Подтвердите почту',
+ description: 'Введите код подтверждения, отправленный на вашу почту',
+ btn: 'Подтвердить',
+ alert: 'Мы заботимся о вашей безопасности. Вы можете смело нам доверять!',
+ proposal: 'Не получили код?',
+ route: 'Отправить повторно',
+ },
+ workspace: {
+ creating: {
+ title: 'Создайте воркспейс',
+ logo: {
+ label: 'Выберите логотип',
+ btn: 'Выбрать картинку | Удалить',
+ description: '*.png, *.jpeg до 10 МБ, размером не менее 400 x 400 пикселей',
+ },
+ alert: 'Помните, если вы не выберете лого, то jenda автоматически создаст его для вас',
+ form: {
+ name: {
+ label: 'Название',
+ placeholder: 'Введите название',
+ },
+ url: {
+ label: 'Удобная ссылка',
+ },
+ },
+ },
+ choosing: {
+ title: 'Выберите воркспейс',
+ description: 'Чем активнее ваша команда использует Jenda, тем эффективнее становятся ваши рабочие пространства. | Jenda преображает совместную работу в общих рабочих пространствах. От организации проектов и управления задачами до объединения обсуждений и создания эффективных процессов.',
+ label: 'Доступные воркспейсы',
+ },
+ route: 'Продолжить',
+ },
+ form: {
+ email: 'Почта',
+ password: 'Пароль',
+ otp: 'Вам нужно ввести последовательность односимвольных числовых значений.',
+ },
+ line: 'Или продолжите с',
+ privacy: [
+ 'Нажав на продолжить, вы соглашаетесь с нашими',
+ 'Нажав на кнопку "войти", вы соглашаетесь с нашими',
+ 'Условиями предоставления услуг',
+ 'и',
+ 'Политикой конфиденциальности.',
+ ],
+ back: 'На главную',
+ auth_alert:
+ 'aвторизация через google будет заменена на gitlab после 28.02.2025',
+ },
+ kanban: {
+ sorting: {
+ all: 'Все задачи',
+ activity: 'Aктивность',
+ workload: 'Нагрузка',
+ },
+ active: 'aктивно',
+ archived: 'в архиве',
+ new: 'Добавить новую колонку',
+ cards: {
+ add: 'Добавить карточку',
+ },
+ sheet: {
+ column: {
+ title: 'Добавить новую колонку',
+ description:
+ 'При создании новой колонки вы можете создавать карточки и использовать доску канбан.',
+ form: {
+ label: 'Название колонки',
+ placeholder: 'Введите название колонки',
+ submit: 'Создать колонку',
+ },
+ },
+ },
+ configuration: {
+ title: 'Настройте свою доску',
+ description: 'Настройте доску для вашего удобства',
+ name: 'Название и цветовой индикатор вашего проекта',
+ users: 'Участники',
+ status: 'Выберите статус вашего проекта',
+ clear: 'Очистить доску',
+ update: 'Применить изменения',
+ },
+ },
+ welcome: {
+ header: {
+ links: [
+ 'О нас',
+ 'Канбан',
+ 'Совместная работа',
+ 'Активность',
+ 'Участники',
+ 'Чаты',
+ ],
+ login: 'Войти',
+ reg: 'Зарегистрироваться',
+ },
+ about: {
+ tagline: 'Ваш путь к совершенству',
+ description:
+ 'Jenda — это облачная программа для эффективного управления проектами и задачами как в команде, так и индивидуально.',
+ badge: 'Основные функции: Доски задач, Отслеживание в реальном времени',
+ badge_mobile: 'Ядро: Проекты, Коллаборации, ...',
+ btn: 'Зарегистрироваться',
+ },
+ marketing: {
+ heading: 'Всё удобство в одном месте',
+ cards: [
+ {
+ title: 'Канбан 🧑💻',
+ description:
+ 'Организуйте и визуализируйте свои проекты с помощью нашей интуитивно понятной доски Канбан.',
+ },
+ {
+ title: 'Совместная работа 👥',
+ description: 'Улучшите свою командную работу!',
+ },
+ {
+ title: 'Участники 🌐',
+ description:
+ 'Пригласите новых участников в ваши проекты. Наслаждайтесь разработкой вместе!',
+ },
+ {
+ title: 'Активность 👔',
+ description: 'Анализируйте вашу активность и двигайтесь вперед.',
+ },
+ {
+ title: 'Чаты 💬',
+ description: 'Общайтесь, делитесь, обсуждайте. Удобство для вас 😉',
+ },
+ ],
+ },
+ marquee: [
+ 'Мы знаем',
+ 'Вы наслаждаетесь',
+ '👑 Лучший аналог в РФ',
+ 'Open source',
+ '👻 У нас проще',
+ 'Без рекламы',
+ 'Совершенство рядом 🤟',
+ ],
+ kanban: {
+ name: 'Пользуйтесь kanban\'ом',
+ heading: 'Что вы знаете о нашем kanban? 👀',
+ about: `Kanban – это простой и эффективный способ управлять проектами, задачами и временем. Визуализируя
+ ваши задачи на доске, вы легко сможете отслеживать прогресс и видеть, что нужно сделать дальше.`,
+ writer: 'Попробуйте освоить мир на полную с нашим kanban',
+ review: {
+ name: 'Александр Пешков',
+ status: 'Frontend-разработчик, Open source энтузиаст',
+ comment:
+ 'Я использую канбан каждый день и советую всем попробовать. Это потрясающе!',
+ },
+ },
+ collaborative: {
+ name: 'Вместе легче',
+ heading: 'Каждая задача – это шаг к 🤝 общему успеху!',
+ about: `Наш сервис создан для команд, которые стремятся к успеху через сотрудничество. С его помощью каждый участник может легко отслеживать задачи, делиться идеями и вместе с коллегами двигаться к общей цели.`,
+ writer: 'Подключайтесь и двигайтесь к успеху всей командой!',
+ review: {
+ name: 'Багаутдинов Айрат',
+ status: 'Backend-разработчик',
+ comment:
+ 'Работать совместно это круто! Jenda предоставляет такую шикарную возможность.',
+ },
+ },
+ activity: {
+ heading: 'Прогресс в действии',
+ about:
+ 'Вы можете просматривать статистику по завершённым задачам и открытым доскам. Это позволяет отслеживать прогресс команды, видеть выполненные задачи, а также управлять текущими задачами и проектами. ',
+ },
+ members: {
+ name: 'Команда участников',
+ heading: 'Работа в команде дружных 😉 разработчиков',
+ about:
+ 'Наш сервис дает возможность создавать и управлять командами, добавлять участников, делиться задачами и получать обратную связь в реальном времени.',
+ writer: 'Участвуй и будь в центре внимания!',
+ review: {
+ name: 'Александр Пешков',
+ status: 'Frontend-разработчик, Open source энтузиаст',
+ comment:
+ 'Прекрасный инструмент для совместной работы. Удобный интерфейс и полезные функции! 👍 Работаю вместе с друзьями',
+ },
+ },
+ chats: {
+ name: 'Легкость общения',
+ heading: 'Как часто вы коммуницируете со своей командой ? 🤔',
+ about: `Наши чаты позволяют вам легко обмениваться сообщениями, делиться идеями и оставаться на одной волне с командой. Оставайтесь на связи и работайте более эффективно!`,
+ writer: 'Делитесь своими мыслями и идеями в наших удобных чатах!',
+ review: {
+ name: 'Багаутдинов Айрат',
+ status: 'Backend-разработчик',
+ comment:
+ 'Чаты здесь действительно удобные! Легко обмениваться идеями и быстро решать вопросы с командой.',
+ },
+ },
+ footer:
+ 'облачная программа для управления задачами. Исходный код доступен на',
+ mobile: {
+ section_blocks: [
+ {
+ title: 'О нас',
+ description: 'Познакомьтесь с Jenda',
+ },
+ {
+ title: 'Канбан',
+ description: 'Организуйте проекты с канбан',
+ },
+ {
+ title: 'Совместная работа',
+ description: 'Улучшите свою командную работу!',
+ },
+ {
+ title: 'Участники',
+ description: 'Пригласите участников в проекты',
+ },
+ {
+ title: 'Активность',
+ description: 'Анализируйте вашу активность',
+ },
+ {
+ title: 'Чаты',
+ description: 'Общайтесь, делитесь, обсуждайте',
+ },
+ ],
+ links: [
+ {
+ title: 'продукт',
+ },
+ {
+ title: 'свяжитесь с нами в Телеграм',
+ },
+ ],
+ },
+ },
+ not_found: {
+ title: 'Не найдено',
+ description: 'Страница, которую вы искали, не найдена',
+ btn: 'На главную',
+ },
+ dialogs: {
+ share: {
+ title: 'Поделиться ссылкой',
+ description: 'Каждый, у кого есть эта ссылка, сможет присоединиться',
+ close: 'Отмена',
+ },
+ hotkeys: {
+ title: 'Хоткеи',
+ badges: 'Открыть поиск в рабочей области | Перейти к главной странице | Выйти из сервиса',
+ alert: 'Вы часто будете видеть { badge } ниже. Это обозначает cmd на Mac и ctrl на Windows и Linux.',
+ },
+ },
+ validations: {
+ email: 'Почта',
+ password: 'Пароль',
+ pin: 'Код подтверждения',
+ name: 'Название',
+ url: 'Удобная ссылка',
+ },
+ search: {
+ placeholder: 'Введите что-нибудь для поиска...',
+ empty: 'Ничего не найдено',
+ recently: 'Недавние',
+ links: 'Ссылки',
+ select: 'Выбрать',
+ open: 'Открыть',
+ close: 'Закрыть',
+ },
+ workspace: {
+ popover: {
+ members: '{n} участников | {n} участник | {n} участника',
+ section: 'Управление',
+ pay: 'Тарифы и оплата',
+ invite: 'Пригласить в проект',
+ team: 'Команда',
+ settings: 'Поменять информацию',
+ active: 'Активно',
+ archive: 'В архиве',
+ },
+ },
+ table: {
+ row_from_all: 'из',
+ row_selected: 'строк выбрано(а)',
+ rows_on_page: 'Строк на странице',
+ page: 'Страница',
+ empty: 'Таблица пуста',
+ },
+}
diff --git a/apps/client/src/shared/libs/i18n/locales/zh-CN.ts b/apps/client/src/shared/libs/i18n/locales/zh-CN.ts
new file mode 100644
index 00000000..f84c78d4
--- /dev/null
+++ b/apps/client/src/shared/libs/i18n/locales/zh-CN.ts
@@ -0,0 +1,502 @@
+export default {
+ sidebar: {
+ badge: '免费',
+ input: '搜索',
+ section: '工作区',
+ projects: '项目',
+ boards: '看板',
+ notes: '笔记',
+ templates: '模板',
+ analytics: '分析',
+ members: '成员',
+ settings: '设置',
+ integrations: '集成',
+ help: {
+ title: '帮助',
+ items: '支持开发者 | 键盘快捷键 | 加入我们',
+ },
+ plan: {
+ btn: '购买订阅',
+ title: '高级计划',
+ description: '剩余看板',
+ tooltip: '1/3 免费工作区',
+ },
+ soon: '敬请期待',
+ },
+ header: {
+ navigator: {
+ question: '快捷键',
+ messages: '消息',
+ },
+ user: {
+ welcome: '前往欢迎页面',
+ logout: '登出',
+ },
+ share: '分享',
+ },
+ boards: {
+ create: '添加看板 | 添加新看板',
+ filters: {
+ sort: {
+ title: '排序依据',
+ name: '名称',
+ tasks: '任务',
+ date: '创建日期',
+ default: '默认',
+ },
+ advanced: {
+ title: '高级筛选',
+ empty: '没有这样的筛选器 :/',
+ statuses: {
+ title: '状态',
+ arr: [
+ {
+ value: 'active',
+ label: '活跃',
+ },
+ {
+ value: 'archived',
+ label: '已归档',
+ },
+ ],
+ },
+ labels: {
+ title: '标签',
+ },
+ },
+ search: '搜索看板...',
+ },
+ forms: {
+ creating: {
+ title: '创建看板',
+ description: '您可以创建一个新的看板来管理任务',
+ name: {
+ label: '名称',
+ placeholder: '请输入看板的名称',
+ },
+ labels: {
+ label: '标签',
+ placeholder: '输入标签',
+ description: '开始输入标签并按 \'Enter\' 键,将其保存到现有标签中',
+ alert: '📝 如何更改标签颜色?指定标签名称后跟 [#所需颜色]。例如:jenda [#000000]。',
+ },
+ btns: '取消 | 创建',
+ },
+ },
+ empty: {
+ title: '没有看板',
+ description: '创建您的第一个看板并开始工作',
+ },
+ card: {
+ date_updated: '更新时间',
+ },
+ chart: {
+ title_boards: '创建的看板',
+ description_boards: '在 Jenda 上创建、分享、工作,然后分析你的活动',
+ title_tasks: '你的任务',
+ description_tasks: '解决并完成你开始的任务',
+ },
+ columns: ['看板名称', '状态', '标签', '参与者', '任务', '估算', '创建日期'],
+ },
+ picker: {
+ placeholder: '自定义颜色',
+ tabs: '纯色 | 渐变',
+ },
+ templates: {
+ title: '模板',
+ description: '选择一个可用模板来创建您的项目',
+ import: '点击或拖拽文件导入。支持文件',
+ community: '社区模板',
+ create: '创建模板',
+ user: '创建者',
+ items: [
+ {
+ title: '基础看板',
+ tag: '推荐',
+ description: '使用 "基础看板" 模板创建一个基本项目',
+ date: '2024年6月12日',
+ },
+ {
+ title: '路线图',
+ tag: '新',
+ description: '基于我们的 "路线图" 模板创建一个项目',
+ date: '2024年6月18日',
+ },
+ {
+ title: '网页开发',
+ tag: '新',
+ description: '使用 "网页开发" 模板创建一个基本项目',
+ date: '2024年7月16日',
+ },
+ ],
+ },
+ members: {
+ title: '工作区参与者',
+ description: '成员可以查看和加入工作区的白板,并在该空间中创建新的白板。',
+ content: {
+ all: {
+ title: '邀请用户',
+ description:
+ '任何拥有邀请链接的人都可以加入这个免费的工作区。链接可以在任何时候被禁用和重新创建。待处理的邀请计入10名参与者的限制。',
+ btn: '所有参与者',
+ },
+ guests: {
+ title: '访客',
+ description: '访客只能查看和编辑他们被添加的板。',
+ btn: '访客',
+ },
+ },
+ role: {
+ member: '成员',
+ admin: '管理员',
+ },
+ btns: {
+ leave: '离开',
+ view: '查看白板',
+ },
+ },
+ analytics: {
+ title: '工作区分析',
+ description: '查看并分析您在 Jenda 上的活动',
+ charts: {
+ users: {
+ name: '用户数量',
+ description: '工作区中的用户数量',
+ },
+ online: {
+ name: '工作区活动',
+ },
+ tasks: {
+ name: '任务数量',
+ dataset: {
+ names: ['未完成', '已完成'],
+ },
+ },
+ boards: {
+ name: '看板数量',
+ },
+ },
+ badges: ['方便', '信息丰富', '随时'],
+ share: {
+ btn: '分享',
+ description: '您可以轻松地截图并将其发送给任何人。',
+ },
+ },
+ settings: {
+ title: '设置',
+ description: '管理您的帐户设置和设定一些偏好。',
+ lang: {
+ label: '语言',
+ about: '这是将在仪表板中使用的语言。',
+ },
+ theme: {
+ label: '主题',
+ about: '选择仪表板的主题。',
+ variants: {
+ light: '浅色',
+ dark: '深色',
+ auto: '系统',
+ },
+ btn: '更新偏好设置',
+ },
+ },
+ sheet: {
+ title: '创建看板',
+ description: '新看板将允许你创建任务以进行解决。',
+ form: {
+ name: {
+ label: '看板名称',
+ placeholder: '例如 “Jenda”',
+ },
+ description: {
+ label: '看板描述',
+ placeholder: '例如 我们公司的开发看板',
+ },
+ submit: '创建看板',
+ },
+ },
+ authentication: {
+ login: {
+ title: '欢迎回来',
+ description: '请输入下面的信息以登录您的帐户',
+ btn: '用电子邮件登录',
+ proposal: '还没有帐户?',
+ route: '立即登录',
+ },
+ registration: {
+ title: '开始使用',
+ description: '请输入下面的信息以创建您的帐户',
+ btn: '继续',
+ proposal: '已有帐户?',
+ route: '立即登录',
+ },
+ confirm: {
+ title: '确认您的邮箱',
+ description: '请输入发送到您邮箱的验证码',
+ btn: '确认',
+ alert: '我们重视您的安全,您可以放心信任我们!',
+ proposal: '没有收到验证码?',
+ route: '重新发送',
+ },
+ workspace: {
+ creating: {
+ title: '创建工作区',
+ logo: {
+ label: '为您的工作区选择一个标志',
+ btn: '选择图片 | 删除',
+ description: '文件 *.png, *.jpeg,最大 10MB,尺寸至少为 400x400 像素',
+ },
+ alert: '请记住,如果您未选择标志,Jenda 将自动为您生成一个',
+ form: {
+ name: {
+ label: '名称',
+ placeholder: '输入您的工作区名称',
+ },
+ url: {
+ label: '自定义链接',
+ },
+ },
+ },
+ choosing: {
+ title: '选择工作空间',
+ description: '您的团队使用 Jenda 越积极,您的工作空间就会越高效。| Jenda 改变了在共享工作空间中的协作方式——从项目组织和任务管理到集中讨论和创建高效工作流程。',
+ label: '可用的工作空间',
+ },
+ route: '立即登录',
+ },
+ form: {
+ email: '电子邮件',
+ password: '密码',
+ otp: '您应输入一串单个数字的值。',
+ },
+ line: '或继续使用',
+ privacy: ['点击继续即表示您同意我们的', '点击登录即表示您同意我们的', '服务条款', '和', '隐私政策。'],
+ back: '回到首页',
+ auth_alert: '通过 google 的授权将在 2025 年 02 月 28 日后被 gitlab 替代',
+ },
+ kanban: {
+ sorting: {
+ all: '所有任务',
+ activity: '按活动排序',
+ workload: '按工作量排序',
+ },
+ active: '活跃',
+ archived: '已归档',
+ new: '添加新列',
+ cards: {
+ add: '添加卡片',
+ },
+ sheet: {
+ column: {
+ title: '添加新列',
+ description: '创建新列时,您可以创建卡片并使用看板。',
+ form: {
+ label: '列名称',
+ placeholder: '输入列名称',
+ submit: '创建列',
+ },
+ },
+ },
+ configuration: {
+ title: '配置您的看板',
+ description: '自定义您的看板以方便使用',
+ name: '项目的名称和颜色指示器"',
+ users: '參與者',
+ status: '选择您的项目状态',
+ clear: '清空看板',
+ update: '应用更改',
+ },
+ },
+ welcome: {
+ header: {
+ links: ['关于', '看板', '协作', '活动', '成员', '聊天'],
+ login: '登录',
+ reg: '注册',
+ },
+ about: {
+ tagline: '通往完美的道路',
+ description:
+ 'Jenda 是一款基于云的程序,用于高效协作和个人项目与任务管理。',
+ badge: '核心功能:任务板、实时跟踪',
+ badge_mobile: '核心功能:任务板、实时跟踪',
+ btn: '注册',
+ },
+ marketing: {
+ heading: '所有便利都在一个地方',
+ cards: [
+ {
+ title: '看板 🧑💻',
+ description:
+ '使用我们直观的看板来组织和可视化您的项目。轻松提升生产力。',
+ },
+ {
+ title: '协作 👥',
+ description: '提升你的团队合作!',
+ },
+ {
+ title: '成员 🌐',
+ description: '邀请新成员参与您的项目。一起享受开发的乐趣!',
+ },
+ {
+ title: '活动 👔',
+ description: '分析您的活动,迈步向前。',
+ },
+ {
+ title: '聊天 💬',
+ description: '交流、分享、讨论。我们为您提供便利 😉',
+ },
+ ],
+ },
+ marquee: [
+ '我们知道',
+ '你享受',
+ '👑 俄罗斯最好的替代品',
+ '开源',
+ '👻 我们更简单',
+ '无广告',
+ '完美就在眼前 🤟',
+ ],
+ kanban: {
+ name: '使用Kanban',
+ heading: '你知道我们的Kanban吗?👀',
+ about:
+ 'Kanban 是一种简单高效的项目、任务和时间管理方式。通过在看板上可视化您的任务,您可以轻松跟踪进度,了解接下来需要做什么。',
+ writer: '尝试使用我们的Kanban,尽情征服世界',
+ review: {
+ name: '亚历克斯·佩什科夫',
+ status: '前端开发者,开源爱好者',
+ comment: '我每天都在使用看板,强烈建议大家尝试。这真是太棒了!',
+ },
+ },
+ collaborative: {
+ name: '一起更轻松',
+ heading: '每个任务都是通向 🤝 共同成功的一步!',
+ about:
+ '我们的服务是为那些通过协作追求成功的团队而创建的。借助它,每个参与者都可以轻松跟踪任务,分享想法,并与同事共同朝着共同目标迈进。',
+ writer: '加入我们,齐心协力迈向成功!',
+ review: {
+ name: '巴高丁诺夫·艾拉特',
+ status: '后端开发人员',
+ comment: '合作是很棒的!Jenda提供了如此美好的机会。',
+ },
+ },
+ activity: {
+ heading: '进行中的进展',
+ about:
+ '您可以查看已完成任务和开放看板的统计信息。这使您能够跟踪团队的进展,查看已完成的任务,以及管理当前的任务和项目。',
+ },
+ members: {
+ name: '团队成员',
+ heading: '与友好的 😉 开发者团队一起工作',
+ about:
+ '我们的服务让您可以创建和管理团队,添加成员,分享任务并实时获取反馈。',
+ writer: '加入并成为焦点!',
+ review: {
+ name: '亚历山大·佩什科夫',
+ status: '前端开发者,开源爱好者',
+ comment:
+ '这是一个很棒的协作工具。界面方便,功能实用! 👍 和朋友们一起工作',
+ },
+ },
+ chats: {
+ name: '沟通的轻松',
+ heading: '您与团队沟通的频率有多高?🤔',
+ about: `我们的聊天功能让您可以轻松地交换消息、分享想法,与团队保持同步。保持联系,更高效地工作!`,
+ writer: '在我们便捷的聊天中分享您的想法和创意!',
+ review: {
+ name: '艾尔拉特·巴高特丁诺夫',
+ status: '后端开发者',
+ comment: '这里的聊天功能真的很方便!与团队轻松交换想法,快速解决问题。',
+ },
+ },
+ footer: '基于云的任务管理程序。源代码可在',
+ mobile: {
+ section_blocks: [
+ {
+ title: '关于我们',
+ description: '了解 Jenda',
+ },
+ {
+ title: '看板',
+ description: '使用看板管理项目',
+ },
+ {
+ title: '协作',
+ description: '提高你的团队合作!',
+ },
+ {
+ title: '成员',
+ description: '邀请成员加入项目',
+ },
+ {
+ title: '活动',
+ description: '分析您的活动',
+ },
+ {
+ title: '聊天',
+ description: '沟通、分享、讨论',
+ },
+ ],
+ links: [
+ {
+ title: '产品',
+ },
+ {
+ title: '通过 Telegram 联系我们',
+ },
+ ],
+ },
+ },
+ not_found: {
+ title: '未找到',
+ description: '找不到您要查找的页面',
+ btn: '返回首页',
+ },
+ dialogs: {
+ share: {
+ title: '分享链接',
+ description: '任何拥有此链接的人都可以加入',
+ close: '取消',
+ },
+ hotkeys: {
+ title: '快捷键',
+ badges: '打开工作区搜索 | 转到主页 | 退出服务',
+ alert: '你会在下面频繁看到 { badge }。它表示 Mac 上的 Command 键,Windows 和 Linux 上的 Control 键。',
+ },
+ },
+ validations: {
+ email: '邮箱',
+ password: '密码',
+ pin: '确认码',
+ name: '名称',
+ url: '自定义链接',
+ },
+ search: {
+ placeholder: '输入内容进行搜索...',
+ empty: '未找到结果',
+ recently: '最近',
+ links: '链接',
+ select: '选择',
+ open: '打开',
+ close: '关闭',
+ },
+ workspace: {
+ popover: {
+ members: '{n} 成员',
+ section: '管理',
+ pay: '套餐与付款',
+ invite: '邀请加入项目',
+ team: '团队',
+ settings: '更改信息',
+ active: '活跃',
+ archive: '已归档',
+ },
+ },
+ table: {
+ row_from_all: '从',
+ row_selected: '行已选择',
+ rows_on_page: '每页行数',
+ page: '页面',
+ empty: '表格为空',
+ },
+}
diff --git a/apps/client/src/shared/libs/i18n/plurals.ts b/apps/client/src/shared/libs/i18n/plurals.ts
new file mode 100644
index 00000000..552febad
--- /dev/null
+++ b/apps/client/src/shared/libs/i18n/plurals.ts
@@ -0,0 +1,14 @@
+export function customPluralRule(choice: T, choicesLength: T): number {
+ if (choice === 0) {
+ return 0
+ }
+ const teen = choice > 10 && choice < 20
+ const endsWithOne = choice % 10 === 1
+ if (!teen && endsWithOne) {
+ return 1
+ }
+ if (!teen && choice % 10 >= 2 && choice % 10 <= 4) {
+ return 2
+ }
+ return choicesLength < 4 ? 2 : 3
+}
diff --git a/apps/client/src/shared/libs/shadcn/utils.ts b/apps/client/src/shared/libs/shadcn/utils.ts
new file mode 100644
index 00000000..a83d31e9
--- /dev/null
+++ b/apps/client/src/shared/libs/shadcn/utils.ts
@@ -0,0 +1,15 @@
+import { clsx } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+import type { ClassValue } from 'clsx'
+import type { Updater } from '@tanstack/vue-table'
+import type { Ref } from 'vue'
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export function valueUpdater>(updaterOrValue: T, ref: Ref): void {
+ ref.value = typeof updaterOrValue === 'function'
+ ? updaterOrValue(ref.value)
+ : updaterOrValue
+}
diff --git a/apps/client/src/shared/libs/unocss/presets/presetUiKit.ts b/apps/client/src/shared/libs/unocss/presets/presetUiKit.ts
new file mode 100644
index 00000000..00066db0
--- /dev/null
+++ b/apps/client/src/shared/libs/unocss/presets/presetUiKit.ts
@@ -0,0 +1,9 @@
+import { shortcuts } from '../../../ui/shortcuts'
+import type { Preset } from 'unocss'
+
+export default function presetJendaUI(): Preset {
+ return {
+ name: '@jenda-ui/preset',
+ shortcuts,
+ }
+}
diff --git a/apps/client/src/shared/libs/vee-validate/index.ts b/apps/client/src/shared/libs/vee-validate/index.ts
new file mode 100644
index 00000000..78965702
--- /dev/null
+++ b/apps/client/src/shared/libs/vee-validate/index.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod'
+import i18n, { type MessageSchema } from '../i18n'
+
+import { makeZodI18nMap } from './map'
+import type { I18n } from 'vue-i18n'
+
+z.setErrorMap(makeZodI18nMap(
+ i18n as unknown as
+ I18n>,
+))
+
+export { z }
diff --git a/apps/client/src/shared/libs/vee-validate/map.ts b/apps/client/src/shared/libs/vee-validate/map.ts
new file mode 100644
index 00000000..9f7bc7c1
--- /dev/null
+++ b/apps/client/src/shared/libs/vee-validate/map.ts
@@ -0,0 +1,201 @@
+import {
+ defaultErrorMap,
+ type ErrorMapCtx,
+ z,
+ type ZodErrorMap,
+ ZodIssueCode,
+ type ZodIssueOptionalMessage,
+ ZodParsedType,
+} from 'zod'
+import { joinValues, jsonStringifyReplacer } from './utils'
+import type {
+ ComposerDateTimeFormatting,
+ ComposerTranslation,
+ I18n,
+} from 'vue-i18n'
+
+const zDate = z.string().regex(/(\d{4})-\d{2}-(\d{2})/)
+
+interface i18nOptions {
+ [key: string]: unknown
+}
+
+function makeZodI18nMap(i18n: I18n, key = 'errors'): ZodErrorMap {
+ return (issue: ZodIssueOptionalMessage, ctx: ErrorMapCtx): { message: string } => {
+ let message: string
+ message = defaultErrorMap(issue, ctx).message
+
+ let options = {} as i18nOptions
+
+ const t = i18n.global.t as ComposerTranslation
+ const te = i18n.global.te
+ const d = i18n.global.d as ComposerDateTimeFormatting
+
+ const translateLabel = (message: string, options: i18nOptions) => {
+ if (te(`${key}.${message}WithPath`)) {
+ return t(`${key}.${message}WithPath`, options)
+ }
+ if (te(`${key}.${message}`)) {
+ return t(`${key}.${message}`, options)
+ }
+ if (te(message)) {
+ return t(message, options)
+ }
+ return message
+ }
+
+ switch (issue.code) {
+ case ZodIssueCode.invalid_type:
+ if (issue.received === ZodParsedType.undefined) {
+ message = 'invalidTypeReceivedUndefined'
+ options = {
+ validation: te(`validations.${issue.path.join('.')}`)
+ ? t(`validations.${issue.path.join('.')}`)
+ : issue.path.join('.'),
+ }
+ }
+ else {
+ message = 'invalidType'
+ options = {
+ expected: te(`types.${issue.expected}`)
+ ? t(`types.${issue.expected}`)
+ : issue.expected,
+ received: te(`types.${issue.received}`)
+ ? t(`types.${issue.received}`)
+ : issue.received,
+ }
+ }
+ break
+ case ZodIssueCode.invalid_literal:
+ message = 'invalidLiteral'
+ options = {
+ expected: JSON.stringify(
+ issue.expected,
+ jsonStringifyReplacer,
+ ),
+ }
+ break
+ case ZodIssueCode.unrecognized_keys:
+ message = 'unrecognizedKeys'
+ options = {
+ keys: joinValues(issue.keys, ', '),
+ }
+ break
+ case ZodIssueCode.invalid_union:
+ message = 'invalidUnion'
+ break
+ case ZodIssueCode.invalid_union_discriminator:
+ message = 'invalidUnionDiscriminator'
+ options = {
+ options: joinValues(issue.options),
+ }
+ break
+ case ZodIssueCode.invalid_enum_value:
+ message = 'invalidEnumValue'
+ options = {
+ options: joinValues(issue.options),
+ received: issue.received,
+ }
+ break
+ case ZodIssueCode.invalid_arguments:
+ message = 'invalidArguments'
+ break
+ case ZodIssueCode.invalid_return_type:
+ message = 'invalidReturnType'
+ break
+ case ZodIssueCode.invalid_date:
+ message = 'invalidDate'
+ break
+
+ case ZodIssueCode.invalid_string:
+ if (typeof issue.validation === 'object') {
+ if ('startsWith' in issue.validation) {
+ message = `invalidString.startsWith`
+ options = {
+ startsWith: issue.validation.startsWith,
+ }
+ }
+ else if ('endsWith' in issue.validation) {
+ message = `invalidString.endsWith`
+ options = {
+ endsWith: issue.validation.endsWith,
+ }
+ }
+ }
+ else {
+ message = `invalidString.${issue.validation}`
+ options = {
+ validation: t(`validations.${issue.validation}`),
+ }
+ }
+ break
+ case ZodIssueCode.too_small:
+ message = `tooSmall.${issue.type}.${
+ issue.exact
+ ? 'exact'
+ : issue.inclusive
+ ? 'inclusive'
+ : 'notInclusive'
+ }`
+ options = {
+ minimum:
+ issue.type === 'date'
+ ? d(new Date(issue.minimum as string | number))
+ : issue.minimum,
+ }
+
+ break
+ case ZodIssueCode.too_big:
+ message = `tooBig.${issue.type}.${
+ issue.exact
+ ? 'exact'
+ : issue.inclusive
+ ? 'inclusive'
+ : 'notInclusive'
+ }`
+ options = {
+ maximum:
+ issue.type === 'date'
+ ? d(new Date(issue.maximum as string | number))
+ : issue.maximum,
+ }
+ break
+ case ZodIssueCode.custom:
+ message = 'custom'
+ if (issue.params?.i18n) {
+ if (typeof issue.params.i18n === 'string') {
+ message = issue.params.i18n
+ break
+ }
+ if (
+ typeof issue.params.i18n === 'object'
+ && issue.params.i18n?.key
+ ) {
+ message = issue.params.i18n.key
+ if (issue.params.i18n?.options) {
+ options = issue.params.i18n.options
+ }
+ }
+ }
+ break
+ case ZodIssueCode.invalid_intersection_types:
+ message = 'invalidIntersectionTypes'
+ break
+ case ZodIssueCode.not_multiple_of:
+ message = 'notMultipleOf'
+ options = {
+ multipleOf: issue.multipleOf,
+ }
+ break
+ case ZodIssueCode.not_finite:
+ message = 'notFinite'
+ break
+ }
+ options.path = issue.path.join('.') || ''
+ message = translateLabel(message, options)
+
+ return { message }
+ }
+}
+
+export { makeZodI18nMap, zDate }
diff --git a/apps/client/src/shared/libs/vee-validate/rules/en.ts b/apps/client/src/shared/libs/vee-validate/rules/en.ts
new file mode 100644
index 00000000..3244d4a9
--- /dev/null
+++ b/apps/client/src/shared/libs/vee-validate/rules/en.ts
@@ -0,0 +1,23 @@
+export default {
+ invalidTypeReceivedUndefined: '{validation} is a required field',
+ invalidString: {
+ email: '{validation} must be a valid',
+ url: '{validation} must be a valid',
+ },
+ tooSmall: {
+ string: {
+ exact: '{validation} must be at least {minimum} characters',
+ inclusive: '{path} must be at least {minimum} characters',
+ },
+ array: {
+ exact: '{path} must be at least {minimum} characters',
+ inclusive: '{validation} must be at least {minimum} characters',
+ },
+ },
+ tooBig: {
+ string: {
+ exact: '{path} must be at most {maximum} characters',
+ inclusive: '{path} must be at most {maximum} characters',
+ },
+ },
+}
diff --git a/apps/client/src/shared/libs/vee-validate/rules/index.ts b/apps/client/src/shared/libs/vee-validate/rules/index.ts
new file mode 100644
index 00000000..6bc12aef
--- /dev/null
+++ b/apps/client/src/shared/libs/vee-validate/rules/index.ts
@@ -0,0 +1,3 @@
+export { default as enZod } from './en'
+export { default as ruZod } from './ru'
+export { default as zhZod } from './zh'
diff --git a/apps/client/src/shared/libs/vee-validate/rules/ru.ts b/apps/client/src/shared/libs/vee-validate/rules/ru.ts
new file mode 100644
index 00000000..d05f3986
--- /dev/null
+++ b/apps/client/src/shared/libs/vee-validate/rules/ru.ts
@@ -0,0 +1,23 @@
+export default {
+ invalidTypeReceivedUndefined: 'Обязательное поле',
+ invalidString: {
+ email: '{validation} должна быть валидной',
+ url: '{validation} должен быть валидным',
+ },
+ tooSmall: {
+ string: {
+ exact: 'длина должна быть не менее {minimum} символов',
+ inclusive: 'длина должна быть не менее {minimum} символов',
+ },
+ array: {
+ exact: 'длина должна быть не менее {minimum} символов',
+ inclusive: 'длина должна быть не менее {minimum} символов',
+ },
+ },
+ tooBig: {
+ string: {
+ exact: 'длина должна быть не более {maximum} символов',
+ inclusive: 'длина должна быть не более {maximum} символов',
+ },
+ },
+}
diff --git a/apps/client/src/shared/libs/vee-validate/rules/zh.ts b/apps/client/src/shared/libs/vee-validate/rules/zh.ts
new file mode 100644
index 00000000..323afbb1
--- /dev/null
+++ b/apps/client/src/shared/libs/vee-validate/rules/zh.ts
@@ -0,0 +1,23 @@
+export default {
+ invalidTypeReceivedUndefined: '此字段为必填项',
+ invalidString: {
+ email: '{validation} 必须是有效的',
+ url: '{validation} 必须是有效的',
+ },
+ tooSmall: {
+ string: {
+ exact: '长度必须至少为 {minimum} 个字符',
+ inclusive: '长度必须至少为 {minimum} 个字符',
+ },
+ array: {
+ exact: '长度必须至少为 {minimum} 个字符',
+ inclusive: '长度必须至少为 {minimum} 个字符',
+ },
+ },
+ tooBig: {
+ string: {
+ exact: '长度不得超过 {maximum} 个字符',
+ inclusive: '长度不得超过 {maximum} 个字符',
+ },
+ },
+}
diff --git a/apps/client/src/shared/libs/vee-validate/utils/index.ts b/apps/client/src/shared/libs/vee-validate/utils/index.ts
new file mode 100644
index 00000000..95c0fcc8
--- /dev/null
+++ b/apps/client/src/shared/libs/vee-validate/utils/index.ts
@@ -0,0 +1,15 @@
+export function jsonStringifyReplacer(_: string, value: unknown): unknown {
+ if (typeof value === 'bigint') {
+ return value.toString()
+ }
+ return value
+}
+
+export function joinValues(
+ array: T,
+ separator = ' | ',
+): string {
+ return array
+ .map(val => (typeof val === 'string' ? `'${val}'` : val))
+ .join(separator)
+}
diff --git a/apps/client/src/shared/libs/vitest-utils/cookiesI18n-mock.ts b/apps/client/src/shared/libs/vitest-utils/cookiesI18n-mock.ts
new file mode 100644
index 00000000..7965a653
--- /dev/null
+++ b/apps/client/src/shared/libs/vitest-utils/cookiesI18n-mock.ts
@@ -0,0 +1,11 @@
+import { vi } from 'vitest'
+
+vi.mock('@vueuse/integrations/useCookies', () => {
+ return {
+ useCookies: () => ({
+ get(key: string) {
+ return key === 'i18n' ? 'en-US' : undefined
+ },
+ }),
+ }
+})
diff --git a/apps/client/src/shared/ui/__docs__/!misc.mdx b/apps/client/src/shared/ui/__docs__/!misc.mdx
new file mode 100644
index 00000000..c35c542d
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/!misc.mdx
@@ -0,0 +1,18 @@
+misc.mdx
+
+import { Markdown } from '@storybook/blocks';
+
+# Miscellaneous Components
+
+This document describes components that are not included in the main documentation but may still be useful in specific contexts. These components are context-dependent and have been thoroughly tested in their respective `.spec.ts` files.
+
+
+ {`
+| Component | Description | Notes |
+|-------------|---------------------------------------------------------------------------------------------------|--------------------------------------------|
+| UiPagination | A pagination component designed to manage and display paginated data dynamically. | Context-dependent, tested in .spec.ts. |
+| DataTable and UiTable | A table component for displaying structured data with customizable columns and rows. | Context-dependent, tested in .spec.ts. |
+ `}
+
+
+> **Note:** These components are excluded from the primary documentation because their usage scenarios are tightly coupled with specific contexts. Ensure to evaluate their fit for your use case before implementation.
diff --git a/apps/client/src/shared/ui/__docs__/Alert.mdx b/apps/client/src/shared/ui/__docs__/Alert.mdx
new file mode 100644
index 00000000..c6a97874
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Alert.mdx
@@ -0,0 +1,39 @@
+{/* Alert.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as AlertStories from '../alert/UiAlert.stories';
+
+
+
+# UiAlert
+
+The **UiAlert** component is a flexible alert element that can adapt to various themes and styles, offering customizability for different UI requirements.
+
+
+
+### Props
+
+
+ {`
+| Prop | Description |
+|------------------|-------------------------------------------------------------------------------------------------------------------------------|
+| variant | Defines the alert's style. Options are: __default__, __secondary__, __ghost__, __destructive__, __outline__, and __dashed__. |
+| closable | Determines if the alert displays a close button. |
+| content | Sets the main text or content for the alert. |
+| class | Use for customize alert with unocss by type of HTMLAttributes['class'] |
+`}
+
+
+
+
+### Slots
+
+
+ {`
+| Slot | Description |
+|------------|----------------------------------------------------------------------------------------------------------- |
+| default | Main content slot for the alert's message or other components. |
+`}
+
+
+
diff --git a/apps/client/src/shared/ui/__docs__/Badge.mdx b/apps/client/src/shared/ui/__docs__/Badge.mdx
new file mode 100644
index 00000000..eb81b170
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Badge.mdx
@@ -0,0 +1,25 @@
+{/* Badge.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as BadgeStories from '../badge/UiBadge.stories';
+
+
+
+# UiBadge
+
+The **UiBadge** component is a flexible badge that supports various styles, making it suitable for different UI contexts.
+
+
+
+### Props
+
+
+ {`
+| Prop | Description |
+|----------|---------------------------------------------------------------------------------------------------------- |
+| variant | Defines the badge style. Options are: __default__, __secondary__, __outline__, __solid__, __colorized(success and destructive)__.|
+| class | Use for customize badge with unocss by type of HTMLAttributes['class'] |
+`}
+
+
+
diff --git a/apps/client/src/shared/ui/__docs__/Button.mdx b/apps/client/src/shared/ui/__docs__/Button.mdx
new file mode 100644
index 00000000..cbcd3dd0
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Button.mdx
@@ -0,0 +1,45 @@
+{/* Button.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as ButtonStories from '../button/UiButton.stories';
+
+
+
+# UiButton
+
+The **UiButton** component is a versatile button that supports various sizes, themes, and icon placements, making it suitable for different UI use cases.
+
+
+
+### Props
+
+
+ {`
+| Prop | Description |
+|------------------|------------------------------------------------------------------------------------------------------------------------------- |
+| variant | Defines the button's style. Options are: __default__, __secondary__, __ghost__, __destructive__, __outline__, __dashed__, __solid__, __success__ |
+| size | Controls the button's size. Options are: __sm__ (small), __default__ (medium, default), __xs__(xs) and __lg__ (large). |
+| loading | Shows a loading spinner when true, indicating a loading state. |
+| disabled | Disables the button when true, setting it to an inactive state and reducing opacity to 0.6. |
+| loadingPlacement | Defines where the loading spinner appears when __loading__ is true. Options are __leading__ (before content) and __trailing__. |
+| class | Use for customize button with unocss by type of HTMLAttributes['class'] |
+`}
+
+
+
+
+### Slots
+
+
+ {`
+| Slot | Description |
+|------------|----------------------------------------------------------------------------------------------------------- |
+| default | Main content slot for the button's label or other components. |
+| leading | Slot for content to appear at the start of the button, such as an icon or loading spinner. |
+| trailing | Slot for content to appear at the end of the button, like an icon or loading spinner. |
+| loading | Slot for a custom loading spinner or icon to be displayed when the button is in a loading state. |
+`}
+
+
+
+
diff --git a/apps/client/src/shared/ui/__docs__/Checkbox.mdx b/apps/client/src/shared/ui/__docs__/Checkbox.mdx
new file mode 100644
index 00000000..71d4e013
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Checkbox.mdx
@@ -0,0 +1,28 @@
+{/* Checkbox.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as CheckboxStories from '../checkbox/UiCheckbox.stories';
+
+
+
+# UiCheckbox
+
+The **UiCheckbox** component is a control that allows the user to toggle between checked and not checked.
+
+
+
+### Props
+
+
+ {`
+| Prop | Description |
+|-------------|-----------------------------------------------------------------------------------------------------------------|
+| checked | The checkbox field value. **Use __v-model__ to bind this prop to a data property.** |
+| defaultValue| The default value |
+| class | Use for customize checkbox with unocss by type of HTMLAttributes['class'] |
+ `}
+
+
+### Another Props
+
+refs: https://www.radix-vue.com/components/checkbox
diff --git a/apps/client/src/shared/ui/__docs__/Command.mdx b/apps/client/src/shared/ui/__docs__/Command.mdx
new file mode 100644
index 00000000..970646ff
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Command.mdx
@@ -0,0 +1,20 @@
+{/* Command.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as CommandStories from '../command/UiCommand.stories';
+
+
+
+# UiCommand
+
+Fast, composable, unstyled command menu.
+
+### Main Components:
+
+`UiCommand`, `UiCommandItem`, `UiCommandList`, `UiCommandInput`,
+
+
+
+### Props
+
+refs: https://www.radix-vue.com/components/combobox
diff --git a/apps/client/src/shared/ui/__docs__/Dialog.mdx b/apps/client/src/shared/ui/__docs__/Dialog.mdx
new file mode 100644
index 00000000..0bd3698b
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Dialog.mdx
@@ -0,0 +1,20 @@
+{/* Dialog.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as DialogStories from '../dialog/UiDialog.stories';
+
+
+
+# UiDialog
+
+A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.
+
+### Main Components:
+
+`UiDialog`, `UiDialogTrigger`, `UiDialogContent`
+
+
+
+### Props
+
+refs: https://www.radix-vue.com/components/dialog
diff --git a/apps/client/src/shared/ui/__docs__/DropdownMenu.mdx b/apps/client/src/shared/ui/__docs__/DropdownMenu.mdx
new file mode 100644
index 00000000..9f524bae
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/DropdownMenu.mdx
@@ -0,0 +1,20 @@
+{/* Dropdown.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as DropdownStories from '../dropdown-menu/UiDropdownMenu.stories';
+
+
+
+# UiDropdown
+
+The **UiDropdown** component displays a menu to the user — such as a set of actions or functions — triggered by a button.
+
+### Main Components:
+
+`UiDropdown`, `UiDropdownTrigger`, `UiDropdownContent`, `UiDropdownItem`,
+
+
+
+### Props
+
+refs: https://www.radix-vue.com/components/dropdown-menu
diff --git a/apps/client/src/shared/ui/__docs__/Form.mdx b/apps/client/src/shared/ui/__docs__/Form.mdx
new file mode 100644
index 00000000..df430b04
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Form.mdx
@@ -0,0 +1,16 @@
+{/* Form.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as FormStories from '../form/UiForm.stories';
+
+
+
+# UiForm
+
+Form component with with possible integration formkit and vee-validate.
+
+
+
+### Warning
+
+
diff --git a/apps/client/src/shared/ui/__docs__/GettingStarted.mdx b/apps/client/src/shared/ui/__docs__/GettingStarted.mdx
new file mode 100644
index 00000000..e100d6a3
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/GettingStarted.mdx
@@ -0,0 +1,39 @@
+import { Meta } from '@storybook/blocks'
+
+
+
+# Get started 🖖
+
+**Jenda UI Kit** is a custom, reusable component library designed to help developers create user interfaces faster with a cohesive design language. Created as part of the Jenda project, this UI kit provides pre-built, customizable Vue components tailored for seamless integration and intuitive use.
+
+### **Usage**
+
+Import components you want into your UI
+
+`import { UiButton, UiBadge, UiInput } from '@shared/ui';`
+
+and use them like so
+
+```
+
+
+
+ hello from ui
+
+```
+
+### **Run and develop UI Kit locally**
+
+Clone the [Jenda repository](https://github.com/mnenie/jenda) then start Storybook.
+
+`pnpm i && pnpm run storybook`
+
+__if you have an errors in console with `iframe` just reload page__
+
+
+### **Theme Support**
+
+Jenda UI Kit components support multiple themes: light, dark, and system (auto-switches based on user’s OS preferences). This allows for a more personalized and accessible UI experience across devices and environments.
+
diff --git a/apps/client/src/shared/ui/__docs__/Input.mdx b/apps/client/src/shared/ui/__docs__/Input.mdx
new file mode 100644
index 00000000..e1c72742
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Input.mdx
@@ -0,0 +1,24 @@
+{/* Input.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as InputStories from '../input/UiInput.stories';
+
+
+
+# UiInput
+
+The **UiInput** component is a versatile input field that supports various types (text, password, number, email) and can be easily integrated with `v-model` for value binding.
+
+
+
+### Props
+
+
+ {`
+| Prop | Description |
+|-------------|-----------------------------------------------------------------------------------------------------------------|
+| modelValue | The input field value. **Use __v-model__ to bind this prop to a data property.** |
+| defaultValue| The default value |
+| class | Use for customize input with unocss by type of HTMLAttributes['class'] |
+ `}
+
diff --git a/apps/client/src/shared/ui/__docs__/PinInput.mdx b/apps/client/src/shared/ui/__docs__/PinInput.mdx
new file mode 100644
index 00000000..e4d578a0
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/PinInput.mdx
@@ -0,0 +1,26 @@
+{/* PinInput.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as PinInputStories from '../pin-input/UiPinInput.stories';
+
+
+
+# UiPinInput
+
+Allows users to input a sequence of one-character alphanumeric inputs.
+
+### Main Components:
+
+`UiPinInput`, `UiPinInputInput`, `UiPinInputGroup`
+
+
+
+### With Separator
+
+`UiPinInput`, `UiPinInputInput`, `UiPinInputGroup`, `UiPinInputSeparator`
+
+
+
+### Props
+
+refs: https://www.radix-vue.com/components/pin-input
diff --git a/apps/client/src/shared/ui/__docs__/Popover.mdx b/apps/client/src/shared/ui/__docs__/Popover.mdx
new file mode 100644
index 00000000..4f484d65
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Popover.mdx
@@ -0,0 +1,20 @@
+{/* Popover.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as PopoverStories from '../popover/UiPopover.stories';
+
+
+
+# UiPopover
+
+The **UiPopover** component displays a content in a portal to the user — such as a set of actions or functions — triggered by a button.
+
+### Components:
+
+`UiPopover`, `UiPopoverTrigger`, `UiPopoverContent`
+
+
+
+### Props
+
+refs: https://www.radix-vue.com/components/popover
diff --git a/apps/client/src/shared/ui/__docs__/Select.mdx b/apps/client/src/shared/ui/__docs__/Select.mdx
new file mode 100644
index 00000000..c68b548e
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Select.mdx
@@ -0,0 +1,20 @@
+{/* Select.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as SelectStories from '../select/UiSelect.stories';
+
+
+
+# UiSelect
+
+The **UiSelect** component displays a list of options for the user to pick from—triggered by a button.
+
+### Main Components:
+
+`UiSelect`, `UiSelectTrigger`, `UiSelectContent`, `UiSelectItem`,
+
+
+
+### Props
+
+refs: https://www.radix-vue.com/components/select
diff --git a/apps/client/src/shared/ui/__docs__/Tabs.mdx b/apps/client/src/shared/ui/__docs__/Tabs.mdx
new file mode 100644
index 00000000..42d5ace7
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/Tabs.mdx
@@ -0,0 +1,20 @@
+{/* Tabs.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as TabsStories from '../tabs/UiTabs.stories';
+
+
+
+# UiTabs
+
+The **UiTabs** component set of layered sections of content—known as tab panels—that are displayed one at a time.
+
+### Main Components:
+
+`UiTabs`, `UiTabsTrigger`, `UiTabsContent`, `UiTabsList`,
+
+
+
+### Props
+
+refs: https://www.radix-vue.com/components/tabs
diff --git a/apps/client/src/shared/ui/__docs__/TagsInput.mdx b/apps/client/src/shared/ui/__docs__/TagsInput.mdx
new file mode 100644
index 00000000..701b4228
--- /dev/null
+++ b/apps/client/src/shared/ui/__docs__/TagsInput.mdx
@@ -0,0 +1,24 @@
+{/* TagsInput.mdx */}
+
+import { Canvas, Story, Meta, Markdown } from '@storybook/blocks';
+import * as TagsInputStories from '../tags-input/UiTagsInput.stories';
+
+
+
+# UiTagsInput
+
+Render tags inside an input, followed by an actual text input.
+
+### Main Components:
+
+`UiTagsInput`, `UiTagsInputInput`, `UiTagsInputItem`
+
+
+
+### Disabled
+
+
+
+### Props
+
+refs: https://www.radix-vue.com/components/tags-input
diff --git a/apps/client/src/shared/ui/_shortcuts/alert.ts b/apps/client/src/shared/ui/_shortcuts/alert.ts
new file mode 100644
index 00000000..d93e00be
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/alert.ts
@@ -0,0 +1,20 @@
+type AlertPrefix = 'alert'
+
+export const staticAlert: Record<`${AlertPrefix}-${string}` | AlertPrefix, string> = {
+ 'alert': 'relative flex items-center justify-center border border-solid gap-2.5 py-1.25 pl-2.5 rounded-8px mb-5 min-h-22px',
+ 'alert-close': 'absolute top-1/2 transform -translate-y-1/2 right-2.5 text-sm text-neutral-700 cursor-pointer dark:text-neutral-200',
+
+ 'alert-default': 'bg-neutral-50 border-neutral-200 text-neutral-800 dark:(bg-neutral-800 text-neutral-200 border-neutral-700)',
+ 'alert-success': 'bg-green-50 border-green-200 text-neutral-950 dark:(bg-#1b4731 text-neutral-200 border-green-800)',
+ 'alert-warning': 'bg-yellow-50 border-#f8d040 text-neutral-950 dark:(bg-#3d351a text-neutral-200 border-#9f8745)',
+ 'alert-important': 'bg-red-50 border-red-600 text-neutral-950 dark:(bg-#692525 text-neutral-200 border-#c73939)',
+}
+
+export const dynamicAlert: [RegExp, (params: RegExpExecArray) => string][] = [
+ [/^alert-(solid|outline)-(.*)$/, ([, c, v]) => `alert-${c}-${v}`],
+]
+
+export const alert = [
+ ...dynamicAlert,
+ staticAlert,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/badge.ts b/apps/client/src/shared/ui/_shortcuts/badge.ts
new file mode 100644
index 00000000..e3eef4f2
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/badge.ts
@@ -0,0 +1,20 @@
+type BadgePrefix = 'badge'
+
+export const staticBadge: Record<`${BadgePrefix}-${string}` | BadgePrefix, string> = {
+ 'badge': 'inline-flex items-center rounded-md border border-solid border-neutral-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:(outline-none ring-2 ring-neutral-950 ring-offset-2) dark:(border-neutral-700 focus:ring-neutral-300)',
+
+ 'badge-default': 'border-transparent bg-neutral-900 text-neutral-50 shadow dark:(bg-neutral-50 text-neutral-900)',
+ 'badge-secondary': 'border-transparent bg-neutral-100 text-neutral-900 dark:(bg-neutral-800 text-neutral-50)',
+ 'badge-outline': 'text-neutral-950 dark:text-neutral-50',
+ 'badge-solid': '!border-transparent bg-#266df0 text-neutral-50 shadow',
+}
+
+export const dynamicBadge: [RegExp, (params: RegExpExecArray) => string][] = [
+ [/^badge-colorized-(.*)$/, ([, c]) => `border-transparent bg-${c}-500 text-neutral-50 shadow dark:(bg-${c}-900 text-neutral-50 border-transparent)`],
+ [/^badge-soft(?:-(.+))?$/, ([, c = 'blue']) => `!border-transparent bg-${c}-100 text-${c}-700 dark:bg-${c}-900 dark:text-${c}-100`],
+]
+
+export const badge = [
+ ...dynamicBadge,
+ staticBadge,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/button.ts b/apps/client/src/shared/ui/_shortcuts/button.ts
new file mode 100644
index 00000000..9aeaa643
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/button.ts
@@ -0,0 +1,21 @@
+type ButtonPrefix = 'btn'
+
+export const staticBtn: Record<`${ButtonPrefix}-${string}` | ButtonPrefix, string> = {
+ 'btn': 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:(outline-none ring-1 ring-neutral-950) disabled:(pointer-events-none opacity-50) [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-neutral-300',
+
+ 'btn-default': 'bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/90 dark:(bg-neutral-50 text-neutral-900 hover:bg-neutral-50/90)',
+ 'btn-secondary': 'bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 dark:(bg-#2e2e2e text-neutral-50 hover:bg-#2e2e2e/80)',
+ 'btn-outline': 'border border-solid border-neutral-200 bg-white shadow-sm hover:(bg-neutral-50 text-neutral-900) dark:(border-neutral-700 bg-neutral-800 hover:bg-neutral-700/20 hover:text-neutral-50)',
+ 'btn-ghost': 'bg-transparent hover:(bg-neutral-100 text-neutral-900) dark:(hover:bg-neutral-700/40 hover:text-neutral-50)',
+ 'btn-dashed': 'border border-dashed border-neutral-200 bg-white shadow-sm hover:(bg-neutral-50 text-neutral-900) dark:(border-neutral-700 bg-neutral-800 hover:bg-neutral-700/20 hover:text-neutral-50)',
+}
+
+export const dynamicBtn: [RegExp, (params: RegExpExecArray) => string][] = [
+ [/^btn-solid(?:-(.+))?$/, ([, c = '#266df0']) => `bg-${c} text-neutral-50 shadow focus:ring-0 hover:bg-${c}/90`],
+ [/^btn-colorized-(.*)$/, ([, c]) => `bg-${c}-500 text-neutral-50 shadow-sm hover:bg-${c}-500/90 dark:(bg-${c}-900 text-neutral-50 hover:bg-${c}-900/90)`],
+]
+
+export const btn = [
+ ...dynamicBtn,
+ staticBtn,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/checkbox.ts b/apps/client/src/shared/ui/_shortcuts/checkbox.ts
new file mode 100644
index 00000000..a3cc82e2
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/checkbox.ts
@@ -0,0 +1,19 @@
+type CheckboxPrefix = 'checkbox'
+
+export const staticCheckbox: Record<`${CheckboxPrefix}-${string}` | CheckboxPrefix, string> = {
+ 'checkbox': 'w-4 h-4 shrink-0 rounded-md bg-transparent border disabled:cursor-not-allowed disabled:opacity-50',
+ 'checkbox-indicator': 'flex h-full w-full items-center justify-center',
+ 'checkbox-indecator-icon': 'w-4 h-4',
+
+ 'checkbox-default': 'border-neutral-950 data-[state=checked]:bg-neutral-950 data-[state=checked]:text-neutral-50 dark:border-neutral-100 dark:(focus-visible:ring-neutral-100 data-[state=checked]:bg-neutral-100 data-[state=checked]:text-neutral-900)',
+
+}
+
+export const dynamicCheckbox: [RegExp, (params: RegExpExecArray) => string][] = [
+ [/^checkbox-solid(?:-(.+))?$/, ([, c = 'blue']) => `border-neutral-200 data-[state=checked]:!bg-${c}-500 data-[state=checked]:!text-neutral-50 dark:border-neutral-600`],
+]
+
+export const checkbox = [
+ staticCheckbox,
+ ...dynamicCheckbox,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/command.ts b/apps/client/src/shared/ui/_shortcuts/command.ts
new file mode 100644
index 00000000..8be2b5f6
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/command.ts
@@ -0,0 +1,18 @@
+type CommandPrefix = 'command'
+
+export const staticCommand: Record<`${CommandPrefix}-${string}` | CommandPrefix, string> = {
+ 'command': 'flex h-full w-full flex-col rounded-md bg-white text-neutral-950 dark:(bg-neutral-800 text-neutral-50)',
+ 'command-dialog': 'overflow-hidden p-0 shadow-lg',
+ 'command-empty': 'py-6 text-center text-sm',
+ 'command-group': 'overflow-y-auto overflow-x-hidden p-1 text-neutral-950',
+ 'command-group-label': 'px-2 py-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400',
+ 'command-input': 'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-neutral-500 disabled:(cursor-not-allowed opacity-50) dark:placeholder:text-neutral-400',
+ 'command-item': 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:(bg-neutral-100 text-neutral-900) data-[disabled]:(pointer-events-none opacity-50) dark:data-[highlighted]:(bg-neutral-700/40 text-neutral-50)',
+ 'command-list': 'max-h-[300px] overflow-y-auto overflow-x-hidden',
+ 'command-separator': '-mx-1 h-px bg-neutral-200 dark:bg-neutral-700/40',
+ 'command-shortcut': 'ml-auto text-xs tracking-widest text-neutral-500 dark:text-neutral-400',
+}
+
+export const command = [
+ staticCommand,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/dialog.ts b/apps/client/src/shared/ui/_shortcuts/dialog.ts
new file mode 100644
index 00000000..4d65bc2f
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/dialog.ts
@@ -0,0 +1,19 @@
+type DialogPrefix = 'dialog'
+
+export const staticDialog: Record<`${DialogPrefix}-${string}` | DialogPrefix, string> = {
+ 'dialog': '',
+ 'dialog-overlay': 'fixed inset-0 z-999 bg-black/60',
+ 'dialog-content': 'fixed left-1/2 top-1/2 z-1000 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-neutral-200 bg-white p-6 shadow-lg duration-200 sm:rounded-lg dark:(border-neutral-800 bg-neutral-800)',
+ 'dialog-close': 'absolute right-4 top-3.25 w-4 h-4 flex items-center justify-center rounded-sm opacity-70 ring-offset-0 transition-opacity hover:opacity-100 focus:outline-none focus:ring-0 disabled:pointer-events-none dark:(ring-offset-0 focus:ring-0)',
+ 'dialog-title': 'text-lg font-semibold leading-none tracking-tight',
+ 'dialog-description': 'text-sm text-neutral-500 dark:text-neutral-400',
+ 'dialog-footer': 'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
+ 'dialog-header': 'flex flex-col gap-y-1.5 text-center sm:text-left',
+ 'dialog-scroll-overlay': 'dialog-overlay grid place-items-center overflow-y-auto',
+ 'dialog-scroll-content': 'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-neutral-200 bg-white p-6 shadow-lg duration-200 sm:rounded-lg md:w-full dark:(border-neutral-800 bg-neutral-800)',
+ 'dialog-scroll-close': 'fixed inset-0 z-50 cursor-pointer opacity-0',
+}
+
+export const dialog = [
+ staticDialog,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/dropdown-menu.ts b/apps/client/src/shared/ui/_shortcuts/dropdown-menu.ts
new file mode 100644
index 00000000..0c07c2e6
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/dropdown-menu.ts
@@ -0,0 +1,19 @@
+type DropdownMenuPrefix = 'dropdown-menu'
+
+export const staticDropdownMenu: Record<`${DropdownMenuPrefix}-${string}` | DropdownMenuPrefix, string> = {
+ 'dropdown-menu': '',
+ 'dropdown-menu-content': 'z-1000 min-w-32 overflow-hidden rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-md dark:(border-neutral-700 bg-neutral-800 text-neutral-50)',
+ 'dropdown-menu-checkbox-item': 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:(pointer-events-none opacity-50) dark:(focus:bg-neutral-700/40 focus:text-neutral-50)',
+ 'dropdown-menu-checkbox-item-indicator': 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',
+ 'dropdown-menu-item': 'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:(pointer-events-none opacity-50) [&>svg]:size-4 [&>svg]:shrink-0 dark:(focus:bg-neutral-700/40 focus:text-neutral-50)',
+ 'dropdown-menu-label': 'px-2 py-1.5 text-sm font-semibold',
+ 'dropdown-menu-separator': '-mx-1 my-1 h-px bg-neutral-100 dark:bg-neutral-700/40',
+ 'dropdown-menu-shortcut': 'ml-auto text-xs tracking-widest opacity-60',
+ 'dropdown-menu-sub-content': 'z-50 min-w-32 overflow-hidden rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-lg dark:(border-neutral-700/40 bg-neutral-800 text-neutral-50)',
+ 'dropdown-menu-sub-trigger': 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-neutral-100 data-[state=open]:bg-neutral-100 dark:(focus:bg-neutral-800 data-[state=open]:bg-neutral-800)',
+ 'dropdown-menu-trigger': 'outline-none',
+}
+
+export const dropdownMenu = [
+ staticDropdownMenu,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/form.ts b/apps/client/src/shared/ui/_shortcuts/form.ts
new file mode 100644
index 00000000..c44b528f
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/form.ts
@@ -0,0 +1,13 @@
+type FormPrefix = 'form'
+
+export const staticForm: Record<`${FormPrefix}-${string}` | FormPrefix, string> = {
+ 'form': '',
+ 'form-field': 'grid gap-2 justify-items-start',
+ 'form-label': 'text-sm text-neutral-900 !fw500 dark:text-neutral-200 peer-disabled:(cursor-not-allowed opacity-70)',
+ 'form-message': 'flex items-center gap-2 capitalize text-xs normal-case !fw500',
+ 'form-text-underline': 'cursor-pointer underline underline-offset-4 duration-100 ease-in hover:text-neutral-900 dark:hover:text-neutral-400',
+}
+
+export const form = [
+ staticForm,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/index.ts b/apps/client/src/shared/ui/_shortcuts/index.ts
new file mode 100644
index 00000000..62ea770e
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/index.ts
@@ -0,0 +1,38 @@
+import { alert } from './alert'
+import { badge } from './badge'
+import { btn } from './button'
+import { dialog } from './dialog'
+import { dropdownMenu } from './dropdown-menu'
+import { form } from './form'
+import { input } from './input'
+import { pinInput } from './pin-input'
+import { select } from './select'
+import { popover } from './popover'
+import { command } from './command'
+import { tabs } from './tabs'
+import { pagination } from './pagination'
+import { table } from './table'
+import { checkbox } from './checkbox'
+import { picker } from './picker'
+import { tagsInput } from './tags-input'
+import type { Preset, StaticShortcutMap } from '@unocss/core'
+
+export const shortcuts = [
+ ...btn,
+ ...input,
+ ...badge,
+ ...alert,
+ ...dropdownMenu,
+ ...select,
+ ...dialog,
+ ...pinInput,
+ ...form,
+ ...popover,
+ ...command,
+ ...tabs,
+ ...pagination,
+ ...checkbox,
+ ...table,
+ ...picker,
+ ...tagsInput,
+] as Exclude
diff --git a/apps/client/src/shared/ui/_shortcuts/input.ts b/apps/client/src/shared/ui/_shortcuts/input.ts
new file mode 100644
index 00000000..c449e7f3
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/input.ts
@@ -0,0 +1,9 @@
+type InputPrefix = 'input'
+
+export const staticInput: Record<`${InputPrefix}-${string}` | InputPrefix, string> = {
+ input: 'flex h-34px w-full rounded-md border border-solid border-neutral-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:(border-0 bg-transparent text-sm font-medium) placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:(cursor-not-allowed opacity-50) dark:(border-neutral-700 placeholder:text-neutral-400 focus-visible:ring-neutral-300)',
+}
+
+export const input = [
+ staticInput,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/pagination.ts b/apps/client/src/shared/ui/_shortcuts/pagination.ts
new file mode 100644
index 00000000..ec20b3ba
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/pagination.ts
@@ -0,0 +1,11 @@
+type PaginationPrefix = 'pagination'
+
+export const staticPagination: Record<`${PaginationPrefix}-${string}` | PaginationPrefix, string> = {
+ 'pagination': '',
+ 'pagination-ellipsis': 'w-9 h-9 flex items-center justify-center',
+ 'pagination-item': 'w-9 h-9 p-0',
+}
+
+export const pagination = [
+ staticPagination,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/picker.ts b/apps/client/src/shared/ui/_shortcuts/picker.ts
new file mode 100644
index 00000000..1dcb94c5
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/picker.ts
@@ -0,0 +1,13 @@
+type PickerPrefix = 'picker'
+
+export const staticPicker: Record<`${PickerPrefix}-${string}` | PickerPrefix, string> = {
+ 'picker': 'flex flex-wrap gap-1 mt-0',
+ 'picker-icon': 'h-3.5 w-3.5 rounded object-cover transition-all mr-px cursor-pointer text-neutral-800 dark:text-neutral-100',
+ 'picker-input': 'col-span-2 h-8 mt-4 focus:ring-0',
+ 'picker-tabs': 'w-full',
+ 'picker-item': 'rounded-md h-6 w-6 cursor-pointer active:scale-105',
+}
+
+export const picker = [
+ staticPicker,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/pin-input.ts b/apps/client/src/shared/ui/_shortcuts/pin-input.ts
new file mode 100644
index 00000000..0116d9bc
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/pin-input.ts
@@ -0,0 +1,11 @@
+type PinInputPrefix = 'pin-input'
+
+export const staticPinInput: Record<`${PinInputPrefix}-${string}` | PinInputPrefix, string> = {
+ 'pin-input': 'flex gap-2 items-center',
+ 'pin-input-group': 'flex items-center',
+ 'pin-input-input': 'relative placeholder:(absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2) align-middle text-center focus:(outline-none ring-2 ring-neutral-950 relative z-10) flex h-9 w-9 items-center justify-center border-y border-r border-neutral-200 text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md dark:(focus:ring-neutral-300 border-neutral-800)',
+}
+
+export const pinInput = [
+ staticPinInput,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/popover.ts b/apps/client/src/shared/ui/_shortcuts/popover.ts
new file mode 100644
index 00000000..734a24c6
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/popover.ts
@@ -0,0 +1,10 @@
+type PopoverPrefix = 'popover'
+
+export const staticPopover: Record<`${PopoverPrefix}-${string}` | PopoverPrefix, string> = {
+ 'popover': '',
+ 'popover-content': 'z-1001 w-72 rounded-md border border-neutral-200 bg-white p-2 px-3 text-neutral-950 shadow outline-none dark:(border-neutral-700 bg-neutral-800 text-neutral-50)',
+}
+
+export const popover = [
+ staticPopover,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/select.ts b/apps/client/src/shared/ui/_shortcuts/select.ts
new file mode 100644
index 00000000..53723393
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/select.ts
@@ -0,0 +1,17 @@
+type SelectPrefix = 'select'
+
+export const staticSelect: Record<`${SelectPrefix}-${string}` | SelectPrefix, string> = {
+ 'select': '',
+ 'select-content': 'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border border-solid border-neutral-200 bg-white text-neutral-950 shadow-md dark:(border-neutral-700/40 bg-neutral-800 text-neutral-50)',
+ 'select-group': 'p-1 w-full',
+ 'select-item': 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:(pointer-events-none opacity-50) dark:(focus:bg-neutral-700/40 focus:text-neutral-50)',
+ 'select-item-indicator': 'absolute right-2 flex h-3.5 w-3.5 items-center justify-center',
+ 'select-label': 'px-2 py-1.5 text-sm font-semibold',
+ 'select-scroll-button': 'flex cursor-default items-center justify-center py-1',
+ 'select-separator': '-mx-1 my-1 h-px bg-neutral-100 dark:bg-neutral-800',
+ 'select-trigger': 'flex h-8 w-full items-center justify-between whitespace-nowrap rounded-md border border-solid border-neutral-200 bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-white data-[placeholder]:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start dark:(border-neutral-700 ring-offset-neutral-800 data-[placeholder]:text-neutral-400 focus:ring-neutral-300)',
+}
+
+export const select = [
+ staticSelect,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/table.ts b/apps/client/src/shared/ui/_shortcuts/table.ts
new file mode 100644
index 00000000..70e70b5b
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/table.ts
@@ -0,0 +1,18 @@
+type TablePrefix = 'table'
+
+export const staticTable: Record<`${TablePrefix}-${string}` | TablePrefix, string> = {
+ 'table': 'w-full caption-bottom text-13px 2xl:text-sm overflow-y-auto',
+ 'table-wrapper': 'relative w-full overflow-x-hidden overflow-y-auto border border-neutral-100 dark:border-neutral-700/50 max-h-[calc(100dvh-160px)] rounded-md',
+ 'table-body': '[&_tr:last-child]:border-0',
+ 'table-row': 'border-b border-neutral-100 dark:border-neutral-700/50 transition-colors data-[state=selected]:bg-blue-100 dark:data-[state=selected]:bg-blue-900 cursor-pointer',
+ 'table-cell': 'p-2 border-r overflow-x-auto first:border-none last:border-none border-neutral-100 dark:border-neutral-700/50 overflow-hidden text-ellipsis whitespace-nowrap px-8px h-12 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:flex [&>[role=checkbox]]:items-center dark:text-neutral-100',
+ 'table-head': 'h-12 px-8px first:pl-12px border-r first:border-none last:border-none border-neutral-100 dark:border-neutral-700/50 text-ellipsis text-left align-middle font-medium text-neutral-500 dark:text-neutral-400 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:flex [&>[role=checkbox]]:items-center',
+ 'table-header': 'sticky top-0 z-33 border-b bg-neutral-50 dark:bg-#2e2e2e',
+ 'table-empty': 'flex items-center justify-center text-sm text-neutral-500 dark:text-neutral-400',
+ 'table-empty-cell': 'whitespace-nowrap align-middle text-sm text-neutral-950 dark:text-neutral-100',
+ 'table-caption': 'mt-4 text-sm text-neutral-500 dark:text-neutral-400',
+}
+
+export const table = [
+ staticTable,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/tabs.ts b/apps/client/src/shared/ui/_shortcuts/tabs.ts
new file mode 100644
index 00000000..5d0b23e5
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/tabs.ts
@@ -0,0 +1,12 @@
+type TabsPrefix = 'tabs'
+
+export const staticTabs: Record<`${TabsPrefix}-${string}` | TabsPrefix, string> = {
+ 'tabs': '',
+ 'tabs-content': 'focus-visible:outline-none focus-visible:ring-0',
+ 'tabs-list': 'inline-flex items-center justify-center rounded-lg bg-neutral-100 h-9 2xl:h-8 border border-neutral-100 p-1 text-neutral-500 dark:(border-neutral-700 bg-neutral-800 text-neutral-400)',
+ 'tabs-trigger': 'inline-flex items-center bg-transparent justify-center whitespace-nowrap rounded-md px-3 py-1 2xl:(py-0.5 px-2.5) text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-neutral-950 data-[state=active]:shadow dark:(ring-offset-0 focus-visible:ring-0 data-[state=active]:bg-neutral-700/40 data-[state=active]:text-neutral-100)',
+}
+
+export const tabs = [
+ staticTabs,
+]
diff --git a/apps/client/src/shared/ui/_shortcuts/tags-input.ts b/apps/client/src/shared/ui/_shortcuts/tags-input.ts
new file mode 100644
index 00000000..d5a7dac6
--- /dev/null
+++ b/apps/client/src/shared/ui/_shortcuts/tags-input.ts
@@ -0,0 +1,14 @@
+type TagsInputPrefix = 'tags-input'
+
+export const staticTagsInput: Record<`${TagsInputPrefix}-${string}` | TagsInputPrefix, string> = {
+ 'tags-input': 'flex flex-wrap gap-2 items-center rounded-md border border-neutral-200 px-3 py-1.5 text-sm dark:(border-neutral-700 focus-visible:ring-neutral-300)',
+ 'tags-input-input': 'text-sm min-h-5 focus:outline-none flex-1 bg-transparent placeholder:text-neutral-500 dark:placeholder:text-neutral-400',
+ 'tags-input-item': 'flex h-5 items-center rounded-md ring-offset-0 bg-neutral-100 text-neutral-800 dark:(bg-neutral-700/60 text-neutral-100) data-[state=active]:(ring-0 ring-offset-0)',
+ 'tags-input-item-delete': 'flex rounded bg-transparent mr-1',
+ 'tags-input-item-delete-icon': 'h-3.5 w-3.5',
+ 'tags-input-item-text': 'py-0.5 px-2 text-sm rounded bg-transparent',
+}
+
+export const tagsInput = [
+ staticTagsInput,
+]
diff --git a/apps/client/src/shared/ui/alert/UiAlert.stories.ts b/apps/client/src/shared/ui/alert/UiAlert.stories.ts
new file mode 100644
index 00000000..b77fcc4f
--- /dev/null
+++ b/apps/client/src/shared/ui/alert/UiAlert.stories.ts
@@ -0,0 +1,53 @@
+import UiAlert from './UiAlert.vue'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiAlert',
+ component: UiAlert,
+ argTypes: {
+ variant: {
+ control: { type: 'select' },
+ options: ['default', 'warning', 'success', 'important'],
+ defaultValue: 'default',
+ },
+ closable: {
+ control: 'boolean',
+ },
+ },
+} as Meta
+
+const Template: StoryFn = args => ({
+ components: { UiAlert },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ ${args.default}
+
+ `,
+})
+
+export const DefaultAlert: StoryFn = Template.bind({})
+
+DefaultAlert.args = {
+ variant: 'default',
+ content: 'hello from alert',
+}
+
+export const AlertExampleProps: StoryFn = Template.bind({})
+
+AlertExampleProps.args = {
+ variant: 'warning',
+ content: 'hello from alert',
+ closable: true,
+}
+
+export const SlottedAlert: StoryFn = Template.bind({})
+
+SlottedAlert.args = {
+ variant: 'success',
+ default: `
+ alert with slot with success variant
+ `,
+}
diff --git a/apps/client/src/shared/ui/alert/UiAlert.vue b/apps/client/src/shared/ui/alert/UiAlert.vue
new file mode 100644
index 00000000..5ad8b407
--- /dev/null
+++ b/apps/client/src/shared/ui/alert/UiAlert.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/alert/__tests__/UiAlert.spec.ts b/apps/client/src/shared/ui/alert/__tests__/UiAlert.spec.ts
new file mode 100644
index 00000000..bbb5ad0c
--- /dev/null
+++ b/apps/client/src/shared/ui/alert/__tests__/UiAlert.spec.ts
@@ -0,0 +1,29 @@
+import { nextTick } from 'vue'
+import { shallowMount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import UiAlert from '../UiAlert.vue'
+
+describe('tests for UiAlert', () => {
+ const wrapper = shallowMount(UiAlert, {
+ props: {
+ variant: 'default',
+ },
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html())
+ })
+
+ it('should have content at slot and render x icon', async () => {
+ const _w = shallowMount(UiAlert, {
+ slots: {
+ default: 'hello from alert',
+ },
+ props: {
+ closable: true,
+ },
+ })
+ await nextTick()
+ expect(_w.html()).toMatchSnapshot()
+ })
+})
diff --git a/apps/client/src/shared/ui/alert/__tests__/__snapshots__/UiAlert.spec.ts.snap b/apps/client/src/shared/ui/alert/__tests__/__snapshots__/UiAlert.spec.ts.snap
new file mode 100644
index 00000000..deb6e7e1
--- /dev/null
+++ b/apps/client/src/shared/ui/alert/__tests__/__snapshots__/UiAlert.spec.ts.snap
@@ -0,0 +1,6 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for UiAlert > should have content at slot and render x icon 1`] = `
+""
+`;
diff --git a/apps/client/src/shared/ui/alert/index.ts b/apps/client/src/shared/ui/alert/index.ts
new file mode 100644
index 00000000..40c4f8bc
--- /dev/null
+++ b/apps/client/src/shared/ui/alert/index.ts
@@ -0,0 +1,16 @@
+import { cva, type VariantProps } from 'class-variance-authority'
+
+export { default as UiAlert } from './UiAlert.vue'
+
+export const alertVariants = cva('alert', {
+ variants: {
+ variant: {
+ default: 'alert-default',
+ success: 'alert-success',
+ warning: 'alert-warning',
+ important: 'alert-important',
+ },
+ },
+})
+
+export type AlertVariants = VariantProps
diff --git a/apps/client/src/shared/ui/badge/UiBadge.stories.ts b/apps/client/src/shared/ui/badge/UiBadge.stories.ts
new file mode 100644
index 00000000..0eeff978
--- /dev/null
+++ b/apps/client/src/shared/ui/badge/UiBadge.stories.ts
@@ -0,0 +1,41 @@
+import UiBadge from './UiBadge.vue'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiBadge',
+ component: UiBadge,
+ argTypes: {
+ variant: {
+ control: { type: 'select' },
+ options: ['default', 'secondary', 'outline'],
+ },
+ },
+} as Meta
+
+const Template: StoryFn = (args) => {
+ return {
+ components: { UiBadge },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ ${args.default}
+
+ `,
+ }
+}
+
+export const DefaultBadge: StoryFn = Template.bind({})
+
+DefaultBadge.args = {
+ variant: 'default',
+ default: `Badge`,
+}
+
+export const BadgeExampleProps: StoryFn = Template.bind({})
+
+BadgeExampleProps.args = {
+ variant: 'outline',
+ default: `new`,
+}
diff --git a/apps/client/src/shared/ui/badge/UiBadge.vue b/apps/client/src/shared/ui/badge/UiBadge.vue
new file mode 100644
index 00000000..a03166f2
--- /dev/null
+++ b/apps/client/src/shared/ui/badge/UiBadge.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/badge/__tests__/UiBadge.spec.ts b/apps/client/src/shared/ui/badge/__tests__/UiBadge.spec.ts
new file mode 100644
index 00000000..4c412124
--- /dev/null
+++ b/apps/client/src/shared/ui/badge/__tests__/UiBadge.spec.ts
@@ -0,0 +1,44 @@
+import { shallowMount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import UiBadge from '../UiBadge.vue'
+import type { BadgeVariants } from '..'
+
+describe('tests for UiBadge', () => {
+ const wrapper = shallowMount(UiBadge, {
+ props: {
+ variant: 'default',
+ },
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html())
+ })
+
+ it('should have content at slot', () => {
+ const _w = shallowMount(UiBadge, {
+ slots: {
+ default: 'badge',
+ },
+ })
+ expect(_w.html()).toMatchSnapshot()
+ })
+
+ it('should apply correct styles for variants', () => {
+ const variants = [
+ 'default',
+ 'secondary',
+ 'outline',
+ 'solid',
+ 'destructive',
+ ] as BadgeVariants['variant'][]
+ variants.forEach(() => {
+ const wrapper = shallowMount(UiBadge, {
+ props: {
+ variant: 'default',
+ },
+ })
+ const badge = wrapper.find('div')
+ expect(badge.attributes('class')).toContain(`badge-default`)
+ })
+ })
+})
diff --git a/apps/client/src/shared/ui/badge/__tests__/__snapshots__/UiBadge.spec.ts.snap b/apps/client/src/shared/ui/badge/__tests__/__snapshots__/UiBadge.spec.ts.snap
new file mode 100644
index 00000000..3db2b9f6
--- /dev/null
+++ b/apps/client/src/shared/ui/badge/__tests__/__snapshots__/UiBadge.spec.ts.snap
@@ -0,0 +1,3 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for UiBadge > should have content at slot 1`] = `"badge
"`;
diff --git a/apps/client/src/shared/ui/badge/index.ts b/apps/client/src/shared/ui/badge/index.ts
new file mode 100644
index 00000000..360eea5c
--- /dev/null
+++ b/apps/client/src/shared/ui/badge/index.ts
@@ -0,0 +1,19 @@
+import { cva, type VariantProps } from 'class-variance-authority'
+
+export { default as UiBadge } from './UiBadge.vue'
+
+export const badgeVariants = cva('badge', {
+ variants: {
+ variant: {
+ default: 'badge-default',
+ secondary: 'badge-secondary',
+ destructive: 'badge-colorized-red',
+ success: 'badge-colorized-green',
+ solid: 'badge-solid',
+ outline: 'badge-outline',
+ soft: 'badge-soft',
+ },
+ },
+})
+
+export type BadgeVariants = VariantProps
diff --git a/apps/client/src/shared/ui/button/UiButton.stories.ts b/apps/client/src/shared/ui/button/UiButton.stories.ts
new file mode 100644
index 00000000..15a55c28
--- /dev/null
+++ b/apps/client/src/shared/ui/button/UiButton.stories.ts
@@ -0,0 +1,69 @@
+import UiButton from './UiButton.vue'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiButton',
+ component: UiButton,
+ argTypes: {
+ size: {
+ control: { type: 'radio' },
+ options: ['sm', 'default', 'lg'],
+ defaultValue: 'md',
+ },
+ variant: {
+ control: { type: 'select' },
+ options: [
+ 'default',
+ 'ghost',
+ 'secondary',
+ 'destructive',
+ 'outline',
+ 'dashed',
+ ],
+ defaultValue: 'default',
+ },
+ },
+} as Meta
+
+const Template: StoryFn = (args) => {
+ return {
+ components: { UiButton, File },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ ${args.leading}
+ ${args.default}
+ ${args.trailing}
+
+ `,
+ }
+}
+
+export const DefaultButton: StoryFn = Template.bind({})
+
+DefaultButton.args = {
+ size: 'default',
+ variant: 'default',
+ default: `Get Started`,
+}
+
+export const ButtonExampleProps: StoryFn = Template.bind({})
+
+ButtonExampleProps.args = {
+ size: 'sm',
+ variant: 'solid',
+ default: `Create task`,
+}
+
+export const SlottedButton: StoryFn = Template.bind({})
+
+SlottedButton.args = {
+ size: 'default',
+ variant: 'default',
+ leading: ``,
+ default: `
+ Download
+ `,
+}
diff --git a/apps/client/src/shared/ui/button/UiButton.vue b/apps/client/src/shared/ui/button/UiButton.vue
new file mode 100644
index 00000000..526f3d4a
--- /dev/null
+++ b/apps/client/src/shared/ui/button/UiButton.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/button/__tests__/UiButton.spec.ts b/apps/client/src/shared/ui/button/__tests__/UiButton.spec.ts
new file mode 100644
index 00000000..92164525
--- /dev/null
+++ b/apps/client/src/shared/ui/button/__tests__/UiButton.spec.ts
@@ -0,0 +1,66 @@
+import { mount, shallowMount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import UiButton from '../UiButton.vue'
+import type { ButtonVariants } from '..'
+
+describe('tests for UiButton', () => {
+ // with
+ const wrapper = shallowMount(UiButton, {
+ props: {
+ variant: 'default',
+ size: 'default',
+ as: 'button',
+ },
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html())
+ })
+
+ it('should have content at slot', () => {
+ const _w = shallowMount(UiButton, {
+ slots: {
+ default: 'ui button',
+ },
+ })
+ expect(_w.html()).toMatchSnapshot()
+ })
+
+ it('should apply correct styles for variants', () => {
+ const variants = [
+ 'default',
+ 'secondary',
+ 'solid',
+ 'destructive',
+ 'ghost',
+ 'outline',
+ 'dashed',
+ ] as ButtonVariants['variant'][]
+
+ variants.forEach(() => {
+ const wrapper = mount(UiButton, {
+ props: {
+ variant: 'solid',
+ size: 'default',
+ },
+ })
+ const button = wrapper.find('button')
+ expect(button.exists()).toBe(true)
+ expect(button.attributes('class')).toContain('btn-solid')
+ })
+ })
+
+ it('should apply correct styles for size', () => {
+ const sizes = ['lg', 'default', 'sm'] as ButtonVariants['size'][]
+ sizes.forEach(() => {
+ const wrapper = mount(UiButton, {
+ props: {
+ variant: 'dashed',
+ size: 'sm',
+ },
+ })
+ const button = wrapper.find('button')
+ expect(button.attributes('class')).toContain(`h-8`)
+ })
+ })
+})
diff --git a/apps/client/src/shared/ui/button/__tests__/__snapshots__/UiButton.spec.ts.snap b/apps/client/src/shared/ui/button/__tests__/__snapshots__/UiButton.spec.ts.snap
new file mode 100644
index 00000000..0880a570
--- /dev/null
+++ b/apps/client/src/shared/ui/button/__tests__/__snapshots__/UiButton.spec.ts.snap
@@ -0,0 +1,3 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for UiButton > should have content at slot 1`] = `""`;
diff --git a/apps/client/src/shared/ui/button/index.ts b/apps/client/src/shared/ui/button/index.ts
new file mode 100644
index 00000000..840c2b51
--- /dev/null
+++ b/apps/client/src/shared/ui/button/index.ts
@@ -0,0 +1,27 @@
+import { cva, type VariantProps } from 'class-variance-authority'
+
+export { default as UiButton } from './UiButton.vue'
+
+export const buttonVariants = cva('btn', {
+ variants: {
+ variant: {
+ default: 'btn-default',
+ solid: 'btn-solid',
+ destructive: 'btn-colorized-red',
+ success: 'btn-colorized-green',
+ outline: 'btn-outline',
+ secondary: 'btn-secondary',
+ ghost: 'btn-ghost',
+ dashed: 'btn-dashed',
+ },
+ size: {
+ default: 'h-9 px-4 py-2',
+ xs: 'h-8 rounded px-2 text-xs rounded-lg',
+ md: 'h-34px rounded-md px-3',
+ sm: 'h-34px 2xl:h-8 rounded-md px-3',
+ lg: 'h-38px rounded-md px-8',
+ },
+ },
+})
+
+export type ButtonVariants = VariantProps
diff --git a/apps/client/src/shared/ui/card/UiCard.vue b/apps/client/src/shared/ui/card/UiCard.vue
new file mode 100644
index 00000000..aa790725
--- /dev/null
+++ b/apps/client/src/shared/ui/card/UiCard.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/card/index.ts b/apps/client/src/shared/ui/card/index.ts
new file mode 100644
index 00000000..e49b307d
--- /dev/null
+++ b/apps/client/src/shared/ui/card/index.ts
@@ -0,0 +1 @@
+export { default as UiCard } from './UiCard.vue'
diff --git a/apps/client/src/shared/ui/checkbox/UiCheckbox.stories.ts b/apps/client/src/shared/ui/checkbox/UiCheckbox.stories.ts
new file mode 100644
index 00000000..7df90e6e
--- /dev/null
+++ b/apps/client/src/shared/ui/checkbox/UiCheckbox.stories.ts
@@ -0,0 +1,32 @@
+import UiCheckbox from './UiCheckbox.vue'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiCheckbox',
+ component: UiCheckbox,
+ argTypes: {
+ checked: {
+ control: { type: 'boolean' },
+ },
+ },
+} as Meta
+
+const Template: StoryFn = (args) => {
+ return {
+ components: { UiCheckbox },
+ setup() {
+ return { args }
+ },
+ template: `
+
+
`,
+ }
+}
+
+export const DefaultCheckbox: StoryFn = Template.bind({})
+
+export const CheckboxWithVariant: StoryFn = Template.bind({})
+
+CheckboxWithVariant.args = {
+ variant: 'solid',
+}
diff --git a/apps/client/src/shared/ui/checkbox/UiCheckbox.vue b/apps/client/src/shared/ui/checkbox/UiCheckbox.vue
new file mode 100644
index 00000000..d3a5d9ca
--- /dev/null
+++ b/apps/client/src/shared/ui/checkbox/UiCheckbox.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/checkbox/__tests__/UiCheckbox.spec.ts b/apps/client/src/shared/ui/checkbox/__tests__/UiCheckbox.spec.ts
new file mode 100644
index 00000000..1a0adfd1
--- /dev/null
+++ b/apps/client/src/shared/ui/checkbox/__tests__/UiCheckbox.spec.ts
@@ -0,0 +1,34 @@
+import { mount, shallowMount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import UiCheckbox from '../UiCheckbox.vue'
+import type { CheckboxVariants } from '..'
+
+describe('test for UiCheckbox', () => {
+ // with
+ it('should render correctly', () => {
+ const wrapper = shallowMount(UiCheckbox, {
+ props: {
+ checked: true,
+ },
+ })
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+ it('should apply correct styles for checkbox variants', () => {
+ const variants = [
+ 'default',
+ 'solid',
+ ] as CheckboxVariants['variant'][]
+
+ variants.forEach(() => {
+ const wrapper = mount(UiCheckbox, {
+ props: {
+ variant: 'solid',
+ checked: true,
+ },
+ })
+ const checkbox = wrapper.find('.checkbox')
+ expect(checkbox.exists()).toBe(true)
+ expect(checkbox.attributes('class')).toContain('checkbox-solid')
+ })
+ })
+})
diff --git a/apps/client/src/shared/ui/checkbox/__tests__/__snapshots__/UiCheckbox.spec.ts.snap b/apps/client/src/shared/ui/checkbox/__tests__/__snapshots__/UiCheckbox.spec.ts.snap
new file mode 100644
index 00000000..99304529
--- /dev/null
+++ b/apps/client/src/shared/ui/checkbox/__tests__/__snapshots__/UiCheckbox.spec.ts.snap
@@ -0,0 +1,3 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`test for UiCheckbox > should render correctly 1`] = `""`;
diff --git a/apps/client/src/shared/ui/checkbox/index.ts b/apps/client/src/shared/ui/checkbox/index.ts
new file mode 100644
index 00000000..9f13b2f1
--- /dev/null
+++ b/apps/client/src/shared/ui/checkbox/index.ts
@@ -0,0 +1,14 @@
+import { cva, type VariantProps } from 'class-variance-authority'
+
+export { default as UiCheckbox } from './UiCheckbox.vue'
+
+export const checkboxVariants = cva('checkbox', {
+ variants: {
+ variant: {
+ default: 'checkbox-default',
+ solid: 'checkbox-solid',
+ },
+ },
+})
+
+export type CheckboxVariants = VariantProps
diff --git a/apps/client/src/shared/ui/command/UiCommand.stories.ts b/apps/client/src/shared/ui/command/UiCommand.stories.ts
new file mode 100644
index 00000000..5f71290b
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommand.stories.ts
@@ -0,0 +1,35 @@
+import {
+ UiCommand,
+ UiCommandInput,
+ UiCommandItem,
+ UiCommandList,
+} from './index'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiCommand',
+ component: UiCommand,
+} as Meta
+
+const Template: StoryFn = args => ({
+ components: {
+ UiCommand,
+ UiCommandItem,
+ UiCommandList,
+ UiCommandInput,
+ },
+ setup() {
+ return { args }
+ },
+ template:
+ `
+
+
+
+ Calendar
+
+
+ `,
+})
+
+export const DefaultCommand: StoryFn = Template.bind({})
diff --git a/apps/client/src/shared/ui/command/UiCommand.vue b/apps/client/src/shared/ui/command/UiCommand.vue
new file mode 100644
index 00000000..7220e108
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommand.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/UiCommandDialog.vue b/apps/client/src/shared/ui/command/UiCommandDialog.vue
new file mode 100644
index 00000000..51ab4165
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommandDialog.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/UiCommandEmpty.vue b/apps/client/src/shared/ui/command/UiCommandEmpty.vue
new file mode 100644
index 00000000..968f3a34
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommandEmpty.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/UiCommandGroup.vue b/apps/client/src/shared/ui/command/UiCommandGroup.vue
new file mode 100644
index 00000000..0e8b4128
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommandGroup.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+ {{ heading }}
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/UiCommandInput.vue b/apps/client/src/shared/ui/command/UiCommandInput.vue
new file mode 100644
index 00000000..3f6ab843
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommandInput.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/UiCommandItem.vue b/apps/client/src/shared/ui/command/UiCommandItem.vue
new file mode 100644
index 00000000..9415220a
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommandItem.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/UiCommandList.vue b/apps/client/src/shared/ui/command/UiCommandList.vue
new file mode 100644
index 00000000..df8252ee
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommandList.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/UiCommandSeparator.vue b/apps/client/src/shared/ui/command/UiCommandSeparator.vue
new file mode 100644
index 00000000..80faba1c
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommandSeparator.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/UiCommandShortcut.vue b/apps/client/src/shared/ui/command/UiCommandShortcut.vue
new file mode 100644
index 00000000..567c8675
--- /dev/null
+++ b/apps/client/src/shared/ui/command/UiCommandShortcut.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/__tests__/MockComponent.vue b/apps/client/src/shared/ui/command/__tests__/MockComponent.vue
new file mode 100644
index 00000000..93d67d79
--- /dev/null
+++ b/apps/client/src/shared/ui/command/__tests__/MockComponent.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+ test item
+
+
+
+
diff --git a/apps/client/src/shared/ui/command/__tests__/UiCommand.spec.ts b/apps/client/src/shared/ui/command/__tests__/UiCommand.spec.ts
new file mode 100644
index 00000000..466c6372
--- /dev/null
+++ b/apps/client/src/shared/ui/command/__tests__/UiCommand.spec.ts
@@ -0,0 +1,43 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import MockComponent from './MockComponent.vue'
+
+describe('tests for UiCommand', () => {
+ const wrapper = mount(MockComponent, {
+ props: {
+ open: true,
+ modelValue: ['value1', 'value2'],
+ },
+ })
+
+ beforeEach(async () => {
+ await wrapper.setProps({
+ filterFunction: (list: any[], term: string) => {
+ return list.filter(i => i.toLowerCase().includes(term.toLowerCase()))
+ },
+ })
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('should filter items', async () => {
+ const input = wrapper.findComponent({ name: 'UiCommandInput' })
+ await input.setValue('te')
+
+ const selection = wrapper.findAll('[data-highlighted]').filter(i => i.attributes('style') !== 'display: none;')
+ expect(selection.length).toBe(1)
+ expect(selection[0].element.innerHTML).contains('test item')
+ })
+
+ it('should work props/emits correctly', async () => {
+ wrapper.findComponent({ name: 'UiCommandItem' }).trigger('click')
+ expect(wrapper.emitted()['update:modelValue']).toBeTruthy()
+
+ const selectedItems = wrapper.props().modelValue
+ expect(selectedItems).toEqual(['value1', 'value2'])
+ })
+})
+
+// more tests: https://github.com/unovue/radix-vue/blob/main/packages/radix-vue/src/Combobox/Combobox.test.ts
diff --git a/apps/client/src/shared/ui/command/__tests__/__snapshots__/UiCommand.spec.ts.snap b/apps/client/src/shared/ui/command/__tests__/__snapshots__/UiCommand.spec.ts.snap
new file mode 100644
index 00000000..c015e831
--- /dev/null
+++ b/apps/client/src/shared/ui/command/__tests__/__snapshots__/UiCommand.spec.ts.snap
@@ -0,0 +1,15 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for UiCommand > should render correctly 1`] = `
+""
+`;
diff --git a/apps/client/src/shared/ui/command/index.ts b/apps/client/src/shared/ui/command/index.ts
new file mode 100644
index 00000000..4702d497
--- /dev/null
+++ b/apps/client/src/shared/ui/command/index.ts
@@ -0,0 +1,9 @@
+export { default as UiCommand } from './UiCommand.vue'
+export { default as UiCommandDialog } from './UiCommandDialog.vue'
+export { default as UiCommandEmpty } from './UiCommandEmpty.vue'
+export { default as UiCommandGroup } from './UiCommandGroup.vue'
+export { default as UiCommandInput } from './UiCommandInput.vue'
+export { default as UiCommandItem } from './UiCommandItem.vue'
+export { default as UiCommandList } from './UiCommandList.vue'
+export { default as UiCommandSeparator } from './UiCommandSeparator.vue'
+export { default as UiCommandShortcut } from './UiCommandShortcut.vue'
diff --git a/apps/client/src/shared/ui/data-table/DataTable.vue b/apps/client/src/shared/ui/data-table/DataTable.vue
new file mode 100644
index 00000000..4249b685
--- /dev/null
+++ b/apps/client/src/shared/ui/data-table/DataTable.vue
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ (header.column.columnDef.meta as any).badge }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('table.empty') }}
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/data-table/index.ts b/apps/client/src/shared/ui/data-table/index.ts
new file mode 100644
index 00000000..4336ee8b
--- /dev/null
+++ b/apps/client/src/shared/ui/data-table/index.ts
@@ -0,0 +1 @@
+export { default as DataTable } from './DataTable.vue'
diff --git a/apps/client/src/shared/ui/data-table/types.ts b/apps/client/src/shared/ui/data-table/types.ts
new file mode 100644
index 00000000..13c93f4b
--- /dev/null
+++ b/apps/client/src/shared/ui/data-table/types.ts
@@ -0,0 +1,66 @@
+import type { ColumnDef, GroupColumnDef } from '@tanstack/vue-table'
+import type { HTMLAttributes } from 'vue'
+
+export interface TableProps extends TableRootProps {
+ data: TData[] | Set
+ columns: ColumnDef[] | GroupColumnDef[]
+ rowId?: string
+ autoResetAll?: boolean
+ enableRowSelection?: boolean
+ enableMultiRowSelection?: boolean
+ enableSubRowSelection?: boolean
+ enableColumnFilters?: boolean
+ enableSorting?: boolean
+ enableMultiSort?: boolean
+ enableMultiRemove?: boolean
+ enableSortingRemoval?: boolean
+ manualSorting?: boolean
+ maxMultiSortColCount?: number
+ manualPagination?: boolean
+ pageCount?: number
+ rowCount?: number
+ autoResetPageIndex?: boolean
+ sortingFns?: Record number>
+ sortDescFirst?: boolean
+ isMultiSortEvent?: (e: unknown) => boolean
+
+ _tableHead?: TableHeadProps
+ _tableHeader?: TableHeaderProps
+ _tableBody?: TableBodyProps
+ _tableRow?: TableRowProps
+ _tableCell?: TableCellProps
+ _tableEmpty?: TableEmptyProps
+}
+
+export interface TableBodyProps {
+ class?: HTMLAttributes['class']
+}
+
+export interface TableRootProps {
+ class?: HTMLAttributes['class']
+}
+
+export interface TableHeadProps {
+ class?: HTMLAttributes['class']
+ dataPinned?: 'left' | 'right' | false
+}
+
+export interface TableHeaderProps {
+ class?: HTMLAttributes['class']
+}
+
+export interface TableRowProps {
+ class?: HTMLAttributes['class']
+}
+
+export interface TableCellProps {
+ class?: HTMLAttributes['class']
+ dataPinned?: 'left' | 'right' | false
+}
+
+export interface TableEmptyProps {
+ class?: HTMLAttributes['class']
+ colspan?: number
+ _tableCell?: TableCellProps
+ _tableRow?: TableRowProps
+}
diff --git a/apps/client/src/shared/ui/dialog/UIDialogDescription.vue b/apps/client/src/shared/ui/dialog/UIDialogDescription.vue
new file mode 100644
index 00000000..ea0f4de8
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UIDialogDescription.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/UiDialog.stories.ts b/apps/client/src/shared/ui/dialog/UiDialog.stories.ts
new file mode 100644
index 00000000..e6ac6e79
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UiDialog.stories.ts
@@ -0,0 +1,36 @@
+import { UiButton } from '../button'
+import {
+ UiDialog,
+ UiDialogContent,
+ UiDialogTrigger,
+} from './index'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiDialog',
+ component: UiDialog,
+} as Meta
+
+const Template: StoryFn = args => ({
+ components: {
+ UiDialog,
+ UiDialogTrigger,
+ UiDialogContent,
+ UiButton,
+ },
+ setup() {
+ return { args }
+ },
+ template:
+ `
+
+ Open
+
+
+ hello from dialog
+ ${args.default}
+
+ `,
+})
+
+export const DefaultDialog: StoryFn = Template.bind({})
diff --git a/apps/client/src/shared/ui/dialog/UiDialog.vue b/apps/client/src/shared/ui/dialog/UiDialog.vue
new file mode 100644
index 00000000..a04c0262
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UiDialog.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/UiDialogClose.vue b/apps/client/src/shared/ui/dialog/UiDialogClose.vue
new file mode 100644
index 00000000..a64703e5
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UiDialogClose.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/UiDialogContent.vue b/apps/client/src/shared/ui/dialog/UiDialogContent.vue
new file mode 100644
index 00000000..b42a9409
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UiDialogContent.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/UiDialogFooter.vue b/apps/client/src/shared/ui/dialog/UiDialogFooter.vue
new file mode 100644
index 00000000..73fdee17
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UiDialogFooter.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/UiDialogHeader.vue b/apps/client/src/shared/ui/dialog/UiDialogHeader.vue
new file mode 100644
index 00000000..7d05aa74
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UiDialogHeader.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/UiDialogScrollContent.vue b/apps/client/src/shared/ui/dialog/UiDialogScrollContent.vue
new file mode 100644
index 00000000..3d3c6433
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UiDialogScrollContent.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+ {
+ const originalEvent = event.detail.originalEvent;
+ const target = originalEvent.target as HTMLElement;
+ if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
+ event.preventDefault();
+ }
+ }"
+ >
+
+
+
+
+ Close
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/UiDialogTitle.vue b/apps/client/src/shared/ui/dialog/UiDialogTitle.vue
new file mode 100644
index 00000000..891a060a
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UiDialogTitle.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/UiDialogTrigger.vue b/apps/client/src/shared/ui/dialog/UiDialogTrigger.vue
new file mode 100644
index 00000000..ee0c12ff
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/UiDialogTrigger.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/__tests__/MockComponent.vue b/apps/client/src/shared/ui/dialog/__tests__/MockComponent.vue
new file mode 100644
index 00000000..de53d7f2
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/__tests__/MockComponent.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+ open
+
+
+
+ title
+
+
+
+
diff --git a/apps/client/src/shared/ui/dialog/__tests__/UiDialog.spec.ts b/apps/client/src/shared/ui/dialog/__tests__/UiDialog.spec.ts
new file mode 100644
index 00000000..2ede07f9
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/__tests__/UiDialog.spec.ts
@@ -0,0 +1,55 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import { UiDialog } from '..'
+import DialogTest from './MockComponent.vue'
+import type { ComponentPublicInstance } from 'vue'
+import type { VueWrapper } from '@vue/test-utils'
+
+const OPEN_TEXT = 'open'
+const TITLE_TEXT = 'title'
+
+type DialogTestInstance = ComponentPublicInstance<
+ {},
+ {},
+ {
+ isOpen: boolean
+ }
+>
+
+describe('tests for UiDialog', () => {
+ it('should render correctly with default slot', () => {
+ const wrapper = mount(UiDialog, {
+ slots: { default: 'dialog
' },
+ props: { defaultOpen: true },
+ }) as VueWrapper
+
+ expect(wrapper.html()).toContain('dialog
')
+ })
+
+ it('should render dialog components correctly', async () => {
+ const wrapper = mount(DialogTest, {
+ global: {
+ stubs: { teleport: true },
+ },
+ }) as VueWrapper
+
+ expect(wrapper.html()).toContain(OPEN_TEXT)
+ expect(wrapper.html()).not.toContain(TITLE_TEXT)
+
+ wrapper.vm.isOpen = true
+ expect(wrapper.vm.isOpen).toBe(true)
+ })
+
+ it('should pass defaultOpen prop to DialogMenuRoot', () => {
+ const wrapper = mount(UiDialog, { props: { defaultOpen: true } })
+ const root = wrapper.findComponent({ name: 'DialogRoot' })
+ expect(root.props('defaultOpen')).toBe(true)
+ })
+
+ it('should emit openChange events correctly', () => {
+ const wrapper = mount(UiDialog)
+ wrapper.vm.$emit('openChange', true)
+ expect(wrapper.emitted('openChange')).toBeTruthy()
+ expect(wrapper.emitted('openChange')?.[0]).toEqual([true])
+ })
+})
diff --git a/apps/client/src/shared/ui/dialog/index.ts b/apps/client/src/shared/ui/dialog/index.ts
new file mode 100644
index 00000000..1b6d3312
--- /dev/null
+++ b/apps/client/src/shared/ui/dialog/index.ts
@@ -0,0 +1,9 @@
+export { default as UiDialog } from './UiDialog.vue'
+export { default as UiDialogClose } from './UiDialogClose.vue'
+export { default as UiDialogContent } from './UiDialogContent.vue'
+export { default as UiDialogDescription } from './UIDialogDescription.vue'
+export { default as UiDialogFooter } from './UiDialogFooter.vue'
+export { default as UiDialogHeader } from './UiDialogHeader.vue'
+export { default as UiDialogScrollContent } from './UiDialogScrollContent.vue'
+export { default as UiDialogTitle } from './UiDialogTitle.vue'
+export { default as UiDialogTrigger } from './UiDialogTrigger.vue'
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenu.stories.ts b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenu.stories.ts
new file mode 100644
index 00000000..b9869529
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenu.stories.ts
@@ -0,0 +1,41 @@
+import { UiButton } from '../button'
+import {
+ UiDropdownMenu,
+ UiDropdownMenuContent,
+ UiDropdownMenuItem,
+ UiDropdownMenuTrigger,
+} from './index'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiDropdownMenu',
+ component: UiDropdownMenu,
+} as Meta
+
+const Template: StoryFn = args => ({
+ components: {
+ UiDropdownMenu,
+ UiDropdownMenuItem,
+ UiDropdownMenuTrigger,
+ UiDropdownMenuContent,
+ UiButton,
+ },
+ setup() {
+ return { args }
+ },
+ template: `
+
+
+ Open
+
+
+ Profile
+ Billing
+ Team
+ Subscription
+
+
+
`,
+})
+
+export const DefaultDropdown: StoryFn = Template.bind({})
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenu.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenu.vue
new file mode 100644
index 00000000..b83d90b8
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenu.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuCheckboxItem.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuCheckboxItem.vue
new file mode 100644
index 00000000..6ecee08b
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuCheckboxItem.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuContent.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuContent.vue
new file mode 100644
index 00000000..867b6136
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuContent.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuGroup.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuGroup.vue
new file mode 100644
index 00000000..3f201352
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuGroup.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuItem.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuItem.vue
new file mode 100644
index 00000000..269a2c37
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuItem.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuLabel.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuLabel.vue
new file mode 100644
index 00000000..717fe40c
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuLabel.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSeparator.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSeparator.vue
new file mode 100644
index 00000000..22f1dcf6
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSeparator.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuShortcut.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuShortcut.vue
new file mode 100644
index 00000000..9db3e1fc
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuShortcut.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSub.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSub.vue
new file mode 100644
index 00000000..e0f4bd77
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSub.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSubContent.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSubContent.vue
new file mode 100644
index 00000000..0a8ee3bb
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSubContent.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSubTrigger.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSubTrigger.vue
new file mode 100644
index 00000000..4dfc4f91
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuSubTrigger.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuTrigger.vue b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuTrigger.vue
new file mode 100644
index 00000000..c7d632a4
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/UiDropdownMenuTrigger.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/dropdown-menu/__tests__/UiDropdownMenu.spec.ts b/apps/client/src/shared/ui/dropdown-menu/__tests__/UiDropdownMenu.spec.ts
new file mode 100644
index 00000000..8ad33425
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/__tests__/UiDropdownMenu.spec.ts
@@ -0,0 +1,27 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import UiDropdownMenu from '../UiDropdownMenu.vue'
+
+describe('tests for UiDropdownMenu', () => {
+ const wrapper = mount(UiDropdownMenu, {
+ slots: { default: 'test
' },
+ props: { defaultOpen: true },
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html()).toContain('test
')
+ })
+
+ it('should pass defaultOpen prop to DropdownMenuRoot', () => {
+ const root = wrapper.findComponent({ name: 'DropdownMenuRoot' })
+ expect(root.props('defaultOpen')).toBe(true)
+ })
+
+ it('should emit events correctly', () => {
+ wrapper.vm.$emit('openChange', true)
+ expect(wrapper.emitted('openChange')).toBeTruthy()
+ expect(wrapper.emitted('openChange')?.[0]).toEqual([true])
+ })
+})
+
+// more tests: https://github.com/unovue/radix-vue/blob/main/packages/radix-vue/src/DropdownMenu/DropdownMenu.test.ts
diff --git a/apps/client/src/shared/ui/dropdown-menu/index.ts b/apps/client/src/shared/ui/dropdown-menu/index.ts
new file mode 100644
index 00000000..b0cde6e4
--- /dev/null
+++ b/apps/client/src/shared/ui/dropdown-menu/index.ts
@@ -0,0 +1,13 @@
+export { default as UiDropdownMenu } from './UiDropdownMenu.vue'
+export { default as UiDropdownMenuCheckboxItem } from './UiDropdownMenuCheckboxItem.vue'
+export { default as UiDropdownMenuContent } from './UiDropdownMenuContent.vue'
+export { default as UiDropdownMenuGroup } from './UiDropdownMenuGroup.vue'
+export { default as UiDropdownMenuItem } from './UiDropdownMenuItem.vue'
+export { default as UiDropdownMenuLabel } from './UiDropdownMenuLabel.vue'
+export { default as UiDropdownMenuSeparator } from './UiDropdownMenuSeparator.vue'
+export { default as UiDropdownMenuShortcut } from './UiDropdownMenuShortcut.vue'
+export { default as UiDropdownMenuSub } from './UiDropdownMenuSub.vue'
+export { default as UiDropdownMenuSubContent } from './UiDropdownMenuSubContent.vue'
+export { default as UiDropdownMenuSubTrigger } from './UiDropdownMenuSubTrigger.vue'
+export { default as UiDropdownMenuTrigger } from './UiDropdownMenuTrigger.vue'
+export { DropdownMenuPortal } from 'radix-vue'
diff --git a/apps/client/src/shared/ui/form/UiForm.stories.ts b/apps/client/src/shared/ui/form/UiForm.stories.ts
new file mode 100644
index 00000000..2282a6d7
--- /dev/null
+++ b/apps/client/src/shared/ui/form/UiForm.stories.ts
@@ -0,0 +1,55 @@
+import {
+ UiButton,
+ UiFormField,
+ UiFormLabel,
+ UiFormMessage,
+ UiInput,
+} from '..'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiForm',
+} as Meta
+
+const Template: StoryFn = (args) => {
+ return {
+ components: { UiFormField, UiFormLabel, UiFormMessage, UiButton, UiInput },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ ${args.default}
+ Test
+
+ `,
+ }
+}
+
+export const DefaultFormField: StoryFn = Template.bind({})
+
+DefaultFormField.args = {
+ default: `
+
+
+ Label
+
+
+
+
+ `,
+}
+
+export const WarningFormField: StoryFn = Template.bind({})
+
+WarningFormField.args = {
+ default: `
+
+
+ Label
+
+
+
+
+ `,
+}
diff --git a/apps/client/src/shared/ui/form/UiFormField.vue b/apps/client/src/shared/ui/form/UiFormField.vue
new file mode 100644
index 00000000..4e3c775d
--- /dev/null
+++ b/apps/client/src/shared/ui/form/UiFormField.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/form/UiFormLabel.vue b/apps/client/src/shared/ui/form/UiFormLabel.vue
new file mode 100644
index 00000000..db14f995
--- /dev/null
+++ b/apps/client/src/shared/ui/form/UiFormLabel.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/form/UiFormMessage.vue b/apps/client/src/shared/ui/form/UiFormMessage.vue
new file mode 100644
index 00000000..435c57d5
--- /dev/null
+++ b/apps/client/src/shared/ui/form/UiFormMessage.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {{ message }}
+
+
diff --git a/apps/client/src/shared/ui/form/__tests__/MockComponent.vue b/apps/client/src/shared/ui/form/__tests__/MockComponent.vue
new file mode 100644
index 00000000..fd4f0ac4
--- /dev/null
+++ b/apps/client/src/shared/ui/form/__tests__/MockComponent.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/form/__tests__/UiForm.spec.ts b/apps/client/src/shared/ui/form/__tests__/UiForm.spec.ts
new file mode 100644
index 00000000..27272090
--- /dev/null
+++ b/apps/client/src/shared/ui/form/__tests__/UiForm.spec.ts
@@ -0,0 +1,19 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import UiForm from './MockComponent.vue'
+
+describe('tests for UiForm', () => {
+ it('should render correctly', () => {
+ const wrapper = mount(UiForm)
+
+ const label = wrapper.findComponent({ name: 'UiFormLabel' })
+ expect(label.exists()).toBe(true)
+ expect(label.text()).toBe('Label')
+
+ const message = wrapper.findComponent({ name: 'UiFormMessage' })
+ expect(message.exists()).toBe(true)
+ expect(message.props('content')).toBe('test message')
+ })
+})
+
+// more tests in stories
diff --git a/apps/client/src/shared/ui/form/index.ts b/apps/client/src/shared/ui/form/index.ts
new file mode 100644
index 00000000..6439979a
--- /dev/null
+++ b/apps/client/src/shared/ui/form/index.ts
@@ -0,0 +1,3 @@
+export { default as UiFormField } from './UiFormField.vue'
+export { default as UiFormLabel } from './UiFormLabel.vue'
+export { default as UiFormMessage } from './UiFormMessage.vue'
diff --git a/apps/client/src/shared/ui/index.ts b/apps/client/src/shared/ui/index.ts
new file mode 100644
index 00000000..a068d4fc
--- /dev/null
+++ b/apps/client/src/shared/ui/index.ts
@@ -0,0 +1,21 @@
+export * from './alert'
+export * from './badge'
+export * from './button'
+export * from './card'
+export * from './checkbox'
+export * from './command'
+export * from './data-table'
+export * from './dialog'
+export * from './dropdown-menu'
+export * from './dropdown-menu'
+export * from './form'
+export * from './input'
+export * from './pagination'
+export * from './picker'
+export * from './pin-input'
+export * from './popover'
+export * from './select'
+export * from './shimmer-button'
+export * from './table'
+export * from './tabs'
+export * from './tags-input'
diff --git a/apps/client/src/shared/ui/input/UiInput.stories.ts b/apps/client/src/shared/ui/input/UiInput.stories.ts
new file mode 100644
index 00000000..2a360bd4
--- /dev/null
+++ b/apps/client/src/shared/ui/input/UiInput.stories.ts
@@ -0,0 +1,32 @@
+import UiInput from './UiInput.vue'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiInput',
+ component: UiInput,
+ argTypes: {
+ modelValue: {
+ control: { type: 'text' },
+ },
+ type: {
+ control: { type: 'select' },
+ options: ['text', 'password', 'number', 'email'],
+ defaultValue: 'text',
+ },
+ placeholder: {
+ control: { type: 'text' },
+ },
+ },
+} as Meta
+
+const Template: StoryFn = (args) => {
+ return {
+ components: { UiInput },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }
+}
+
+export const DefaultInput: StoryFn = Template.bind({})
diff --git a/apps/client/src/shared/ui/input/UiInput.vue b/apps/client/src/shared/ui/input/UiInput.vue
new file mode 100644
index 00000000..9a707443
--- /dev/null
+++ b/apps/client/src/shared/ui/input/UiInput.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/input/__tests__/UiInput.spec.ts b/apps/client/src/shared/ui/input/__tests__/UiInput.spec.ts
new file mode 100644
index 00000000..5f69466c
--- /dev/null
+++ b/apps/client/src/shared/ui/input/__tests__/UiInput.spec.ts
@@ -0,0 +1,23 @@
+import { shallowMount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import UiInput from '../UiInput.vue'
+
+describe('tests for UiInput', () => {
+ const wrapper = shallowMount(UiInput, {
+ props: {
+ modelValue: 'value',
+ placeholder: 'jenda',
+ type: 'text',
+ },
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('should update value with @input', async () => {
+ const input = wrapper.find('input')
+ await input.setValue('new')
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual(['new'])
+ })
+})
diff --git a/apps/client/src/shared/ui/input/__tests__/__snapshots__/UiInput.spec.ts.snap b/apps/client/src/shared/ui/input/__tests__/__snapshots__/UiInput.spec.ts.snap
new file mode 100644
index 00000000..11d9a0c9
--- /dev/null
+++ b/apps/client/src/shared/ui/input/__tests__/__snapshots__/UiInput.spec.ts.snap
@@ -0,0 +1,3 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for UiInput > should render correctly 1`] = `""`;
diff --git a/apps/client/src/shared/ui/input/index.ts b/apps/client/src/shared/ui/input/index.ts
new file mode 100644
index 00000000..ad141f55
--- /dev/null
+++ b/apps/client/src/shared/ui/input/index.ts
@@ -0,0 +1 @@
+export { default as UiInput } from './UiInput.vue'
diff --git a/apps/client/src/shared/ui/pagination/UiPaginationEllipsis.vue b/apps/client/src/shared/ui/pagination/UiPaginationEllipsis.vue
new file mode 100644
index 00000000..cb70cb97
--- /dev/null
+++ b/apps/client/src/shared/ui/pagination/UiPaginationEllipsis.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pagination/UiPaginationFirst.vue b/apps/client/src/shared/ui/pagination/UiPaginationFirst.vue
new file mode 100644
index 00000000..f7bccedb
--- /dev/null
+++ b/apps/client/src/shared/ui/pagination/UiPaginationFirst.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pagination/UiPaginationLast.vue b/apps/client/src/shared/ui/pagination/UiPaginationLast.vue
new file mode 100644
index 00000000..a965a69d
--- /dev/null
+++ b/apps/client/src/shared/ui/pagination/UiPaginationLast.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pagination/UiPaginationNext.vue b/apps/client/src/shared/ui/pagination/UiPaginationNext.vue
new file mode 100644
index 00000000..a79cc127
--- /dev/null
+++ b/apps/client/src/shared/ui/pagination/UiPaginationNext.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pagination/UiPaginationPrev.vue b/apps/client/src/shared/ui/pagination/UiPaginationPrev.vue
new file mode 100644
index 00000000..96a7b2a6
--- /dev/null
+++ b/apps/client/src/shared/ui/pagination/UiPaginationPrev.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pagination/__tests__/MockComponent.vue b/apps/client/src/shared/ui/pagination/__tests__/MockComponent.vue
new file mode 100644
index 00000000..1c63e03b
--- /dev/null
+++ b/apps/client/src/shared/ui/pagination/__tests__/MockComponent.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pagination/__tests__/UiPagination.spec.ts b/apps/client/src/shared/ui/pagination/__tests__/UiPagination.spec.ts
new file mode 100644
index 00000000..6d0e3ae3
--- /dev/null
+++ b/apps/client/src/shared/ui/pagination/__tests__/UiPagination.spec.ts
@@ -0,0 +1,26 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import MockComponent from './MockComponent.vue'
+
+describe('tests for UiPagination', () => {
+ const wrapper = mount(MockComponent, {
+ props: {
+ total: 50,
+ itemsPerPage: 10,
+ page: 1,
+ },
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('should emit update:page when page is changed to + 1', async () => {
+ const nextBtn = wrapper.findComponent({ name: 'UiPaginationNext' })
+ await nextBtn.trigger('click')
+ expect(wrapper.emitted('update:page')).toBeTruthy()
+ expect(wrapper.emitted('update:page')?.[0]).toEqual([2])
+ })
+})
+
+// more tests: https://github.com/unovue/radix-vue/blob/main/packages/radix-vue/src/Pagination/Pagination.test.ts
diff --git a/apps/client/src/shared/ui/pagination/__tests__/__snapshots__/UiPagination.spec.ts.snap b/apps/client/src/shared/ui/pagination/__tests__/__snapshots__/UiPagination.spec.ts.snap
new file mode 100644
index 00000000..d41039e5
--- /dev/null
+++ b/apps/client/src/shared/ui/pagination/__tests__/__snapshots__/UiPagination.spec.ts.snap
@@ -0,0 +1,15 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for UiPagination > should render correctly 1`] = `
+""
+`;
diff --git a/apps/client/src/shared/ui/pagination/index.ts b/apps/client/src/shared/ui/pagination/index.ts
new file mode 100644
index 00000000..86bc4a3d
--- /dev/null
+++ b/apps/client/src/shared/ui/pagination/index.ts
@@ -0,0 +1,10 @@
+export { default as UiPaginationEllipsis } from './UiPaginationEllipsis.vue'
+export { default as UiPaginationFirst } from './UiPaginationFirst.vue'
+export { default as UiPaginationLast } from './UiPaginationLast.vue'
+export { default as UiPaginationNext } from './UiPaginationNext.vue'
+export { default as UiPaginationPrev } from './UiPaginationPrev.vue'
+export {
+ PaginationRoot as UiPagination,
+ PaginationList as UiPaginationList,
+ PaginationListItem as UiPaginationListItem,
+} from 'radix-vue'
diff --git a/apps/client/src/shared/ui/picker/UiColorPicker.vue b/apps/client/src/shared/ui/picker/UiColorPicker.vue
new file mode 100644
index 00000000..7e788c49
--- /dev/null
+++ b/apps/client/src/shared/ui/picker/UiColorPicker.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('picker.tabs', 1) }}
+
+
+ {{ $t('picker.tabs', 2) }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/picker/index.ts b/apps/client/src/shared/ui/picker/index.ts
new file mode 100644
index 00000000..d91bd0ec
--- /dev/null
+++ b/apps/client/src/shared/ui/picker/index.ts
@@ -0,0 +1 @@
+export { default as UiColorPicker } from './UiColorPicker.vue'
diff --git a/apps/client/src/shared/ui/pin-input/UiPinInput.stories.ts b/apps/client/src/shared/ui/pin-input/UiPinInput.stories.ts
new file mode 100644
index 00000000..953f4912
--- /dev/null
+++ b/apps/client/src/shared/ui/pin-input/UiPinInput.stories.ts
@@ -0,0 +1,71 @@
+import {
+ UiPinInput,
+ UiPinInputGroup,
+ UiPinInputInput,
+ UiPinInputSeparator,
+} from '..'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiPinInput',
+ component: UiPinInput,
+ argTypes: {
+ modelValue: {
+ control: { type: 'text' },
+ },
+ placeholder: {
+ control: { type: 'text' },
+ },
+ },
+} as Meta
+
+const Template: StoryFn = (args) => {
+ return {
+ components: { UiPinInput, UiPinInputGroup, UiPinInputInput, UiPinInputSeparator },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ ${args.default}
+
+ `,
+ }
+}
+
+export const DefaultPinInput: StoryFn = Template.bind({})
+
+DefaultPinInput.args = {
+ default: `
+
+
+
+ `,
+ placeholder: '○',
+}
+
+export const PinInputWithSeparator: StoryFn = Template.bind({})
+
+PinInputWithSeparator.args = {
+ default: `
+
+
+
+
+
+
+
+
+ `,
+ placeholder: '',
+}
diff --git a/apps/client/src/shared/ui/pin-input/UiPinInput.vue b/apps/client/src/shared/ui/pin-input/UiPinInput.vue
new file mode 100644
index 00000000..47e9d465
--- /dev/null
+++ b/apps/client/src/shared/ui/pin-input/UiPinInput.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pin-input/UiPinInputGroup.vue b/apps/client/src/shared/ui/pin-input/UiPinInputGroup.vue
new file mode 100644
index 00000000..08963a7f
--- /dev/null
+++ b/apps/client/src/shared/ui/pin-input/UiPinInputGroup.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pin-input/UiPinInputInput.vue b/apps/client/src/shared/ui/pin-input/UiPinInputInput.vue
new file mode 100644
index 00000000..6677c08e
--- /dev/null
+++ b/apps/client/src/shared/ui/pin-input/UiPinInputInput.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pin-input/UiPinInputSeparator.vue b/apps/client/src/shared/ui/pin-input/UiPinInputSeparator.vue
new file mode 100644
index 00000000..42681824
--- /dev/null
+++ b/apps/client/src/shared/ui/pin-input/UiPinInputSeparator.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pin-input/__tests__/MockComponent.vue b/apps/client/src/shared/ui/pin-input/__tests__/MockComponent.vue
new file mode 100644
index 00000000..9103d8dd
--- /dev/null
+++ b/apps/client/src/shared/ui/pin-input/__tests__/MockComponent.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/pin-input/__tests__/UiPinInput.spec.ts b/apps/client/src/shared/ui/pin-input/__tests__/UiPinInput.spec.ts
new file mode 100644
index 00000000..d1e59785
--- /dev/null
+++ b/apps/client/src/shared/ui/pin-input/__tests__/UiPinInput.spec.ts
@@ -0,0 +1,30 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import UiPinInput from './MockComponent.vue'
+
+describe('tests for UiPinInput', () => {
+ const wrapper = mount(UiPinInput, {
+ props: {
+ placeholder: '○',
+ type: 'text',
+ modelValue: ['', '', '', '', ''],
+ },
+ })
+ it('should render correctly', () => {
+ expect(wrapper.html()).toMatchSnapshot()
+ })
+
+ it('should emit the correct event on input', async () => {
+ wrapper.vm.$emit('update:modelValue', ['1', '2', '3'])
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['1', '2', '3']])
+ })
+
+ it('should emit "complete" event', async () => {
+ const inputs = wrapper.findAll('.pin-input-input')
+ for (let i = 0; i < inputs.length; i++) {
+ await inputs[i].setValue((i + 1).toString())
+ }
+ expect(wrapper.emitted('complete')).toBeTruthy()
+ expect(wrapper.emitted('complete')?.[0]).toEqual([['1', '2', '3', '4', '5']])
+ })
+})
diff --git a/apps/client/src/shared/ui/pin-input/__tests__/__snapshots__/UiPinInput.spec.ts.snap b/apps/client/src/shared/ui/pin-input/__tests__/__snapshots__/UiPinInput.spec.ts.snap
new file mode 100644
index 00000000..2f73e493
--- /dev/null
+++ b/apps/client/src/shared/ui/pin-input/__tests__/__snapshots__/UiPinInput.spec.ts.snap
@@ -0,0 +1,6 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`tests for UiPinInput > should render correctly 1`] = `
+"
+"
+`;
diff --git a/apps/client/src/shared/ui/pin-input/index.ts b/apps/client/src/shared/ui/pin-input/index.ts
new file mode 100644
index 00000000..5a4144d7
--- /dev/null
+++ b/apps/client/src/shared/ui/pin-input/index.ts
@@ -0,0 +1,4 @@
+export { default as UiPinInput } from './UiPinInput.vue'
+export { default as UiPinInputGroup } from './UiPinInputGroup.vue'
+export { default as UiPinInputInput } from './UiPinInputInput.vue'
+export { default as UiPinInputSeparator } from './UiPinInputSeparator.vue'
diff --git a/apps/client/src/shared/ui/popover/UiPopover.stories.ts b/apps/client/src/shared/ui/popover/UiPopover.stories.ts
new file mode 100644
index 00000000..328cbdb3
--- /dev/null
+++ b/apps/client/src/shared/ui/popover/UiPopover.stories.ts
@@ -0,0 +1,39 @@
+import { PopoverRoot } from 'radix-vue'
+import { UiButton } from '../button'
+import {
+ UiPopover,
+ UiPopoverContent,
+ UiPopoverTrigger,
+} from './index'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiPopover',
+ component: UiPopover,
+} as Meta
+
+const Template: StoryFn = args => ({
+ components: {
+ PopoverRoot,
+ UiPopover,
+ UiPopoverContent,
+ UiPopoverTrigger,
+ UiButton,
+ },
+ setup() {
+ return { args }
+ },
+ template: `
+
+
+
+ Open
+
+
+ hello
+
+
+
`,
+})
+
+export const DefaultPopover: StoryFn = Template.bind({})
diff --git a/apps/client/src/shared/ui/popover/UiPopover.vue b/apps/client/src/shared/ui/popover/UiPopover.vue
new file mode 100644
index 00000000..1a5873a3
--- /dev/null
+++ b/apps/client/src/shared/ui/popover/UiPopover.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/popover/UiPopoverContent.vue b/apps/client/src/shared/ui/popover/UiPopoverContent.vue
new file mode 100644
index 00000000..f2e53bc8
--- /dev/null
+++ b/apps/client/src/shared/ui/popover/UiPopoverContent.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/popover/UiPopoverTrigger.vue b/apps/client/src/shared/ui/popover/UiPopoverTrigger.vue
new file mode 100644
index 00000000..22f4772a
--- /dev/null
+++ b/apps/client/src/shared/ui/popover/UiPopoverTrigger.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/popover/__tests__/MockComponent.vue b/apps/client/src/shared/ui/popover/__tests__/MockComponent.vue
new file mode 100644
index 00000000..2a254820
--- /dev/null
+++ b/apps/client/src/shared/ui/popover/__tests__/MockComponent.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Open popover
+
+
+ Some popover content
+
+
+
diff --git a/apps/client/src/shared/ui/popover/__tests__/UiPopover.spec.ts b/apps/client/src/shared/ui/popover/__tests__/UiPopover.spec.ts
new file mode 100644
index 00000000..115c20e0
--- /dev/null
+++ b/apps/client/src/shared/ui/popover/__tests__/UiPopover.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import MockComponent from './MockComponent.vue'
+
+describe('tests for UiPopover', () => {
+ const wrapper = mount(MockComponent, {
+ props: {
+ defaultOpen: true,
+ modal: false,
+ },
+ })
+ it('should render correctly', () => {
+ expect(wrapper.html())
+ })
+
+ it('should pass props to PopoverRoot', () => {
+ const root = wrapper.findComponent({ name: 'PopoverRoot' })
+ expect(root.props('defaultOpen')).toBe(true)
+ expect(root.props('modal')).toBe(false)
+ })
+
+ it('should emit events correctly', () => {
+ wrapper.vm.$emit('openChange', true)
+ expect(wrapper.emitted('openChange')).toBeTruthy()
+ expect(wrapper.emitted('openChange')?.[0]).toEqual([true])
+ })
+})
+
+// more tests: https://github.com/unovue/radix-vue/blob/main/packages/radix-vue/src/Popover/Popover.test.ts
diff --git a/apps/client/src/shared/ui/popover/index.ts b/apps/client/src/shared/ui/popover/index.ts
new file mode 100644
index 00000000..c9459bf9
--- /dev/null
+++ b/apps/client/src/shared/ui/popover/index.ts
@@ -0,0 +1,4 @@
+export { default as UiPopover } from './UiPopover.vue'
+export { default as UiPopoverContent } from './UiPopoverContent.vue'
+export { default as UiPopoverTrigger } from './UiPopoverTrigger.vue'
+export { PopoverAnchor } from 'radix-vue'
diff --git a/apps/client/src/shared/ui/select/UiSelect.stories.ts b/apps/client/src/shared/ui/select/UiSelect.stories.ts
new file mode 100644
index 00000000..ce580526
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelect.stories.ts
@@ -0,0 +1,49 @@
+import { UiButton } from '../button'
+import {
+ UiSelect,
+ UiSelectContent,
+ UiSelectGroup,
+ UiSelectItem,
+ UiSelectLabel,
+ UiSelectTrigger,
+ UiSelectValue,
+} from './index'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiSelect',
+ component: UiSelect,
+} as Meta
+
+const Template: StoryFn = args => ({
+ components: {
+ UiSelect,
+ UiSelectContent,
+ UiSelectGroup,
+ UiSelectItem,
+ UiSelectLabel,
+ UiSelectTrigger,
+ UiSelectValue,
+ UiButton,
+ },
+ setup() {
+ return { args }
+ },
+ template: `
+
+
+
+
+
+
+ Fruits
+
+ Apple
+
+
+
+
+
`,
+})
+
+export const DefaultSelect: StoryFn = Template.bind({})
diff --git a/apps/client/src/shared/ui/select/UiSelect.vue b/apps/client/src/shared/ui/select/UiSelect.vue
new file mode 100644
index 00000000..46597625
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelect.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectContent.vue b/apps/client/src/shared/ui/select/UiSelectContent.vue
new file mode 100644
index 00000000..5b3ec53a
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectContent.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectGroup.vue b/apps/client/src/shared/ui/select/UiSelectGroup.vue
new file mode 100644
index 00000000..dcc2e66e
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectGroup.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectItem.vue b/apps/client/src/shared/ui/select/UiSelectItem.vue
new file mode 100644
index 00000000..33a68fc9
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectItem.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectItemText.vue b/apps/client/src/shared/ui/select/UiSelectItemText.vue
new file mode 100644
index 00000000..a0bb5c24
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectItemText.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectLabel.vue b/apps/client/src/shared/ui/select/UiSelectLabel.vue
new file mode 100644
index 00000000..4f136e90
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectLabel.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectScrollDownButton.vue b/apps/client/src/shared/ui/select/UiSelectScrollDownButton.vue
new file mode 100644
index 00000000..1835d4ef
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectScrollDownButton.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectScrollUpButton.vue b/apps/client/src/shared/ui/select/UiSelectScrollUpButton.vue
new file mode 100644
index 00000000..256319d3
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectScrollUpButton.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectSeparator.vue b/apps/client/src/shared/ui/select/UiSelectSeparator.vue
new file mode 100644
index 00000000..a472251a
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectSeparator.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectTrigger.vue b/apps/client/src/shared/ui/select/UiSelectTrigger.vue
new file mode 100644
index 00000000..8af5e166
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectTrigger.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/UiSelectValue.vue b/apps/client/src/shared/ui/select/UiSelectValue.vue
new file mode 100644
index 00000000..4bc37dd8
--- /dev/null
+++ b/apps/client/src/shared/ui/select/UiSelectValue.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/select/__tests__/UiSelect.spec.ts b/apps/client/src/shared/ui/select/__tests__/UiSelect.spec.ts
new file mode 100644
index 00000000..fb05aa51
--- /dev/null
+++ b/apps/client/src/shared/ui/select/__tests__/UiSelect.spec.ts
@@ -0,0 +1,27 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import UiSelect from '../UiSelect.vue'
+
+describe('tests for UiSelect', () => {
+ const wrapper = mount(UiSelect, {
+ slots: { default: 'test select
' },
+ props: { defaultOpen: true },
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html()).toContain('test select
')
+ })
+
+ it('should pass defaultOpen prop to DropdownMenuRoot', () => {
+ const root = wrapper.findComponent({ name: 'SelectRoot' })
+ expect(root.props('defaultOpen')).toBe(true)
+ })
+
+ it('should emit events correctly', () => {
+ wrapper.vm.$emit('openChange', true)
+ expect(wrapper.emitted('openChange')).toBeTruthy()
+ expect(wrapper.emitted('openChange')?.[0]).toEqual([true])
+ })
+})
+
+// more tests: https://github.com/unovue/radix-vue/blob/main/packages/radix-vue/src/Select/Select.test.ts
diff --git a/apps/client/src/shared/ui/select/index.ts b/apps/client/src/shared/ui/select/index.ts
new file mode 100644
index 00000000..a75c0864
--- /dev/null
+++ b/apps/client/src/shared/ui/select/index.ts
@@ -0,0 +1,11 @@
+export { default as UiSelect } from './UiSelect.vue'
+export { default as UiSelectContent } from './UiSelectContent.vue'
+export { default as UiSelectGroup } from './UiSelectGroup.vue'
+export { default as UiSelectItem } from './UiSelectItem.vue'
+export { default as UiSelectItemText } from './UiSelectItemText.vue'
+export { default as UiSelectLabel } from './UiSelectLabel.vue'
+export { default as UiSelectScrollDownButton } from './UiSelectScrollDownButton.vue'
+export { default as UiSelectScrollUpButton } from './UiSelectScrollUpButton.vue'
+export { default as UiSelectSeparator } from './UiSelectSeparator.vue'
+export { default as UiSelectTrigger } from './UiSelectTrigger.vue'
+export { default as UiSelectValue } from './UiSelectValue.vue'
diff --git a/apps/client/src/shared/ui/shimmer-button/ShimmerButton.vue b/apps/client/src/shared/ui/shimmer-button/ShimmerButton.vue
new file mode 100644
index 00000000..a65d03ca
--- /dev/null
+++ b/apps/client/src/shared/ui/shimmer-button/ShimmerButton.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/shimmer-button/index.ts b/apps/client/src/shared/ui/shimmer-button/index.ts
new file mode 100644
index 00000000..b0d44c77
--- /dev/null
+++ b/apps/client/src/shared/ui/shimmer-button/index.ts
@@ -0,0 +1 @@
+export { default as ShimmerButton } from './ShimmerButton.vue'
diff --git a/apps/client/src/shared/ui/shortcuts.ts b/apps/client/src/shared/ui/shortcuts.ts
new file mode 100644
index 00000000..b7c5a7c4
--- /dev/null
+++ b/apps/client/src/shared/ui/shortcuts.ts
@@ -0,0 +1 @@
+export * from './_shortcuts'
diff --git a/apps/client/src/shared/ui/table/UiTableBody.vue b/apps/client/src/shared/ui/table/UiTableBody.vue
new file mode 100644
index 00000000..90b26390
--- /dev/null
+++ b/apps/client/src/shared/ui/table/UiTableBody.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/table/UiTableCaption.vue b/apps/client/src/shared/ui/table/UiTableCaption.vue
new file mode 100644
index 00000000..7cbd8333
--- /dev/null
+++ b/apps/client/src/shared/ui/table/UiTableCaption.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/table/UiTableCell.vue b/apps/client/src/shared/ui/table/UiTableCell.vue
new file mode 100644
index 00000000..25b5d129
--- /dev/null
+++ b/apps/client/src/shared/ui/table/UiTableCell.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+ |
+
+
+
diff --git a/apps/client/src/shared/ui/table/UiTableEmpty.vue b/apps/client/src/shared/ui/table/UiTableEmpty.vue
new file mode 100644
index 00000000..ea357620
--- /dev/null
+++ b/apps/client/src/shared/ui/table/UiTableEmpty.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/table/UiTableHead.vue b/apps/client/src/shared/ui/table/UiTableHead.vue
new file mode 100644
index 00000000..20359f6e
--- /dev/null
+++ b/apps/client/src/shared/ui/table/UiTableHead.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+ |
+
diff --git a/apps/client/src/shared/ui/table/UiTableHeader.vue b/apps/client/src/shared/ui/table/UiTableHeader.vue
new file mode 100644
index 00000000..80e16a0c
--- /dev/null
+++ b/apps/client/src/shared/ui/table/UiTableHeader.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/table/UiTableRoot.vue b/apps/client/src/shared/ui/table/UiTableRoot.vue
new file mode 100644
index 00000000..b7c0a875
--- /dev/null
+++ b/apps/client/src/shared/ui/table/UiTableRoot.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/table/UiTableRow.vue b/apps/client/src/shared/ui/table/UiTableRow.vue
new file mode 100644
index 00000000..5289c538
--- /dev/null
+++ b/apps/client/src/shared/ui/table/UiTableRow.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/table/index.ts b/apps/client/src/shared/ui/table/index.ts
new file mode 100644
index 00000000..ba90d281
--- /dev/null
+++ b/apps/client/src/shared/ui/table/index.ts
@@ -0,0 +1,9 @@
+// GLOBAL: we used all in DataTable from @/shared/ui/data-table
+export { default as UiTableBody } from './UiTableBody.vue'
+export { default as UiTableCaption } from './UiTableCaption.vue'
+export { default as UiTableCell } from './UiTableCell.vue'
+export { default as UiTableEmpty } from './UiTableEmpty.vue'
+export { default as UiTableHead } from './UiTableHead.vue'
+export { default as UiTableHeader } from './UiTableHeader.vue'
+export { default as UiTableRoot } from './UiTableRoot.vue'
+export { default as UiTableRow } from './UiTableRow.vue'
diff --git a/apps/client/src/shared/ui/tabs/UiTabs.stories.ts b/apps/client/src/shared/ui/tabs/UiTabs.stories.ts
new file mode 100644
index 00000000..6c9827a7
--- /dev/null
+++ b/apps/client/src/shared/ui/tabs/UiTabs.stories.ts
@@ -0,0 +1,44 @@
+import {
+ UiTabs,
+ UiTabsContent,
+ UiTabsList,
+ UiTabsTrigger,
+} from './index'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiTabs',
+ component: UiTabs,
+} as Meta
+
+const Template: StoryFn = args => ({
+ components: {
+ UiTabs,
+ UiTabsContent,
+ UiTabsList,
+ UiTabsTrigger,
+ },
+ setup() {
+ return { args }
+ },
+ template: `
+
+
+
+ Account
+
+
+ Password
+
+
+
+ Make changes to your account here.
+
+
+ Change your password here.
+
+
+ `,
+})
+
+export const DefaultTabs: StoryFn = Template.bind({})
diff --git a/apps/client/src/shared/ui/tabs/UiTabs.vue b/apps/client/src/shared/ui/tabs/UiTabs.vue
new file mode 100644
index 00000000..2fa0971f
--- /dev/null
+++ b/apps/client/src/shared/ui/tabs/UiTabs.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tabs/UiTabsContent.vue b/apps/client/src/shared/ui/tabs/UiTabsContent.vue
new file mode 100644
index 00000000..ebe0fab3
--- /dev/null
+++ b/apps/client/src/shared/ui/tabs/UiTabsContent.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tabs/UiTabsList.vue b/apps/client/src/shared/ui/tabs/UiTabsList.vue
new file mode 100644
index 00000000..f527410d
--- /dev/null
+++ b/apps/client/src/shared/ui/tabs/UiTabsList.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tabs/UiTabsTrigger.vue b/apps/client/src/shared/ui/tabs/UiTabsTrigger.vue
new file mode 100644
index 00000000..20016690
--- /dev/null
+++ b/apps/client/src/shared/ui/tabs/UiTabsTrigger.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tabs/__tests__/MockComponent.vue b/apps/client/src/shared/ui/tabs/__tests__/MockComponent.vue
new file mode 100644
index 00000000..380f2c97
--- /dev/null
+++ b/apps/client/src/shared/ui/tabs/__tests__/MockComponent.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ Test 1
+
+
+ Test 2
+
+
+
+ test 1 content
+
+
+ test 2 content
+
+
+
diff --git a/apps/client/src/shared/ui/tabs/__tests__/UiTabs.spec.ts b/apps/client/src/shared/ui/tabs/__tests__/UiTabs.spec.ts
new file mode 100644
index 00000000..0d64d9e4
--- /dev/null
+++ b/apps/client/src/shared/ui/tabs/__tests__/UiTabs.spec.ts
@@ -0,0 +1,37 @@
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it } from 'vitest'
+import MockComponent from './MockComponent.vue'
+
+describe('tests for UiTabs', () => {
+ const wrapper = mount(MockComponent, {
+ props: {
+ defaultValue: 'test1',
+ },
+ })
+
+ it('should render correctly', () => {
+ expect(wrapper.html())
+ })
+
+ it('should render default content', () => {
+ expect(wrapper.find('[role=tabpanel]').exists()).toBeTruthy()
+ expect(wrapper.html()).toContain('test 1 content')
+ })
+
+ describe('should move focus to next tab', () => {
+ beforeEach(() => {
+ const trigger = wrapper.find('#radix-vue-tabs-v-0-trigger-test2')
+ trigger.trigger('keydown', { key: 'ArrowRight' })
+ })
+
+ it('should focus on next tab', () => {
+ expect(wrapper.find('[role=tabpanel]').exists()).toBeTruthy()
+ expect(wrapper.html()).toContain('Test 2')
+ })
+ })
+
+ it('should apply props correctly', async () => {
+ expect(wrapper.props().defaultValue).toBe('test1')
+ expect(wrapper.props().class).toBeUndefined()
+ })
+})
diff --git a/apps/client/src/shared/ui/tabs/index.ts b/apps/client/src/shared/ui/tabs/index.ts
new file mode 100644
index 00000000..fa70d45c
--- /dev/null
+++ b/apps/client/src/shared/ui/tabs/index.ts
@@ -0,0 +1,4 @@
+export { default as UiTabs } from './UiTabs.vue'
+export { default as UiTabsContent } from './UiTabsContent.vue'
+export { default as UiTabsList } from './UiTabsList.vue'
+export { default as UiTabsTrigger } from './UiTabsTrigger.vue'
diff --git a/apps/client/src/shared/ui/tags-input/UiTagsInput.stories.ts b/apps/client/src/shared/ui/tags-input/UiTagsInput.stories.ts
new file mode 100644
index 00000000..0d456a30
--- /dev/null
+++ b/apps/client/src/shared/ui/tags-input/UiTagsInput.stories.ts
@@ -0,0 +1,61 @@
+import {
+ UiTagsInput,
+ UiTagsInputInput,
+ UiTagsInputItem,
+ UiTagsInputItemDelete,
+ UiTagsInputItemText,
+} from '..'
+import type { Meta, StoryFn } from '@storybook/vue3'
+
+export default {
+ title: 'UiTagsInput',
+ component: UiTagsInput,
+ argTypes: {
+ modelValue: {
+ control: 'object',
+ },
+ disabled: {
+ control: { type: 'boolean' },
+ },
+ },
+} as Meta
+
+const Template: StoryFn = (args) => {
+ return {
+ components: {
+ UiTagsInput,
+ UiTagsInputInput,
+ UiTagsInputItem,
+ UiTagsInputItemDelete,
+ UiTagsInputItemText,
+ },
+ setup() {
+ return { args }
+ },
+ template: `
+
+
+
+
+
+
+
+
+ `,
+ }
+}
+
+export const DefaultTagsInput: StoryFn = Template.bind({})
+
+DefaultTagsInput.args = {
+ modelValue: ['test', 'test2'],
+}
+
+export const DisabledTagsInput: StoryFn = Template.bind({})
+
+DisabledTagsInput.args = {
+ disabled: true,
+ modelValue: ['test', 'test2'],
+}
diff --git a/apps/client/src/shared/ui/tags-input/UiTagsInput.vue b/apps/client/src/shared/ui/tags-input/UiTagsInput.vue
new file mode 100644
index 00000000..bff04c79
--- /dev/null
+++ b/apps/client/src/shared/ui/tags-input/UiTagsInput.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tags-input/UiTagsInputInput.vue b/apps/client/src/shared/ui/tags-input/UiTagsInputInput.vue
new file mode 100644
index 00000000..00b2ec54
--- /dev/null
+++ b/apps/client/src/shared/ui/tags-input/UiTagsInputInput.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tags-input/UiTagsInputItem.vue b/apps/client/src/shared/ui/tags-input/UiTagsInputItem.vue
new file mode 100644
index 00000000..b5349521
--- /dev/null
+++ b/apps/client/src/shared/ui/tags-input/UiTagsInputItem.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tags-input/UiTagsInputItemDelete.vue b/apps/client/src/shared/ui/tags-input/UiTagsInputItemDelete.vue
new file mode 100644
index 00000000..cda10ddc
--- /dev/null
+++ b/apps/client/src/shared/ui/tags-input/UiTagsInputItemDelete.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tags-input/UiTagsInputItemText.vue b/apps/client/src/shared/ui/tags-input/UiTagsInputItemText.vue
new file mode 100644
index 00000000..3260cbbf
--- /dev/null
+++ b/apps/client/src/shared/ui/tags-input/UiTagsInputItemText.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tags-input/__tests__/MockComponent.vue b/apps/client/src/shared/ui/tags-input/__tests__/MockComponent.vue
new file mode 100644
index 00000000..768b35e3
--- /dev/null
+++ b/apps/client/src/shared/ui/tags-input/__tests__/MockComponent.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/shared/ui/tags-input/__tests__/UiTagsInput.spec.ts b/apps/client/src/shared/ui/tags-input/__tests__/UiTagsInput.spec.ts
new file mode 100644
index 00000000..09486e8e
--- /dev/null
+++ b/apps/client/src/shared/ui/tags-input/__tests__/UiTagsInput.spec.ts
@@ -0,0 +1,44 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import MockComponent from './MockComponent.vue'
+import type { DOMWrapper } from '@vue/test-utils'
+
+describe('tests for UiTagsInput', () => {
+ const wrapper = mount(MockComponent, {
+ props: {
+ modelValue: ['value1', 'value2'],
+ },
+ })
+
+ let input: DOMWrapper
+ let tags: DOMWrapper[]
+
+ const addTag = async (text: string) => {
+ await input.setValue(text)
+ await input.trigger('keydown.enter')
+ tags = wrapper.findAll('[data-radix-vue-collection-item]')
+ }
+
+ it('should render correctly', () => {
+ expect(wrapper.html())
+ })
+
+ it('should emit the correct event on input', async () => {
+ wrapper.vm.$emit('update:modelValue', ['1', '2', '3'])
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['1', '2', '3']])
+ })
+
+ describe('add tag', () => {
+ beforeEach(async () => {
+ input = wrapper.find('input')
+ input.element.focus()
+ await addTag('test1')
+ await addTag('test2')
+ })
+
+ it('should add a new tag', () => {
+ expect(wrapper.html()).contains('test1')
+ expect(wrapper.html()).contains('test2')
+ })
+ })
+})
diff --git a/apps/client/src/shared/ui/tags-input/index.ts b/apps/client/src/shared/ui/tags-input/index.ts
new file mode 100644
index 00000000..623d5449
--- /dev/null
+++ b/apps/client/src/shared/ui/tags-input/index.ts
@@ -0,0 +1,5 @@
+export { default as UiTagsInput } from './UiTagsInput.vue'
+export { default as UiTagsInputInput } from './UiTagsInputInput.vue'
+export { default as UiTagsInputItem } from './UiTagsInputItem.vue'
+export { default as UiTagsInputItemDelete } from './UiTagsInputItemDelete.vue'
+export { default as UiTagsInputItemText } from './UiTagsInputItemText.vue'
diff --git a/apps/client/src/store/index.ts b/apps/client/src/store/index.ts
new file mode 100644
index 00000000..c569ef7f
--- /dev/null
+++ b/apps/client/src/store/index.ts
@@ -0,0 +1,3 @@
+import { createPinia } from 'pinia'
+
+export const pinia = createPinia()
diff --git a/apps/client/src/styles/_base.css b/apps/client/src/styles/_base.css
new file mode 100644
index 00000000..898bad19
--- /dev/null
+++ b/apps/client/src/styles/_base.css
@@ -0,0 +1,55 @@
+*,
+*::before,
+*::after {
+ padding: 0;
+ margin: 0;
+ box-sizing: border-box;
+}
+
+html {
+ height: 100%;
+ min-height: 100%;
+ text-size-adjust: 100%;
+}
+
+.dark {
+ background-color: var(--custom-jenda-dark);
+ color-scheme: dark;
+}
+
+body {
+ height: 100%;
+ width: 100%;
+ font-family: Geist, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ overscroll-behavior: none;
+}
+
+.ui-container {
+ width: 100%;
+ max-width: 1600px;
+ margin-right: auto;
+ margin-left: auto;
+ padding-right: 20px;
+ padding-left: 20px;
+}
+
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation: none;
+ mix-blend-mode: normal;
+}
+
+.dropdown-enter-active,
+.dropdown-leave-active {
+ transition:
+ opacity 0.15s ease,
+ transform 0.15s ease;
+}
+
+.dropdown-enter-from,
+.dropdown-leave-to {
+ opacity: 0;
+ transform: translateY(-10px);
+}
diff --git a/apps/client/src/styles/_exceptions.css b/apps/client/src/styles/_exceptions.css
new file mode 100644
index 00000000..e2b43f9a
--- /dev/null
+++ b/apps/client/src/styles/_exceptions.css
@@ -0,0 +1,60 @@
+.v-popper__inner {
+ font-size: 12px;
+ padding: 4px 10px !important;
+}
+
+.v-popper__arrow-container {
+ display: none !important;
+}
+
+canvas {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+/* tr,
+thead {
+ clip-path: xywh(0 0 100% 100% round 0.5em);
+} */
+
+.splitpanes--vertical .splitpanes__pane {
+ transition: none;
+}
+
+.splitpanes--vertical > .splitpanes__splitter {
+ display: none;
+}
+
+[data-sonner-toaster][data-theme='dark'] {
+ --normal-bg: var(--custom-jenda-dark) !important;
+ --normal-border: hsl(0deg 0% 20%);
+ --normal-text: var(--gray1);
+ --success-border: hsl(147deg 100% 12%);
+ --info-bg: var(--custom-jenda-dark);
+}
+
+.vue-ui-kpi {
+ background: transparent !important;
+}
+
+.vue-ui-kpi-value {
+ @apply text-blue-500;
+}
+
+.vue-data-ui-fulscreen--off {
+ circle {
+ fill: #fff !important;
+ }
+}
+
+html.dark {
+ .v-popper__inner {
+ @apply bg-neutral-100 text-neutral-800;
+ }
+
+ .vue-data-ui-fulscreen--off {
+ circle {
+ fill: #313131 !important;
+ }
+ }
+}
diff --git a/apps/client/src/styles/_families.css b/apps/client/src/styles/_families.css
new file mode 100644
index 00000000..18b211ac
--- /dev/null
+++ b/apps/client/src/styles/_families.css
@@ -0,0 +1,3 @@
+* {
+ font-family: Geist, sans-serif;
+}
diff --git a/apps/client/src/styles/_fonts.css b/apps/client/src/styles/_fonts.css
new file mode 100644
index 00000000..0defed18
--- /dev/null
+++ b/apps/client/src/styles/_fonts.css
@@ -0,0 +1,6 @@
+@font-face {
+ font-family: Geist;
+ font-style: normal;
+ font-display: swap;
+ src: url('../shared/assets/fonts/geist/GeistVariableVF.woff2') format('woff2');
+}
diff --git a/apps/client/src/styles/_properties.css b/apps/client/src/styles/_properties.css
new file mode 100644
index 00000000..b93a57c5
--- /dev/null
+++ b/apps/client/src/styles/_properties.css
@@ -0,0 +1,8 @@
+:root {
+ /* priorities */
+ --low-color: 34, 197, 94;
+ --medium-color: 245, 158, 11;
+ --high-color: 239, 68, 68;
+
+ --custom-jenda-dark: rgb(38 38 38);
+}
diff --git a/apps/client/src/styles/_scrollbar.css b/apps/client/src/styles/_scrollbar.css
new file mode 100644
index 00000000..8ff69384
--- /dev/null
+++ b/apps/client/src/styles/_scrollbar.css
@@ -0,0 +1,13 @@
+::-webkit-scrollbar {
+ background-color: transparent;
+ height: 0.6em;
+ width: 0.6em;
+}
+
+::-webkit-scrollbar-track {
+ @apply bg-neutral-100 dark:bg-neutral-700/20;
+}
+
+::-webkit-scrollbar-thumb {
+ @apply bg-neutral-300 rounded-xl dark:bg-neutral-700;
+}
diff --git a/apps/client/src/styles/index.css b/apps/client/src/styles/index.css
new file mode 100644
index 00000000..d3c98fc9
--- /dev/null
+++ b/apps/client/src/styles/index.css
@@ -0,0 +1,6 @@
+@import './_properties';
+@import './_fonts';
+@import './_base';
+@import './_exceptions';
+@import './_families';
+@import './_scrollbar';
diff --git a/apps/client/tsconfig.app.json b/apps/client/tsconfig.app.json
new file mode 100644
index 00000000..35da1403
--- /dev/null
+++ b/apps/client/tsconfig.app.json
@@ -0,0 +1,14 @@
+{
+ "extends": "@vue/tsconfig/tsconfig.dom.json",
+ "compilerOptions": {
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "baseUrl": ".",
+ "moduleResolution": "Bundler",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "./typed-router.d.ts"],
+ "exclude": ["src/**/__tests__/*"]
+}
diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json
new file mode 100644
index 00000000..e44f9a16
--- /dev/null
+++ b/apps/client/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ },
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.vitest.json"
+ }
+ ],
+ "files": []
+}
diff --git a/apps/client/tsconfig.node.json b/apps/client/tsconfig.node.json
new file mode 100644
index 00000000..0fa71eed
--- /dev/null
+++ b/apps/client/tsconfig.node.json
@@ -0,0 +1,19 @@
+{
+ "extends": "@tsconfig/node20/tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "types": ["node"],
+ "noEmit": true
+ },
+ "include": [
+ "vite.config.*",
+ "vitest.config.*",
+ "cypress.config.*",
+ "nightwatch.conf.*",
+ "playwright.config.*"
+ ]
+}
diff --git a/apps/client/tsconfig.vitest.json b/apps/client/tsconfig.vitest.json
new file mode 100644
index 00000000..79721714
--- /dev/null
+++ b/apps/client/tsconfig.vitest.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.app.json",
+ "compilerOptions": {
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
+
+ "lib": [],
+ "types": ["node", "jsdom"]
+ },
+ "exclude": []
+}
diff --git a/apps/client/typed-router.d.ts b/apps/client/typed-router.d.ts
new file mode 100644
index 00000000..e064a3ae
--- /dev/null
+++ b/apps/client/typed-router.d.ts
@@ -0,0 +1,38 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
+// It's recommended to commit this file.
+// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
+
+declare module 'vue-router/auto-routes' {
+ import type {
+ RouteRecordInfo,
+ ParamValue,
+ ParamValueOneOrMore,
+ ParamValueZeroOrMore,
+ ParamValueZeroOrOne,
+ } from 'vue-router'
+
+ /**
+ * Route name map generated by unplugin-vue-router
+ */
+ export interface RouteNamedMap {
+ 'welcome': RouteRecordInfo<'welcome', '/', Record, Record>,
+ 'not-found': RouteRecordInfo<'not-found', '/:path(.*)', { path: ParamValue }, { path: ParamValue }>,
+ 'analytics': RouteRecordInfo<'analytics', '/analytics', Record, Record>,
+ 'sign-in': RouteRecordInfo<'sign-in', '/auth/sign-in', Record, Record>,
+ 'sign-in-workspace': RouteRecordInfo<'sign-in-workspace', '/auth/sign-in/workspace', Record, Record>,
+ 'sign-up': RouteRecordInfo<'sign-up', '/auth/sign-up', Record, Record>,
+ 'confirm': RouteRecordInfo<'confirm', '/auth/sign-up/confirm', Record, Record>,
+ 'sign-up-workspace': RouteRecordInfo<'sign-up-workspace', '/auth/sign-up/workspace', Record, Record>,
+ 'boards': RouteRecordInfo<'boards', '/boards', Record, Record>,
+ 'board-id': RouteRecordInfo<'board-id', '/boards/:id', { id: ParamValue }, { id: ParamValue }>,
+ 'boards-new': RouteRecordInfo<'boards-new', '/boards/new', Record, Record>,
+ 'members': RouteRecordInfo<'members', '/members', Record, Record>,
+ 'notes': RouteRecordInfo<'notes', '/notes', Record, Record>,
+ 'settings': RouteRecordInfo<'settings', '/settings', Record, Record>,
+ 'templates': RouteRecordInfo<'templates', '/templates', Record, Record>,
+ 'community': RouteRecordInfo<'community', '/templates/community', Record, Record>,
+ }
+}
diff --git a/apps/client/uno.config.ts b/apps/client/uno.config.ts
new file mode 100644
index 00000000..54b88819
--- /dev/null
+++ b/apps/client/uno.config.ts
@@ -0,0 +1,88 @@
+import {
+ defineConfig,
+ presetAttributify,
+ presetIcons,
+ presetTypography,
+ presetUno,
+ transformerDirectives,
+ transformerVariantGroup,
+} from 'unocss'
+import presetAnimations from 'unocss-preset-animations'
+import { presetShadcn } from 'unocss-preset-shadcn'
+import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders'
+import presetJendaUI from './src/shared/libs/unocss/presets/presetUiKit'
+
+export default defineConfig({
+ presets: [
+ presetUno(),
+ presetAttributify({
+ ignoreAttributes: [
+ 'size',
+ 'variant',
+ ],
+ }),
+ presetAnimations(),
+ presetShadcn({ color: 'neutral' }, false),
+ presetIcons({
+ extraProperties: {
+ 'display': 'inline-block',
+ 'vertical-align': 'middle',
+ },
+ collections: {
+ lucid: () => import('@iconify-json/lucide/icons.json').then(i => i.default),
+ huge: () => import('@iconify-json/hugeicons/icons.json').then(i => i.default),
+ jenda: FileSystemIconLoader('./src/shared/assets/icons/custom-jenda'),
+ },
+ }),
+ presetJendaUI(),
+ presetTypography(),
+ ],
+ shortcuts: [
+ {
+ 'auth-page': 'h-full flex w-68% max-[1440px]:w-80% max-[1100px]:!w-full',
+ 'auth-slot-container': 'relative h-full w-full mx-auto px-2rem dark:bg-#1c1c1c',
+ 'form-container': 'relative flex w-460px flex-col gap-2 max-[520px]:!w-full max-[1100px]:!w-460px max-[1200px]:w-360px',
+ },
+ {
+ 'bg-main': 'bg-white dark:bg-#262626',
+ 'border-layout': 'border-neutral-200 dark:border-#1c1c1c',
+ 'bg-sidebar': 'bg-neutral-50 dark:bg-#1c1c1c66',
+ },
+ ],
+ configDeps: [
+ './src/shared/lib/unocss/presets/presetUiKit.ts',
+
+ './src/shared/ui/_shortcuts/button.ts',
+ './src/shared/ui/_shortcuts/badge.ts',
+ './src/shared/ui/_shortcuts/input.ts',
+ './src/shared/ui/_shortcuts/alert.ts',
+ './src/shared/ui/_shortcuts/dropdown-menu.ts',
+ './src/shared/ui/_shortcuts/select.ts',
+ './src/shared/ui/_shortcuts/dialog.ts',
+ './src/shared/ui/_shortcuts/pin-input.ts',
+ './src/shared/ui/_shortcuts/form.ts',
+ './src/shared/ui/_shortcuts/popover.ts',
+ './src/shared/ui/_shortcuts/picker.ts',
+ './src/shared/ui/_shortcuts/command.ts',
+ './src/shared/ui/_shortcuts/tabs.ts',
+ './src/shared/ui/_shortcuts/pagination.ts',
+ './src/shared/ui/_shortcuts/checkbox.ts',
+ './src/shared/ui/_shortcuts/table.ts',
+ './src/shared/ui/_shortcuts/tags-input.ts',
+ './src/shared/ui/_shortcuts/index.ts',
+ ],
+ content: {
+ pipeline: {
+ include: [
+ /\.(vue|mdx?|html)($|\?)/,
+ '(components|src)/**/*.{js,ts}',
+ 'src/**/*.stories.{js,ts}',
+ 'stories/**/*',
+ ],
+ },
+ },
+ transformers: [
+ transformerDirectives(),
+ transformerVariantGroup(),
+ ],
+})
diff --git a/apps/client/vercel.json b/apps/client/vercel.json
new file mode 100644
index 00000000..3a48e56b
--- /dev/null
+++ b/apps/client/vercel.json
@@ -0,0 +1,3 @@
+{
+ "rewrites": [{ "source": "/(.*)", "destination": "/" }]
+}
diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts
new file mode 100644
index 00000000..e82a921b
--- /dev/null
+++ b/apps/client/vite.config.ts
@@ -0,0 +1,61 @@
+import { fileURLToPath, URL } from 'node:url'
+import vue from '@vitejs/plugin-vue'
+import UnoCSS from 'unocss/vite'
+import { getNuxtStyleRouteName } from 'unplugin-vue-router-extend'
+import UnpluginVueRouterExtend from 'unplugin-vue-router-extend/vite'
+import UnpluginVueRouter from 'unplugin-vue-router/vite'
+import { defineConfig } from 'vite'
+import vueDevTools from 'vite-plugin-vue-devtools'
+// import Sonda from 'sonda/vite'
+import type { TreeNode } from 'unplugin-vue-router'
+
+const routeMap = new Map()
+export default defineConfig({
+ plugins: [
+ UnpluginVueRouter({
+ getRouteName: (node: TreeNode) => {
+ if (!routeMap.size) {
+ for (const [key, value] of (node.parent as any)?.map)
+ routeMap.set(key, value)
+ }
+ return getNuxtStyleRouteName(node)
+ },
+ routesFolder: [
+ './src/modules/auth/pages',
+ './src/modules/members/pages',
+ './src/modules/boards/pages',
+ './src/modules/workspace/pages',
+ './src/modules/notes/pages',
+ './src/modules/settings/pages',
+ './src/modules/templates/pages',
+ './src/modules/charts/pages',
+ './src/modules/welcome/pages',
+ './src/core/pages',
+ ],
+ dts: './typed-router.d.ts',
+ }),
+ UnpluginVueRouterExtend({
+ routeMap,
+ }),
+ vueDevTools(),
+ UnoCSS(),
+ // Sonda(),
+ vue(),
+ ],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ build: {
+ // only with sonda active
+ // sourcemap: true,
+ },
+ css: {
+ preprocessorOptions: {
+ scss: {
+ api: 'modern-compiler',
+ },
+ },
+ },
+})
diff --git a/apps/client/vitest.config.ts b/apps/client/vitest.config.ts
new file mode 100644
index 00000000..d18ade7e
--- /dev/null
+++ b/apps/client/vitest.config.ts
@@ -0,0 +1,20 @@
+import { fileURLToPath } from 'node:url'
+import { configDefaults, defineConfig, mergeConfig } from 'vitest/config'
+import viteConfig from './vite.config'
+
+export default mergeConfig(
+ viteConfig,
+ defineConfig({
+ test: {
+ environment: 'jsdom',
+ exclude: [...configDefaults.exclude, 'e2e/**'],
+ setupFiles: ['./src/shared/libs/vitest-utils/cookiesI18n-mock.ts'],
+ root: fileURLToPath(new URL('./', import.meta.url)),
+ css: {
+ modules: {
+ classNameStrategy: 'non-scoped',
+ },
+ },
+ },
+ }),
+)
diff --git a/apps/server/.gitkeep b/apps/server/.gitkeep
new file mode 100644
index 00000000..e69de29b