1
0
Fork 0

initial commit

This commit is contained in:
Henrik Hautakoski 2026-04-07 23:16:12 +02:00
commit ae93c7e1a6
233 changed files with 20282 additions and 0 deletions

18
frontend/.editorconfig Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

23
frontend/components.json Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

37
frontend/package.json Normal file
View 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"
}
}

View 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
View 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
View 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
View 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
View 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})`);
}
};

View 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>;
};

View file

@ -0,0 +1,3 @@
export * from './products.ts';
export * from './categories.ts';
export * from './auth.ts';

View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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;
}
}

View 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);
}

View 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);
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -0,0 +1,5 @@
<template>
<ul class="divide-y">
<slot />
</ul>
</template>

View file

@ -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>

View file

@ -0,0 +1 @@
export { default as CategorySidebar } from "./Root.vue";

View 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>

View 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>

View file

@ -0,0 +1,2 @@
export { default as ProductGrid } from './ProductGrid.vue';
export { default as ProductCard } from './ProductCard.vue';

View 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>

View 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>

View 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>

View 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"

View 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>

View 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>

View 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>

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>
<span
data-slot="dropdown-menu-shortcut"
:class="cn('text-muted ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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"

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"

View 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>

View 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>

View 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>

View 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>

View 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>

View 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"

View 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