initial commit
This commit is contained in:
commit
ae93c7e1a6
233 changed files with 20282 additions and 0 deletions
18
frontend/.editorconfig
Normal file
18
frontend/.editorconfig
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml,js,ts,tsx,vue,css}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
26
frontend/.gitignore
vendored
Normal file
26
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
23
frontend/components.json
Normal file
23
frontend/components.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"base": "reka",
|
||||
"font": "inter",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/assets/css/app.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BitHarbor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="min-h-dvh flex flex-col"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2515
frontend/package-lock.json
generated
Normal file
2515
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"reka-ui": "^2.9.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vue3-marquee": "^4.2.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.4",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
27
frontend/public/hero-bg.svg
Normal file
27
frontend/public/hero-bg.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="1200" viewBox="0 0 2400 1200" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="glowA" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(420 180) rotate(18) scale(860 500)">
|
||||
<stop stop-color="#22D3EE" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#22D3EE" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glowB" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1930 930) rotate(-14) scale(980 560)">
|
||||
<stop stop-color="#06B6D4" stop-opacity="0.28"/>
|
||||
<stop offset="1" stop-color="#06B6D4" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="wave" x1="130" y1="220" x2="2190" y2="960" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0EA5E9" stop-opacity="0.18"/>
|
||||
<stop offset="0.55" stop-color="#06B6D4" stop-opacity="0.12"/>
|
||||
<stop offset="1" stop-color="#22D3EE" stop-opacity="0.08"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" width="88" height="88" patternUnits="userSpaceOnUse">
|
||||
<path d="M88 0H0V88" stroke="#0891B2" stroke-opacity="0.08"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="2400" height="1200" fill="url(#grid)"/>
|
||||
<circle cx="420" cy="180" r="700" fill="url(#glowA)"/>
|
||||
<circle cx="1930" cy="930" r="760" fill="url(#glowB)"/>
|
||||
|
||||
<path d="M-140 930C220 760 620 850 930 720C1240 590 1630 330 2050 360C2240 374 2410 430 2540 498" stroke="url(#wave)" stroke-width="128" stroke-linecap="round"/>
|
||||
<path d="M-70 1060C280 900 620 980 950 880C1260 786 1640 600 2010 640C2220 662 2360 722 2490 796" stroke="#0E7490" stroke-opacity="0.12" stroke-width="72" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
24
frontend/src/App.vue
Normal file
24
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import Header from '@/layout/partials/Header.vue';
|
||||
import Footer from './layout/partials/Footer.vue';
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
|
||||
const { initialize } = useAuth();
|
||||
|
||||
onMounted(() => {
|
||||
void initialize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Header />
|
||||
|
||||
<div class="grow">
|
||||
<main class="container mx-auto my-8">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</template>
|
||||
185
frontend/src/api/auth.ts
Normal file
185
frontend/src/api/auth.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { apiCall, csrf } from './base';
|
||||
|
||||
export type LoginPayload = {
|
||||
email: string;
|
||||
password: string;
|
||||
remember?: boolean;
|
||||
};
|
||||
|
||||
export type RegisterPayload = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
};
|
||||
|
||||
export type ForgotPasswordPayload = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type ResetPasswordPayload = {
|
||||
token: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const login = async (payload: LoginPayload): Promise<void> => {
|
||||
await csrf();
|
||||
|
||||
const response = await apiCall('auth/login', {
|
||||
method: 'POST',
|
||||
withCsrf: true,
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = `Login failed (${response.status})`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (typeof data?.message === 'string') {
|
||||
message = data.message;
|
||||
} else if (data?.errors && typeof data.errors === 'object') {
|
||||
const firstError = Object.values<string[]>(data.errors)[0]?.[0];
|
||||
if (firstError) {
|
||||
message = firstError;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parsing issues
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
export const register = async (payload: RegisterPayload): Promise<void> => {
|
||||
await csrf();
|
||||
|
||||
const response = await apiCall('/auth/register', {
|
||||
method: 'POST',
|
||||
withCsrf: true,
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = `Register failed (${response.status})`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (typeof data?.message === 'string') {
|
||||
message = data.message;
|
||||
} else if (data?.errors && typeof data.errors === 'object') {
|
||||
const firstError = Object.values<string[]>(data.errors)[0]?.[0];
|
||||
if (firstError) {
|
||||
message = firstError;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parsing issues
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
export const forgotPassword = async (payload: ForgotPasswordPayload): Promise<string> => {
|
||||
await csrf();
|
||||
|
||||
const response = await apiCall('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
withCsrf: true,
|
||||
body: payload,
|
||||
});
|
||||
|
||||
let data: { message?: string; errors?: Record<string, string[]> } | null = null;
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return data?.message ?? 'Password reset link sent.';
|
||||
}
|
||||
|
||||
let message = `Request failed (${response.status})`;
|
||||
if (typeof data?.message === 'string') {
|
||||
message = data.message;
|
||||
} else if (data?.errors && typeof data.errors === 'object') {
|
||||
const firstError = Object.values<string[]>(data.errors)[0]?.[0];
|
||||
if (firstError) {
|
||||
message = firstError;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
export const resetPassword = async (payload: ResetPasswordPayload): Promise<string> => {
|
||||
await csrf();
|
||||
|
||||
const response = await apiCall('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
withCsrf: true,
|
||||
body: payload,
|
||||
});
|
||||
|
||||
let data: { message?: string; errors?: Record<string, string[]> } | null = null;
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return data?.message ?? 'Your password has been reset.';
|
||||
}
|
||||
|
||||
let message = `Request failed (${response.status})`;
|
||||
if (typeof data?.message === 'string') {
|
||||
message = data.message;
|
||||
} else if (data?.errors && typeof data.errors === 'object') {
|
||||
const firstError = Object.values<string[]>(data.errors)[0]?.[0];
|
||||
if (firstError) {
|
||||
message = firstError;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
const response = await apiCall('/auth/logout', {
|
||||
method: 'POST',
|
||||
withCsrf: true,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Logout failed (${response.status})`);
|
||||
}
|
||||
};
|
||||
|
||||
export const getCurrentUser = async (): Promise<AuthUser | null> => {
|
||||
const response = await apiCall('/user');
|
||||
|
||||
if (response.status === 401) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch current user (${response.status})`);
|
||||
}
|
||||
|
||||
return await response.json() as AuthUser;
|
||||
};
|
||||
41
frontend/src/api/base.ts
Normal file
41
frontend/src/api/base.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export const getApiBaseUrl = () => import.meta.env.VITE_API_BASE_URL ?? 'http://localhost';
|
||||
|
||||
const getCookie = (name: string): string | null => {
|
||||
const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]*)`));
|
||||
const value = match?.[2];
|
||||
|
||||
return value ? decodeURIComponent(value) : null;
|
||||
};
|
||||
|
||||
type ApiCallOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
withCsrf?: boolean;
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
|
||||
export const apiCall = async (path: string, options: ApiCallOptions = {}): Promise<Response> => {
|
||||
const url = new URL(path, getApiBaseUrl());
|
||||
const csrfToken = options.withCsrf ? getCookie('XSRF-TOKEN') : null;
|
||||
const hasBody = options.body !== undefined;
|
||||
|
||||
return await fetch(url.toString(), {
|
||||
method: options.method ?? 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(csrfToken ? { 'X-XSRF-TOKEN': csrfToken } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
...(hasBody ? { body: JSON.stringify(options.body) } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
export const csrf = async () => {
|
||||
const response = await apiCall('/sanctum/csrf-cookie');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get CSRF cookie (${response.status})`);
|
||||
}
|
||||
};
|
||||
13
frontend/src/api/categories.ts
Normal file
13
frontend/src/api/categories.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { getApiBaseUrl } from './base';
|
||||
import type { CategoryCollection } from './types';
|
||||
|
||||
export const categories = async (): Promise<CategoryCollection> => {
|
||||
const url = new URL('/categories', getApiBaseUrl());
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<CategoryCollection>;
|
||||
};
|
||||
3
frontend/src/api/index.ts
Normal file
3
frontend/src/api/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './products.ts';
|
||||
export * from './categories.ts';
|
||||
export * from './auth.ts';
|
||||
28
frontend/src/api/products.ts
Normal file
28
frontend/src/api/products.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { getApiBaseUrl } from './base';
|
||||
import type { PaginatedResponse, Product } from './types';
|
||||
|
||||
export type SearchProductsParams = {
|
||||
query?: string;
|
||||
category?: string;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
export const products = async ({ query, category, page }: SearchProductsParams = {}): Promise<PaginatedResponse<Product>> => {
|
||||
const url = new URL('/products', getApiBaseUrl());
|
||||
if (query?.trim()) {
|
||||
url.searchParams.set('q', query.trim());
|
||||
}
|
||||
if (category?.trim()) {
|
||||
url.searchParams.set('category', category.trim());
|
||||
}
|
||||
if (page) {
|
||||
url.searchParams.set('page', page.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<PaginatedResponse<Product>>;
|
||||
};
|
||||
6
frontend/src/api/types/brand.d.ts
vendored
Normal file
6
frontend/src/api/types/brand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
export interface Brand {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
12
frontend/src/api/types/category.d.ts
vendored
Normal file
12
frontend/src/api/types/category.d.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { ResourceCollection } from ".";
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
parent_id: number | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
products_count?: number;
|
||||
}
|
||||
|
||||
export type CategoryCollection = ResourceCollection<Category>
|
||||
4
frontend/src/api/types/index.d.ts
vendored
Normal file
4
frontend/src/api/types/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./resource.d.ts";
|
||||
export * from "./product.d.ts";
|
||||
export * from "./category.d.ts";
|
||||
export * from "./brand.d.ts";
|
||||
24
frontend/src/api/types/product.d.ts
vendored
Normal file
24
frontend/src/api/types/product.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type {
|
||||
Brand,
|
||||
Category,
|
||||
PaginatedResponse,
|
||||
ResourceCollection
|
||||
} from ".";
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
sku: string;
|
||||
short_description: string | null;
|
||||
description: string | null;
|
||||
price: string;
|
||||
stock_quantity: number;
|
||||
active: boolean;
|
||||
published_at: string | null;
|
||||
category?: Category;
|
||||
brand?: Brand;
|
||||
}
|
||||
|
||||
export type ProductsCollection = ResourceCollection<Product>
|
||||
export type PaginatedProductsCollection = PaginatedResponse<Product>
|
||||
28
frontend/src/api/types/resource.d.ts
vendored
Normal file
28
frontend/src/api/types/resource.d.ts
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
|
||||
export type ResourceCollection<T> = T[]
|
||||
|
||||
export interface PaginationMeta {
|
||||
current_page: number;
|
||||
from: number | null;
|
||||
last_page: number;
|
||||
links: Array<{
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}>;
|
||||
path: string;
|
||||
per_page: number;
|
||||
to: number | null;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
links: {
|
||||
first: string | null;
|
||||
last: string | null;
|
||||
prev: string | null;
|
||||
next: string | null;
|
||||
};
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
35
frontend/src/assets/css/app.css
Normal file
35
frontend/src/assets/css/app.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@import "./semantic.css";
|
||||
@import "./utility.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-50: var(--primary-50);
|
||||
--color-primary-100: var(--primary-100);
|
||||
--color-primary-200: var(--primary-200);
|
||||
--color-primary-300: var(--primary-300);
|
||||
--color-primary-400: var(--primary-400);
|
||||
--color-primary-500: var(--primary-500);
|
||||
--color-primary-600: var(--primary-600);
|
||||
--color-primary-700: var(--primary-700);
|
||||
--color-primary-800: var(--primary-800);
|
||||
--color-primary-900: var(--primary-900);
|
||||
--color-primary-950: var(--primary-950);
|
||||
--color-info: var(--primary);
|
||||
--color-success: var(--primary);
|
||||
--color-warning: var(--warning);
|
||||
--color-danger: var(--primary);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-default;
|
||||
}
|
||||
body {
|
||||
@apply bg-default text-default;
|
||||
}
|
||||
}
|
||||
107
frontend/src/assets/css/semantic.css
Normal file
107
frontend/src/assets/css/semantic.css
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
|
||||
:root {
|
||||
--primary-50: var(--color-cyan-50);
|
||||
--primary-100: var(--color-cyan-100);
|
||||
--primary-200: var(--color-cyan-200);
|
||||
--primary-300: var(--color-cyan-300);
|
||||
--primary-400: var(--color-cyan-400);
|
||||
--primary-500: var(--color-cyan-500);
|
||||
--primary-600: var(--color-cyan-600);
|
||||
--primary-700: var(--color-cyan-700);
|
||||
--primary-800: var(--color-cyan-800);
|
||||
--primary-900: var(--color-cyan-900);
|
||||
--primary-950: var(--color-cyan-950);
|
||||
|
||||
--bg: white;
|
||||
--bg-muted: var(--color-slate-50);
|
||||
--bg-elevated: var(--color-slate-100);
|
||||
--bg-accented: var(--color-slate-200);
|
||||
--bg-inverted: var(--color-slate-900);
|
||||
|
||||
--text: var(--color-slate-950);
|
||||
--text-muted: var(--color-slate-600);
|
||||
--text-inverted: var(--color-slate-50);
|
||||
|
||||
--primary: var(--primary-500);
|
||||
--primary-hover: var(--primary-600);
|
||||
--primary-active: var(--primary-700);
|
||||
--primary-contrast: var(--color-white);
|
||||
--primary-soft: var(--primary-100);
|
||||
--primary-soft-hover: var(--primary-200);
|
||||
--primary-soft-text: var(--primary-800);
|
||||
|
||||
--info: var(--color-blue-500);
|
||||
--success: var(--color-green-500);
|
||||
--warning: var(--color-yellow-500);
|
||||
--danger: var(--color-red-500);
|
||||
|
||||
--border: var(--color-slate-200);
|
||||
--border-strong: var(--color-slate-300);
|
||||
--border-accent: var(--primary-300);
|
||||
|
||||
--input: var(--bg-muted);
|
||||
--input-border: var(--border);
|
||||
--input-border-focus: var(--border-accent);
|
||||
|
||||
--hero-from: var(--primary-50);
|
||||
--hero-via: var(--color-white);
|
||||
--hero-to: var(--color-slate-100);
|
||||
--hero-overlay: linear-gradient(110deg, rgb(15 23 42 / 0.08), rgb(14 116 144 / 0.06));
|
||||
--hero-title: var(--color-slate-950);
|
||||
--hero-body: var(--color-slate-700);
|
||||
--hero-panel-bg: rgb(255 255 255 / 0.78);
|
||||
--hero-panel-border: var(--color-slate-200);
|
||||
|
||||
--feature-from: var(--color-white);
|
||||
--feature-to: var(--primary-50);
|
||||
--feature-border: var(--color-slate-200);
|
||||
|
||||
--badge-tech-bg: var(--primary-100);
|
||||
--badge-tech-border: var(--primary-300);
|
||||
--badge-tech-text: var(--primary-900);
|
||||
|
||||
--shadow-elevated: 0 24px 50px -36px rgb(14 116 144 / 0.45);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: var(--color-slate-950);
|
||||
--bg-muted: var(--color-slate-900);
|
||||
--bg-elevated: var(--color-slate-900);
|
||||
--bg-accented: var(--color-slate-800);
|
||||
--bg-inverted: var(--color-slate-50);
|
||||
|
||||
--text: var(--color-slate-100);
|
||||
--text-muted: var(--color-slate-400);
|
||||
--text-inverted: var(--color-slate-950);
|
||||
|
||||
--primary: var(--primary-400);
|
||||
--primary-hover: var(--primary-300);
|
||||
--primary-active: var(--primary-200);
|
||||
--primary-contrast: var(--primary-950);
|
||||
--primary-soft: rgb(6 182 212 / 0.22);
|
||||
--primary-soft-hover: rgb(34 211 238 / 0.3);
|
||||
--primary-soft-text: var(--primary-200);
|
||||
|
||||
--border: var(--color-slate-800);
|
||||
--border-strong: var(--color-slate-700);
|
||||
--border-accent: var(--primary-700);
|
||||
|
||||
--hero-from: var(--color-slate-950);
|
||||
--hero-via: var(--color-slate-900);
|
||||
--hero-to: var(--primary-950);
|
||||
--hero-overlay: linear-gradient(110deg, rgb(2 6 23 / 0.5), rgb(8 47 73 / 0.4));
|
||||
--hero-title: var(--color-slate-50);
|
||||
--hero-body: var(--color-slate-300);
|
||||
--hero-panel-bg: rgb(2 6 23 / 0.45);
|
||||
--hero-panel-border: var(--color-slate-700);
|
||||
|
||||
--feature-from: var(--color-slate-900);
|
||||
--feature-to: rgb(8 47 73 / 0.38);
|
||||
--feature-border: var(--color-slate-700);
|
||||
|
||||
--badge-tech-bg: rgb(8 145 178 / 0.2);
|
||||
--badge-tech-border: var(--primary-700);
|
||||
--badge-tech-text: var(--primary-200);
|
||||
|
||||
--shadow-elevated: 0 30px 65px -34px rgb(2 6 23 / 0.92);
|
||||
}
|
||||
92
frontend/src/assets/css/utility.css
Normal file
92
frontend/src/assets/css/utility.css
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
|
||||
@utility bg-default {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@utility bg-canvas {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@utility bg-muted {
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
@utility bg-elevated {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
@utility bg-primary {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
@utility bg-primary-soft {
|
||||
background-color: var(--primary-soft);
|
||||
}
|
||||
|
||||
@utility bg-hero {
|
||||
background-image: linear-gradient(140deg, var(--hero-from), var(--hero-via) 48%, var(--hero-to));
|
||||
}
|
||||
|
||||
@utility bg-marquee {
|
||||
background-image: linear-gradient(90deg, var(--marquee-from), var(--marquee-via), var(--marquee-to));
|
||||
}
|
||||
|
||||
@utility bg-accented {
|
||||
background: var(--bg-accented);
|
||||
}
|
||||
|
||||
@utility bg-inverted {
|
||||
background: var(--bg-inverted);
|
||||
}
|
||||
|
||||
@utility text-default {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@utility text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@utility text-primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@utility text-hero-title {
|
||||
color: var(--hero-title);
|
||||
}
|
||||
|
||||
@utility text-hero-body {
|
||||
color: var(--hero-body);
|
||||
}
|
||||
|
||||
@utility text-inverted {
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
@utility border-default {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
@utility border-strong {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
@utility border-accent {
|
||||
border-color: var(--border-accent);
|
||||
}
|
||||
|
||||
@utility bg-input {
|
||||
background-color: var(--input);
|
||||
}
|
||||
|
||||
@utility border-input {
|
||||
border-color: var(--input-border);
|
||||
}
|
||||
|
||||
@utility border-input-focus {
|
||||
border-color: var(--input-border-focus);
|
||||
}
|
||||
|
||||
@utility shadow-elevated {
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
45
frontend/src/components/Hero.vue
Normal file
45
frontend/src/components/Hero.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import HeroBackground from './HeroBackground.vue';
|
||||
|
||||
const heroStats = [
|
||||
{ label: 'Items in catalog', value: '12,000+' },
|
||||
{ label: 'Deal updates', value: 'Every 5 min' },
|
||||
{ label: 'Avg. support time', value: '< 3 min' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero-card relative overflow-hidden rounded-xs border border-accent bg-hero">
|
||||
<HeroBackground class="absolute inset-0" />
|
||||
|
||||
<div class="relative z-10 grid gap-6 p-6 md:grid-cols-[1.2fr,0.8fr] md:p-10">
|
||||
<Card simple class="space-y-5 border-0 bg-transparent py-8 md:py-12">
|
||||
<Badge variant="tech" padding="md">high voltage deals</Badge>
|
||||
|
||||
<h1 class="text-hero-title text-4xl leading-tight font-semibold md:text-6xl">
|
||||
Build your next setup with speed, style, and clear pricing.
|
||||
</h1>
|
||||
|
||||
<p class="text-hero-body max-w-2xl text-base md:text-lg">
|
||||
Browse laptops, components, and accessories in one place with lightning-fast filtering,
|
||||
practical specs, and live market pricing.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button :as="RouterLink" to="/products" class="w-fit">
|
||||
Start shopping
|
||||
</Button>
|
||||
|
||||
<Button :as="RouterLink" to="/products" variant="soft" class="w-fit">
|
||||
Explore hot builds
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
85
frontend/src/components/HeroBackground.vue
Normal file
85
frontend/src/components/HeroBackground.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<div class="bg-cyan-100 dark:bg-slate-950">
|
||||
<div class="hero-overlay absolute inset-0" />
|
||||
<div class="absolute -left-20 -top-16 h-56 w-56 rounded-full bg-primary-soft blur-3xl float-slow" />
|
||||
<div class="absolute -bottom-24 right-10 h-72 w-72 rounded-full bg-primary-soft blur-3xl float-fast" />
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2400 1200" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="glowA" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(420 180) rotate(18) scale(860 500)">
|
||||
<stop class="end" stop-opacity="0.4"/>
|
||||
<stop class="end" offset="0.85" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glowB" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1930 930) rotate(-14) scale(980 560)">
|
||||
<stop class="mid" stop-opacity="0.28"/>
|
||||
<stop class="mid" offset="0.9" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="wave" x1="130" y1="220" x2="2190" y2="960" gradientUnits="userSpaceOnUse">
|
||||
<stop class="start" stop-opacity="0.18"/>
|
||||
<stop class="mid" offset="0.55" stop-opacity="0.12"/>
|
||||
<stop class="end" offset="1" stop-opacity="0.08"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wave2" x1="130" y1="220" x2="2190" y2="960" gradientUnits="userSpaceOnUse">
|
||||
<stop class="start" stop-opacity="0.18"/>
|
||||
<stop class="mid" offset="0.55" stop-opacity="0.12"/>
|
||||
<stop class="end" offset="1" stop-opacity="0.08"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" x="-1" y="-1" width="88" height="88" patternUnits="userSpaceOnUse">
|
||||
<path d="M88 0H0V88" stroke-opacity="1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="2400" height="1200" fill="url(#grid)"/>
|
||||
|
||||
<circle cx="420" cy="180" r="700" fill="url(#glowA)"/>
|
||||
<circle cx="1930" cy="930" r="760" fill="url(#glowB)"/>
|
||||
|
||||
<path d="M-140 930C220 760 620 850 930 720C1240 590 1630 330 2050 360C2240 374 2410 430 2540 498" stroke="url(#wave)" stroke-width="128" stroke-linecap="round"/>
|
||||
<path d="M-70 1060C280 900 620 980 950 880C1260 786 1640 600 2010 640C2220 662 2360 722 2490 796" class="wave2" stroke-opacity="0.12" stroke-width="72" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
#grid {
|
||||
stroke: #00000020;
|
||||
}
|
||||
|
||||
.start {
|
||||
stop-color: var(--color-cyan-400);
|
||||
}
|
||||
|
||||
.mid {
|
||||
stop-color: var(--color-cyan-500);
|
||||
}
|
||||
|
||||
.end {
|
||||
stop-color: var(--color-cyan-600);
|
||||
}
|
||||
|
||||
.wave2 {
|
||||
stroke: var(--color-cyan-500);
|
||||
}
|
||||
|
||||
.dark #grid {
|
||||
stroke: #ffffff10;
|
||||
}
|
||||
|
||||
.dark .start {
|
||||
stop-color: var(--color-cyan-800);
|
||||
}
|
||||
|
||||
.dark .mid {
|
||||
stop-color: var(--color-cyan-900);
|
||||
}
|
||||
|
||||
.dark .end {
|
||||
stop-color: var(--color-cyan-950);
|
||||
}
|
||||
|
||||
.dark .wave2 {
|
||||
stroke: var(--color-cyan-600);
|
||||
}
|
||||
|
||||
</style>
|
||||
18
frontend/src/components/Link.vue
Normal file
18
frontend/src/components/Link.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Primitive, type PrimitiveProps } from 'reka-ui';
|
||||
|
||||
withDefaults(defineProps<PrimitiveProps>(), {
|
||||
as: 'a'
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:class="cn('inline-block underline-offset-4 hover:underline')"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
23
frontend/src/components/ThemeSwitch.vue
Normal file
23
frontend/src/components/ThemeSwitch.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import { MoonIcon, SunIcon } from 'lucide-vue-next';
|
||||
import { Switch } from './ui/switch';
|
||||
|
||||
const mode = useColorMode({ disableTransition: false })
|
||||
|
||||
const isDark = computed({
|
||||
get: () => mode.value === 'dark',
|
||||
set: (value: boolean) => {
|
||||
mode.value = value ? 'dark' : 'light';
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="theme-switch"><SunIcon :size="18" /></label>
|
||||
<Switch id="theme-switch" v-model="isDark" />
|
||||
<label for="theme-switch"><MoonIcon :size="18" /></label>
|
||||
</div>
|
||||
</template>
|
||||
25
frontend/src/components/UserAvatar.vue
Normal file
25
frontend/src/components/UserAvatar.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { Avatar, AvatarFallback } from './ui/avatar';
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const getInitials = (fullName : string) => {
|
||||
const allNames = fullName.trim().split(' ');
|
||||
const initials = allNames.reduce((acc, curr, index) => {
|
||||
if(index === 0 || index === allNames.length - 1){
|
||||
acc = `${acc}${curr.charAt(0).toUpperCase()}`;
|
||||
}
|
||||
return acc;
|
||||
}, '');
|
||||
return initials;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Avatar>
|
||||
<AvatarFallback>{{ getInitials(name) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
</template>
|
||||
27
frontend/src/components/app-navigation/AppNavigation.vue
Normal file
27
frontend/src/components/app-navigation/AppNavigation.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import { mainNavigation } from '@/navigation/main-navigation';
|
||||
import { NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList } from '../ui/navigation-menu';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const isActive = (to: string) => route.path === to;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem
|
||||
v-for="item in mainNavigation"
|
||||
:key="item.to"
|
||||
>
|
||||
<NavigationMenuLink :as="RouterLink" :to="item.to"
|
||||
:active="isActive(item.to)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</template>
|
||||
32
frontend/src/components/app-navigation/UserMenu.vue
Normal file
32
frontend/src/components/app-navigation/UserMenu.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import LogoutButton from '../auth/LogoutButton.vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator
|
||||
} from '../ui/dropdown-menu';
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<UserAvatar :name="name" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled>My account</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>Settings</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogoutButton />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
16
frontend/src/components/auth/LogoutButton.vue
Normal file
16
frontend/src/components/auth/LogoutButton.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { useAuth } from '@/composables/useAuth';
|
||||
import { LogOutIcon } from 'lucide-vue-next';
|
||||
|
||||
const { isLoading, signOut } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="flex items-center gap-2" :disabled="isLoading" @click="handleLogout">
|
||||
<LogOutIcon /> Logout
|
||||
</button>
|
||||
</template>
|
||||
22
frontend/src/components/frontpage/FeaturedItem.vue
Normal file
22
frontend/src/components/frontpage/FeaturedItem.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
type: string
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card simple variant="feature" class="py-5">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold">{{ title }}</h2>
|
||||
<Badge variant="tech">{{ type }}</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted">
|
||||
<slot />
|
||||
</p>
|
||||
</Card>
|
||||
</template>
|
||||
30
frontend/src/components/products/CategorySidebar/Button.vue
Normal file
30
frontend/src/components/products/CategorySidebar/Button.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
active: boolean
|
||||
count?: number
|
||||
depth?: number
|
||||
}>(), {
|
||||
count: undefined,
|
||||
depth: 0
|
||||
})
|
||||
|
||||
const indentStyle = (depth:number) => `margin-left: ${depth}rem`
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-none border-l-4 border-l-transparent px-3 py-2 text-left text-sm hover:bg-accent"
|
||||
:class="{ 'border-l-primary!': active }"
|
||||
>
|
||||
<span class="truncate text-left text-sm" :style="indentStyle(depth)">
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
<span class="flex items-center">
|
||||
<span v-if="count" class="text-xs text-muted-foreground">{{ count }}</span>
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
78
frontend/src/components/products/CategorySidebar/Root.vue
Normal file
78
frontend/src/components/products/CategorySidebar/Root.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Category } from '@/api/types';
|
||||
import Button from './Button.vue';
|
||||
import TreeNode from './TreeNode.vue';
|
||||
import Tree from './Tree.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
categories: Category[];
|
||||
modelValue: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
type CategoryTreeNode = Category & {
|
||||
children: CategoryTreeNode[];
|
||||
};
|
||||
|
||||
const categoryTree = computed<CategoryTreeNode[]>(() => {
|
||||
const byId = new Map<number, CategoryTreeNode>();
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
|
||||
for (const category of props.categories) {
|
||||
byId.set(category.id, {
|
||||
...category,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const node of byId.values()) {
|
||||
if (node.parent_id === null) {
|
||||
roots.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = byId.get(node.parent_id);
|
||||
if (!parent) {
|
||||
roots.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
parent.children.push(node);
|
||||
}
|
||||
|
||||
return roots;
|
||||
});
|
||||
|
||||
const onSelect = (slug: string) => {
|
||||
if (props.modelValue === slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:modelValue', slug);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-muted">
|
||||
<Button
|
||||
:active="modelValue === ''"
|
||||
@click="onSelect('')"
|
||||
>
|
||||
All categories
|
||||
</Button>
|
||||
|
||||
<Tree class="border-t">
|
||||
<TreeNode
|
||||
v-for="category in categoryTree"
|
||||
:key="category.id"
|
||||
:node="category"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
</Tree>
|
||||
</nav>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<ul class="divide-y">
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
|
||||
import Button from './Button.vue';
|
||||
import Tree from './Tree.vue';
|
||||
|
||||
type CategoryTreeNode = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
products_count?: number;
|
||||
children: CategoryTreeNode[];
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
node: CategoryTreeNode;
|
||||
modelValue: string;
|
||||
depth?: number;
|
||||
}>(), {
|
||||
depth: 0
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const onSelect = (slug: string) => {
|
||||
if (props.modelValue === slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:modelValue', slug);
|
||||
};
|
||||
|
||||
const isActive = (node : CategoryTreeNode) => props.modelValue === node.slug
|
||||
const open = ref(false);
|
||||
|
||||
const onSelectAndToggle = (slug: string) => {
|
||||
onSelect(slug);
|
||||
open.value = !open.value;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<template v-if="node.children.length > 0">
|
||||
<Button
|
||||
@click="onSelectAndToggle(node.slug)"
|
||||
:active="isActive(node)"
|
||||
:depth="depth"
|
||||
>
|
||||
{{ node.name }}
|
||||
|
||||
<template v-slot:icon>
|
||||
<ChevronDown v-if="open" />
|
||||
<ChevronRight v-else />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Tree v-if="open" class="border-t">
|
||||
<TreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="onSelect"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</Tree>
|
||||
</template>
|
||||
|
||||
<Button v-else
|
||||
@click="onSelect(node.slug)"
|
||||
:active="isActive(node)"
|
||||
:depth="depth"
|
||||
>
|
||||
{{ node.name }}
|
||||
</Button>
|
||||
</li>
|
||||
</template>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as CategorySidebar } from "./Root.vue";
|
||||
25
frontend/src/components/products/ProductCard.vue
Normal file
25
frontend/src/components/products/ProductCard.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { Product } from '@/api/types';
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||
|
||||
defineProps<{
|
||||
item: Product
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card variant="soft">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ item.name }}</CardTitle>
|
||||
<CardDescription>${{ item.price }} - {{ item.stock_quantity }} st</CardDescription>
|
||||
<CardAction>
|
||||
In stock
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{{ item.description }}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
5
frontend/src/components/products/ProductGrid.vue
Normal file
5
frontend/src/components/products/ProductGrid.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
2
frontend/src/components/products/index.ts
Normal file
2
frontend/src/components/products/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ProductGrid } from './ProductGrid.vue';
|
||||
export { default as ProductCard } from './ProductCard.vue';
|
||||
18
frontend/src/components/ui/avatar/Avatar.vue
Normal file
18
frontend/src/components/ui/avatar/Avatar.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { AvatarRoot } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot
|
||||
data-slot="avatar"
|
||||
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
21
frontend/src/components/ui/avatar/AvatarFallback.vue
Normal file
21
frontend/src/components/ui/avatar/AvatarFallback.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { AvatarFallbackProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AvatarFallback } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarFallback
|
||||
data-slot="avatar-fallback"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AvatarFallback>
|
||||
</template>
|
||||
16
frontend/src/components/ui/avatar/AvatarImage.vue
Normal file
16
frontend/src/components/ui/avatar/AvatarImage.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import type { AvatarImageProps } from "reka-ui"
|
||||
import { AvatarImage } from "reka-ui"
|
||||
|
||||
const props = defineProps<AvatarImageProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarImage
|
||||
data-slot="avatar-image"
|
||||
v-bind="props"
|
||||
class="aspect-square size-full"
|
||||
>
|
||||
<slot />
|
||||
</AvatarImage>
|
||||
</template>
|
||||
3
frontend/src/components/ui/avatar/index.ts
Normal file
3
frontend/src/components/ui/avatar/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Avatar } from "./Avatar.vue"
|
||||
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
||||
export { default as AvatarImage } from "./AvatarImage.vue"
|
||||
27
frontend/src/components/ui/badge/Badge.vue
Normal file
27
frontend/src/components/ui/badge/Badge.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"]
|
||||
padding?: BadgeVariants["padding"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant, padding }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
26
frontend/src/components/ui/badge/index.ts
Normal file
26
frontend/src/components/ui/badge/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-xs border text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none aria-invalid:border-danger transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-inverted [a&]:hover:bg-primary/90",
|
||||
outline: "text-default [a&]:hover:bg-accented",
|
||||
tech: "uppercase tracking-[0.18em] border-[color:var(--badge-tech-border)] bg-[var(--badge-tech-bg)] text-[var(--badge-tech-text)]",
|
||||
},
|
||||
padding: {
|
||||
default: 'px-4 py-0.5',
|
||||
md: 'px-3 py-1'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
padding: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
22
frontend/src/components/ui/button-group/ButtonGroup.vue
Normal file
22
frontend/src/components/ui/button-group/ButtonGroup.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonGroupVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonGroupVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
orientation?: ButtonGroupVariants["orientation"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
:data-orientation="props.orientation"
|
||||
:class="cn(buttonGroupVariants({ orientation: props.orientation }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { SeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>(), {
|
||||
orientation: "vertical",
|
||||
})
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
v-bind="delegatedProps"
|
||||
:orientation="props.orientation"
|
||||
:class="cn(
|
||||
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
|
||||
props.class,
|
||||
)"
|
||||
/>
|
||||
</template>
|
||||
29
frontend/src/components/ui/button-group/ButtonGroupText.vue
Normal file
29
frontend/src/components/ui/button-group/ButtonGroupText.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonGroupVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
orientation?: ButtonGroupVariants["orientation"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "div",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
:data-orientation="props.orientation"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn('bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
25
frontend/src/components/ui/button-group/index.ts
Normal file
25
frontend/src/components/ui/button-group/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as ButtonGroup } from "./ButtonGroup.vue"
|
||||
export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue"
|
||||
export { default as ButtonGroupText } from "./ButtonGroupText.vue"
|
||||
|
||||
export const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-xs has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonGroupVariants = VariantProps<typeof buttonGroupVariants>
|
||||
31
frontend/src/components/ui/button/Button.vue
Normal file
31
frontend/src/components/ui/button/Button.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
33
frontend/src/components/ui/button/index.ts
Normal file
33
frontend/src/components/ui/button/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none aria-invalid:border-danger",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[var(--primary)] text-[var(--primary-contrast)] hover:bg-[var(--primary-hover)] active:bg-[var(--primary-active)]",
|
||||
secondary: 'border bg-[var(--bg-muted)] text-default hover:bg-[var(--bg-accented)]',
|
||||
outline: "border bg-default text-default hover:bg-muted",
|
||||
soft: "border border-[color:var(--border-accent)] bg-[var(--primary-soft)] text-[var(--primary-soft-text)] hover:bg-[var(--primary-soft-hover)]",
|
||||
ghost: "hover:bg-muted",
|
||||
link: "text-[var(--primary)] underline-offset-4 hover:text-[var(--primary-hover)] hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"sm": "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
21
frontend/src/components/ui/card/Card.vue
Normal file
21
frontend/src/components/ui/card/Card.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cardVariants, type CardVariants } from ".";
|
||||
|
||||
const props = defineProps<{
|
||||
simple?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: CardVariants["variant"]
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="cn(cardVariants({ variant }), props.class, simple ? 'px-6' : '')"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardAction.vue
Normal file
17
frontend/src/components/ui/card/CardAction.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardContent.vue
Normal file
17
frontend/src/components/ui/card/CardContent.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-content"
|
||||
:class="cn('px-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardDescription.vue
Normal file
17
frontend/src/components/ui/card/CardDescription.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardFooter.vue
Normal file
17
frontend/src/components/ui/card/CardFooter.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardHeader.vue
Normal file
17
frontend/src/components/ui/card/CardHeader.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardTitle.vue
Normal file
17
frontend/src/components/ui/card/CardTitle.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
28
frontend/src/components/ui/card/index.ts
Normal file
28
frontend/src/components/ui/card/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardAction } from "./CardAction.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
export { default as CardDescription } from "./CardDescription.vue"
|
||||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
|
||||
export const cardVariants = cva(
|
||||
"flex flex-col gap-6 rounded-xs py-6",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
outline: "border bg-default",
|
||||
soft: "border bg-muted",
|
||||
strong: "border bg-accented",
|
||||
hero: "bg-[var(--hero-panel-bg)] backdrop-blur-sm",
|
||||
feature: "bg-[linear-gradient(140deg,var(--feature-from),var(--feature-to))]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "outline",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type CardVariants = VariantProps<typeof cardVariants>
|
||||
19
frontend/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
19
frontend/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dropdown-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class=" cn(
|
||||
'focus:bg-accented focus:text-accented relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-muted data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xs border p-1 shadow-md', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuGroupProps } from "reka-ui"
|
||||
import { DropdownMenuGroup } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup
|
||||
data-slot="dropdown-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}>(), {
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('focus:bg-elevated data-[variant=destructive]:text-danger data-[variant=destructive]:focus:bg-danger/10 data-[variant=destructive]:*:[svg]:!text-danger [&_svg:not([class*=\'text-\'])]:text-muted relative flex cursor-default items-center gap-2 rounded-xs px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuLabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Circle } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accented relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Circle class="size-2 fill-current" />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
data-slot="dropdown-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('border-t -mx-1 my-1', props.class)"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
:class="cn('text-muted ml-auto text-xs tracking-widest', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
18
frontend/src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
18
frontend/src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuSubProps>()
|
||||
const emits = defineEmits<DropdownMenuSubEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-muted data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:class="cn(
|
||||
'focus:bg-accented data-[state=open]:bg-accented relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 data-[variant=destructive]:*:[svg]:!text-danger [&_svg:not([class*=\'text-\'])]:text-muted',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRight class="ml-auto size-4" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuTriggerProps } from "reka-ui"
|
||||
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
|
||||
|
||||
const props = defineProps<DropdownMenuTriggerProps>()
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
16
frontend/src/components/ui/dropdown-menu/index.ts
Normal file
16
frontend/src/components/ui/dropdown-menu/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export { default as DropdownMenu } from "./DropdownMenu.vue"
|
||||
|
||||
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
|
||||
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
|
||||
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
|
||||
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
|
||||
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
|
||||
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
|
||||
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
|
||||
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
|
||||
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
|
||||
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
|
||||
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
|
||||
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
|
||||
export { DropdownMenuPortal } from "reka-ui"
|
||||
20
frontend/src/components/ui/empty/Empty.vue
Normal file
20
frontend/src/components/ui/empty/Empty.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty"
|
||||
:class="cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-xs border-dashed p-6 text-center md:p-12',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
frontend/src/components/ui/empty/EmptyContent.vue
Normal file
20
frontend/src/components/ui/empty/EmptyContent.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
:class="cn(
|
||||
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
frontend/src/components/ui/empty/EmptyDescription.vue
Normal file
20
frontend/src/components/ui/empty/EmptyDescription.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="empty-description"
|
||||
:class="cn(
|
||||
'text-muted [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
|
||||
$attrs.class ?? '',
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
20
frontend/src/components/ui/empty/EmptyHeader.vue
Normal file
20
frontend/src/components/ui/empty/EmptyHeader.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
:class="cn(
|
||||
'flex max-w-sm flex-col items-center gap-2 text-center',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
frontend/src/components/ui/empty/EmptyMedia.vue
Normal file
21
frontend/src/components/ui/empty/EmptyMedia.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { EmptyMediaVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { emptyMediaVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: EmptyMediaVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
:data-variant="variant"
|
||||
:class="cn(emptyMediaVariants({ variant }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/empty/EmptyTitle.vue
Normal file
17
frontend/src/components/ui/empty/EmptyTitle.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
:class="cn('text-lg font-medium tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
26
frontend/src/components/ui/empty/index.ts
Normal file
26
frontend/src/components/ui/empty/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Empty } from "./Empty.vue"
|
||||
export { default as EmptyContent } from "./EmptyContent.vue"
|
||||
export { default as EmptyDescription } from "./EmptyDescription.vue"
|
||||
export { default as EmptyHeader } from "./EmptyHeader.vue"
|
||||
export { default as EmptyMedia } from "./EmptyMedia.vue"
|
||||
export { default as EmptyTitle } from "./EmptyTitle.vue"
|
||||
|
||||
export const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-default flex size-10 shrink-0 items-center justify-center rounded-xs [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants>
|
||||
25
frontend/src/components/ui/field/Field.vue
Normal file
25
frontend/src/components/ui/field/Field.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { FieldVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fieldVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
orientation?: FieldVariants["orientation"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
:data-orientation="orientation"
|
||||
:class="cn(
|
||||
fieldVariants({ orientation }),
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
frontend/src/components/ui/field/FieldContent.vue
Normal file
20
frontend/src/components/ui/field/FieldContent.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-content"
|
||||
:class="cn(
|
||||
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
frontend/src/components/ui/field/FieldDescription.vue
Normal file
22
frontend/src/components/ui/field/FieldDescription.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="field-description"
|
||||
:class="cn(
|
||||
'text-muted text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
53
frontend/src/components/ui/field/FieldError.vue
Normal file
53
frontend/src/components/ui/field/FieldError.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { computed } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
errors?: Array<string | { message: string | undefined } | undefined>
|
||||
}>()
|
||||
|
||||
const content = computed(() => {
|
||||
if (!props.errors || props.errors.length === 0)
|
||||
return null
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(
|
||||
props.errors
|
||||
.filter(Boolean)
|
||||
.map((error) => {
|
||||
const message = typeof error === "string" ? error : error?.message
|
||||
return [message, error]
|
||||
}),
|
||||
).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
|
||||
return typeof uniqueErrors[0] === "string" ? uniqueErrors[0] : uniqueErrors[0].message
|
||||
}
|
||||
|
||||
return uniqueErrors.map(error => typeof error === "string" ? error : error?.message)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="$slots.default || content"
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
:class="cn('text-danger text-sm font-normal', props.class)"
|
||||
>
|
||||
<slot v-if="$slots.default" />
|
||||
|
||||
<template v-else-if="typeof content === 'string'">
|
||||
{{ content }}
|
||||
</template>
|
||||
|
||||
<ul v-else-if="Array.isArray(content)" class="ml-4 flex list-disc flex-col gap-1">
|
||||
<li v-for="(error, index) in content" :key="index">
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
20
frontend/src/components/ui/field/FieldGroup.vue
Normal file
20
frontend/src/components/ui/field/FieldGroup.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-group"
|
||||
:class="cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
frontend/src/components/ui/field/FieldLabel.vue
Normal file
23
frontend/src/components/ui/field/FieldLabel.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
:class="cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-xs has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||
'has-data-[state=checked]:bg-primary-soft has-data-[state=checked]:border-primary',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
24
frontend/src/components/ui/field/FieldLegend.vue
Normal file
24
frontend/src/components/ui/field/FieldLegend.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: "legend" | "label"
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
:data-variant="variant"
|
||||
:class="cn(
|
||||
'mb-3 font-medium',
|
||||
'data-[variant=legend]:text-base',
|
||||
'data-[variant=label]:text-sm',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</legend>
|
||||
</template>
|
||||
29
frontend/src/components/ui/field/FieldSeparator.vue
Normal file
29
frontend/src/components/ui/field/FieldSeparator.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
:data-content="!!$slots.default"
|
||||
:class="cn(
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<Separator class="absolute inset-0 top-1/2" />
|
||||
<span
|
||||
v-if="$slots.default"
|
||||
class="bg-default text-muted relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
21
frontend/src/components/ui/field/FieldSet.vue
Normal file
21
frontend/src/components/ui/field/FieldSet.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
:class="cn(
|
||||
'flex flex-col gap-6',
|
||||
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</fieldset>
|
||||
</template>
|
||||
20
frontend/src/components/ui/field/FieldTitle.vue
Normal file
20
frontend/src/components/ui/field/FieldTitle.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="field-label"
|
||||
:class="cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
39
frontend/src/components/ui/field/index.ts
Normal file
39
frontend/src/components/ui/field/index.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-danger",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type FieldVariants = VariantProps<typeof fieldVariants>
|
||||
|
||||
export { default as Field } from "./Field.vue"
|
||||
export { default as FieldContent } from "./FieldContent.vue"
|
||||
export { default as FieldDescription } from "./FieldDescription.vue"
|
||||
export { default as FieldError } from "./FieldError.vue"
|
||||
export { default as FieldGroup } from "./FieldGroup.vue"
|
||||
export { default as FieldLabel } from "./FieldLabel.vue"
|
||||
export { default as FieldLegend } from "./FieldLegend.vue"
|
||||
export { default as FieldSeparator } from "./FieldSeparator.vue"
|
||||
export { default as FieldSet } from "./FieldSet.vue"
|
||||
export { default as FieldTitle } from "./FieldTitle.vue"
|
||||
17
frontend/src/components/ui/form/FormControl.vue
Normal file
17
frontend/src/components/ui/form/FormControl.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts" setup>
|
||||
import { Slot } from "reka-ui"
|
||||
import { useFormField } from "./useFormField"
|
||||
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot
|
||||
:id="formItemId"
|
||||
data-slot="form-control"
|
||||
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
|
||||
:aria-invalid="!!error"
|
||||
>
|
||||
<slot />
|
||||
</Slot>
|
||||
</template>
|
||||
21
frontend/src/components/ui/form/FormDescription.vue
Normal file
21
frontend/src/components/ui/form/FormDescription.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useFormField } from "./useFormField"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const { formDescriptionId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
:id="formDescriptionId"
|
||||
data-slot="form-description"
|
||||
:class="cn('text-muted text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
23
frontend/src/components/ui/form/FormItem.vue
Normal file
23
frontend/src/components/ui/form/FormItem.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useId } from "reka-ui"
|
||||
import { provide } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const id = useId()
|
||||
provide(FORM_ITEM_INJECTION_KEY, id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
:class="cn('grid gap-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
25
frontend/src/components/ui/form/FormLabel.vue
Normal file
25
frontend/src/components/ui/form/FormLabel.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts" setup>
|
||||
import type { LabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useFormField } from "./useFormField"
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const { error, formItemId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
:data-error="!!error"
|
||||
:class="cn(
|
||||
'data-[error=true]:text-danger',
|
||||
props.class,
|
||||
)"
|
||||
:for="formItemId"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
23
frontend/src/components/ui/form/FormMessage.vue
Normal file
23
frontend/src/components/ui/form/FormMessage.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { ErrorMessage } from "vee-validate"
|
||||
import { toValue } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useFormField } from "./useFormField"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const { name, formMessageId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ErrorMessage
|
||||
:id="formMessageId"
|
||||
data-slot="form-message"
|
||||
as="p"
|
||||
:name="toValue(name)"
|
||||
:class="cn('text-danger text-sm', props.class)"
|
||||
/>
|
||||
</template>
|
||||
7
frontend/src/components/ui/form/index.ts
Normal file
7
frontend/src/components/ui/form/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { default as FormControl } from "./FormControl.vue"
|
||||
export { default as FormDescription } from "./FormDescription.vue"
|
||||
export { default as FormItem } from "./FormItem.vue"
|
||||
export { default as FormLabel } from "./FormLabel.vue"
|
||||
export { default as FormMessage } from "./FormMessage.vue"
|
||||
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
|
||||
export { Form, Field as FormField, FieldArray as FormFieldArray } from "vee-validate"
|
||||
4
frontend/src/components/ui/form/injectionKeys.ts
Normal file
4
frontend/src/components/ui/form/injectionKeys.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import type { InjectionKey } from "vue"
|
||||
|
||||
export const FORM_ITEM_INJECTION_KEY
|
||||
= Symbol() as InjectionKey<string>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue