Initial commit with project structure, including configuration files, components, pages, and styles for a Nuxt.js application.
This commit is contained in:
parent
d44c704659
commit
5096eb1e91
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
20
app.vue
Normal file
20
app.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
useSeoMeta({
|
||||
title: "CORRAD UI 2025",
|
||||
description: "Modern UI components for CORRAD",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLoadingIndicator />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<!-- Toast containers for all positions -->
|
||||
<Toast position="top-left" />
|
||||
<Toast position="top-right" />
|
||||
<Toast position="bottom-left" />
|
||||
<Toast position="bottom-right" />
|
||||
</div>
|
||||
</template>
|
97
assets/css/base/theme.css
Normal file
97
assets/css/base/theme.css
Normal file
@ -0,0 +1,97 @@
|
||||
:root {
|
||||
/* Font settings */
|
||||
--font-sans: "Geist";
|
||||
--font-fallback: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-size: 14px;
|
||||
|
||||
/* Layout backgrounds */
|
||||
--background: 255, 255, 255;
|
||||
--content-background: 250, 250, 250;
|
||||
--header-background: 255, 255, 255;
|
||||
--sidebar-background: 255, 255, 255;
|
||||
|
||||
/* Core colors */
|
||||
--foreground: 9, 9, 11;
|
||||
--border: 228, 228, 231;
|
||||
--ring: 9, 9, 11;
|
||||
--radius: 0.25rem;
|
||||
|
||||
/* Component colors */
|
||||
--accent: 244, 244, 245;
|
||||
--accent-foreground: 9, 9, 11;
|
||||
|
||||
--card: 255, 255, 255;
|
||||
--card-foreground: 9, 9, 11;
|
||||
|
||||
--muted: 244, 244, 245;
|
||||
--muted-foreground: 113, 113, 122;
|
||||
|
||||
--popover: 255, 255, 255;
|
||||
--popover-foreground: 9, 9, 11;
|
||||
|
||||
--primary: 9, 9, 11;
|
||||
--primary-foreground: 250, 250, 250;
|
||||
|
||||
--secondary: 244, 244, 245;
|
||||
--secondary-foreground: 9, 9, 11;
|
||||
|
||||
/* Status colors */
|
||||
--danger: 239, 68, 68;
|
||||
--danger-foreground: 255, 255, 255;
|
||||
|
||||
--info: 59, 130, 246;
|
||||
--info-foreground: 255, 255, 255;
|
||||
|
||||
--success: 34, 197, 94;
|
||||
--success-foreground: 255, 255, 255;
|
||||
|
||||
--warning: 245, 158, 11;
|
||||
--warning-foreground: 255, 255, 255;
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
/* Layout backgrounds */
|
||||
--background: 9, 9, 11;
|
||||
--content-background: 9, 9, 11;
|
||||
--header-background: 9, 9, 11;
|
||||
--sidebar-background: 9, 9, 11;
|
||||
|
||||
/* Core colors */
|
||||
--foreground: 250, 250, 250;
|
||||
--border: 39, 39, 42;
|
||||
--ring: 212, 212, 216;
|
||||
--radius: 0.25rem;
|
||||
|
||||
/* Component colors */
|
||||
--accent: 24, 24, 27;
|
||||
--accent-foreground: 250, 250, 250;
|
||||
|
||||
--card: 9, 9, 11;
|
||||
--card-foreground: 250, 250, 250;
|
||||
|
||||
--muted: 39, 39, 42;
|
||||
--muted-foreground: 161, 161, 170;
|
||||
|
||||
--popover: 9, 9, 11;
|
||||
--popover-foreground: 250, 250, 250;
|
||||
|
||||
--primary: 250, 250, 250;
|
||||
--primary-foreground: 9, 9, 11;
|
||||
|
||||
--secondary: 39, 39, 42;
|
||||
--secondary-foreground: 250, 250, 250;
|
||||
|
||||
/* Status colors */
|
||||
--danger: 248, 113, 113;
|
||||
--danger-foreground: 255, 255, 255;
|
||||
|
||||
--info: 96, 165, 250;
|
||||
--info-foreground: 255, 255, 255;
|
||||
|
||||
--success: 74, 222, 128;
|
||||
--success-foreground: 255, 255, 255;
|
||||
|
||||
--warning: 251, 191, 36;
|
||||
--warning-foreground: 255, 255, 255;
|
||||
}
|
13
assets/css/tailwind.css
Normal file
13
assets/css/tailwind.css
Normal file
@ -0,0 +1,13 @@
|
||||
@import "./base/theme.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-[rgb(var(--background))] text-[rgb(var(--foreground))];
|
||||
font-family: var(--font-sans), var(--font-fallback);
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
}
|
79
components/accordion/content.vue
Normal file
79
components/accordion/content.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
defineOptions({
|
||||
name: "AccordionContent",
|
||||
});
|
||||
|
||||
const accordionValue = inject("accordionValue");
|
||||
const isOpen = inject("isOpen");
|
||||
const contentRef = ref(null);
|
||||
const contentHeight = ref("0px");
|
||||
|
||||
function updateHeight() {
|
||||
if (!contentRef.value) return;
|
||||
|
||||
// Use requestAnimationFrame for smoother animation
|
||||
requestAnimationFrame(() => {
|
||||
const height = contentRef.value.scrollHeight;
|
||||
contentHeight.value = isOpen(accordionValue) ? `${height}px` : "0px";
|
||||
});
|
||||
}
|
||||
|
||||
// Debounce the resize handler for better performance
|
||||
const debouncedResize = useDebounceFn(updateHeight, 100);
|
||||
|
||||
// Use MutationObserver to watch for content changes
|
||||
let observer;
|
||||
|
||||
onMounted(() => {
|
||||
updateHeight();
|
||||
window.addEventListener("resize", debouncedResize);
|
||||
|
||||
// Create MutationObserver to watch for content changes
|
||||
observer = new MutationObserver(updateHeight);
|
||||
if (contentRef.value) {
|
||||
observer.observe(contentRef.value, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", debouncedResize);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for isOpen changes with optimized timing
|
||||
watch(
|
||||
() => isOpen(accordionValue),
|
||||
() => {
|
||||
nextTick(updateHeight);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="accordion-content overflow-hidden will-change-[height]"
|
||||
:style="{
|
||||
height: contentHeight,
|
||||
'--content-height': contentHeight,
|
||||
}"
|
||||
>
|
||||
<div ref="contentRef" class="pb-4 pt-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.accordion-content {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: translateZ(0); /* Hardware acceleration */
|
||||
}
|
||||
</style>
|
47
components/accordion/index.vue
Normal file
47
components/accordion/index.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "Accordion",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: "single",
|
||||
validator: (value) => ["single", "multiple"].includes(value),
|
||||
},
|
||||
defaultValue: {
|
||||
type: [String, Array],
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const value = ref(props.defaultValue);
|
||||
|
||||
const isOpen = (itemValue) => {
|
||||
if (props.type === "single") {
|
||||
return value.value === itemValue;
|
||||
}
|
||||
return Array.isArray(value.value) && value.value.includes(itemValue);
|
||||
};
|
||||
|
||||
const toggle = (itemValue) => {
|
||||
if (props.type === "single") {
|
||||
value.value = value.value === itemValue ? "" : itemValue;
|
||||
} else {
|
||||
value.value = Array.isArray(value.value)
|
||||
? value.value.includes(itemValue)
|
||||
? value.value.filter((v) => v !== itemValue)
|
||||
: [...value.value, itemValue]
|
||||
: [itemValue];
|
||||
}
|
||||
};
|
||||
|
||||
provide("isOpen", isOpen);
|
||||
provide("toggle", toggle);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="divide-y rounded-md">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
20
components/accordion/item.vue
Normal file
20
components/accordion/item.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "AccordionItem",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
provide("accordionValue", props.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
31
components/accordion/trigger.vue
Normal file
31
components/accordion/trigger.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "AccordionTrigger",
|
||||
});
|
||||
|
||||
const accordionValue = inject("accordionValue");
|
||||
const isOpen = inject("isOpen");
|
||||
const toggle = inject("toggle");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle(accordionValue)"
|
||||
class="flex flex-1 w-full items-center justify-between py-4 text-sm font-medium transition-all hover:underline"
|
||||
:data-state="isOpen(accordionValue) ? 'open' : 'closed'"
|
||||
>
|
||||
<slot />
|
||||
<Icon
|
||||
name="ph:caret-down"
|
||||
class="h-4 w-4 shrink-0 text-[rgb(var(--foreground))] transition-transform duration-300"
|
||||
:class="{ 'rotate-180': isOpen(accordionValue) }"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
159
components/admin/Header.vue
Normal file
159
components/admin/Header.vue
Normal file
@ -0,0 +1,159 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
isMinimized: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const isRTL = computed(() => layoutStore.isRTL);
|
||||
|
||||
defineEmits(["toggle"]);
|
||||
|
||||
const unreadCount = 2;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 h-14 bg-background border-b px-3 flex items-center gap-3 z-50"
|
||||
:class="[isRTL ? 'flex-row-reverse' : 'flex-row']"
|
||||
>
|
||||
<!-- Left side -->
|
||||
<button
|
||||
@click="$emit('toggle')"
|
||||
:class="[
|
||||
'p-1.5 hover:bg-accent rounded-lg flex items-center justify-center',
|
||||
isRTL ? 'order-last' : 'order-first',
|
||||
]"
|
||||
>
|
||||
<Icon name="mdi:menu" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<!-- Right side -->
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
:class="[isRTL ? 'mr-auto' : 'ml-auto']"
|
||||
>
|
||||
<!-- Theme Switcher -->
|
||||
<ThemeSwitcher />
|
||||
|
||||
<!-- Notification -->
|
||||
<Dropdown>
|
||||
<DropdownTrigger class="relative">
|
||||
<button
|
||||
class="p-1.5 hover:bg-accent rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Icon name="mdi:bell" class="w-5 h-5" />
|
||||
<span
|
||||
v-if="unreadCount"
|
||||
:class="[
|
||||
'absolute top-0.5 w-3.5 h-3.5 bg-danger rounded-full text-[10px] text-danger-foreground flex items-center justify-center',
|
||||
isRTL ? 'left-0.5' : 'right-0.5',
|
||||
]"
|
||||
>
|
||||
{{ unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent class="w-80">
|
||||
<div class="px-3 py-2 border-b">
|
||||
<h3 class="font-semibold text-sm">Notifications</h3>
|
||||
</div>
|
||||
<div class="py-1.5">
|
||||
<DropdownItem>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="p-1 bg-primary/10 rounded">
|
||||
<Icon name="mdi:email" class="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">New message received</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
You have a new message from John Doe
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">2m ago</p>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="p-1 bg-success/10 rounded">
|
||||
<Icon
|
||||
name="mdi:check-circle"
|
||||
class="w-3.5 h-3.5 text-success"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">Task completed</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
Project X has been successfully deployed
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">1h ago</p>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</div>
|
||||
<div class="px-3 py-2 border-t text-center">
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="text-xs text-primary hover:text-primary/90"
|
||||
>
|
||||
View all notifications
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
<!-- User Profile -->
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<button
|
||||
class="flex items-center gap-2 p-1 hover:bg-accent rounded-lg"
|
||||
>
|
||||
<div
|
||||
class="w-7 h-7 rounded-full border flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24M74.08 197.5a64 64 0 0 1 107.84 0a87.83 87.83 0 0 1-107.84 0M96 120a32 32 0 1 1 32 32a32 32 0 0 1-32-32m97.76 66.41a79.66 79.66 0 0 0-36.06-28.75a48 48 0 1 0-59.4 0a79.66 79.66 0 0 0-36.06 28.75a88 88 0 1 1 131.52 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-sm font-medium">John Doe</span>
|
||||
<span class="text-xs text-muted-foreground">Administrator</span>
|
||||
</div>
|
||||
<Icon name="mdi:chevron-down" class="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent>
|
||||
<DropdownItem>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="mdi:account" class="w-4 h-4" />
|
||||
<span class="text-sm">Profile</span>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="mdi:cog" class="w-4 h-4" />
|
||||
<span class="text-sm">Settings</span>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownSeparator />
|
||||
<DropdownItem>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="mdi:logout" class="w-4 h-4" />
|
||||
<span class="text-sm">Log out</span>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
465
components/admin/SideMenu.vue
Normal file
465
components/admin/SideMenu.vue
Normal file
@ -0,0 +1,465 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMinimized: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["toggle"]);
|
||||
const layoutStore = useLayoutStore();
|
||||
const { getAdminNavigation } = useNavigation();
|
||||
const menuItems = getAdminNavigation();
|
||||
|
||||
// Initialize expanded items based on current route
|
||||
const initializeExpandedItems = () => {
|
||||
const currentPath = useRoute().path;
|
||||
|
||||
const checkAndExpandParents = (items, parentNames = []) => {
|
||||
items.forEach(item => {
|
||||
if (item.children) {
|
||||
const hasActiveChild = item.children.some(child => {
|
||||
if (child.path === currentPath) return true;
|
||||
if (child.children) {
|
||||
return checkAndExpandParents([child], [...parentNames, item.name]);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasActiveChild) {
|
||||
// Expand all parent items in the chain
|
||||
parentNames.forEach(name => expandedItems.value.add(name));
|
||||
expandedItems.value.add(item.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.path === currentPath) {
|
||||
// Expand all parent items when current item is found
|
||||
parentNames.forEach(name => expandedItems.value.add(name));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return items.some(item => item.path === currentPath);
|
||||
};
|
||||
|
||||
menuItems.forEach(section => {
|
||||
checkAndExpandParents(section.items);
|
||||
});
|
||||
};
|
||||
|
||||
// Call initialization on mount
|
||||
onMounted(() => {
|
||||
initializeExpandedItems();
|
||||
});
|
||||
|
||||
// Watch for route changes to update expanded state
|
||||
watch(
|
||||
() => useRoute().path,
|
||||
() => {
|
||||
initializeExpandedItems();
|
||||
}
|
||||
);
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
emit("toggle");
|
||||
};
|
||||
|
||||
// Compute layout-based properties
|
||||
const isHorizontal = computed(() => layoutStore.sidebarLayout === "horizontal");
|
||||
const isRTL = computed(() => layoutStore.isRTL);
|
||||
|
||||
// Compute effective isMinimized state based on screen size and layout
|
||||
const effectiveIsMinimized = computed(() => {
|
||||
if (isHorizontal.value) return false;
|
||||
if (process.client && window.innerWidth < 768) {
|
||||
return false;
|
||||
}
|
||||
return props.isMinimized;
|
||||
});
|
||||
|
||||
// Compute sidebar positioning classes
|
||||
const sidebarPositionClasses = computed(() => {
|
||||
if (isHorizontal.value) {
|
||||
return "fixed top-14 left-0 right-0 h-auto";
|
||||
}
|
||||
|
||||
return [
|
||||
"fixed inset-y-0",
|
||||
isRTL.value ? "right-0" : "left-0",
|
||||
props.isOpen ? "z-50" : "-translate-x-full md:translate-x-0",
|
||||
effectiveIsMinimized.value ? "w-14" : "w-56",
|
||||
];
|
||||
});
|
||||
|
||||
// Track expanded menu items
|
||||
const expandedItems = ref(new Set());
|
||||
|
||||
const toggleExpand = (itemName) => {
|
||||
if (expandedItems.value.has(itemName)) {
|
||||
expandedItems.value.delete(itemName);
|
||||
} else {
|
||||
expandedItems.value.add(itemName);
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamic classes for navigation items
|
||||
const navItemClasses = computed(() => {
|
||||
return "flex items-center w-full px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded-md gap-3 transition-colors duration-200";
|
||||
});
|
||||
|
||||
const sectionTitleClasses = computed(() => {
|
||||
return "px-3 text-xs font-medium text-muted-foreground tracking-wider uppercase";
|
||||
});
|
||||
|
||||
const getNestedItemClasses = (level = 1) => {
|
||||
const baseClasses = "flex items-center w-full py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors duration-200 relative";
|
||||
// Use fixed padding classes instead of dynamic ones
|
||||
const levelPadding = {
|
||||
1: 'pl-10', // First level indent
|
||||
2: 'pl-16', // Second level indent
|
||||
3: 'pl-20' // Third level indent
|
||||
};
|
||||
return `${baseClasses} ${levelPadding[level] || 'pl-10'} pr-3`;
|
||||
};
|
||||
|
||||
const isActive = (path) => {
|
||||
if (!path) return false;
|
||||
const currentPath = useRoute().path;
|
||||
return currentPath === path;
|
||||
};
|
||||
|
||||
const isParentActive = (item) => {
|
||||
const currentPath = useRoute().path;
|
||||
|
||||
const checkChildrenActive = (items) => {
|
||||
return items.some(child => {
|
||||
if (child.path === currentPath) return true;
|
||||
if (child.children) {
|
||||
return checkChildrenActive(child.children);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
if (item.path) {
|
||||
return isActive(item.path);
|
||||
}
|
||||
if (item.children) {
|
||||
return checkChildrenActive(item.children);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Add this to the script section
|
||||
const activePopover = ref(null);
|
||||
|
||||
const handlePopoverTrigger = (itemName) => {
|
||||
if (activePopover.value === itemName) {
|
||||
activePopover.value = null;
|
||||
} else {
|
||||
activePopover.value = itemName;
|
||||
}
|
||||
};
|
||||
|
||||
// Add this computed property to the script section
|
||||
const dropdownPosition = computed(() => {
|
||||
return {
|
||||
placement: 'right-start',
|
||||
middleware: [{ name: 'offset', options: { mainAxis: 12 } }]
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Overlay (only for vertical layout) -->
|
||||
<div
|
||||
v-if="props.isOpen && !isHorizontal"
|
||||
@click="handleOverlayClick"
|
||||
class="fixed inset-0 backdrop-blur-sm backdrop-brightness-75 z-40 md:hidden"
|
||||
></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
:class="[
|
||||
'bg-sidebar border-r flex',
|
||||
isHorizontal ? 'flex-row' : 'flex-col',
|
||||
...sidebarPositionClasses,
|
||||
'transition-all duration-200',
|
||||
]"
|
||||
>
|
||||
<!-- Logo (only show in vertical layout) -->
|
||||
<div
|
||||
v-if="!isHorizontal"
|
||||
class="h-14 flex items-center px-3 border-b border-border"
|
||||
>
|
||||
<NuxtLink to="/admin" class="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
<div
|
||||
class="w-8 h-8 bg-primary rounded-md flex items-center justify-center"
|
||||
>
|
||||
<Icon name="lucide:box" class="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<div
|
||||
class="overflow-hidden transition-all duration-200"
|
||||
:style="{
|
||||
width: effectiveIsMinimized ? '0' : 'auto',
|
||||
opacity: effectiveIsMinimized ? '0' : '1',
|
||||
transform: effectiveIsMinimized ? 'translateX(-10px)' : 'translateX(0)'
|
||||
}"
|
||||
>
|
||||
<h2 class="text-sm font-semibold whitespace-nowrap">Corrad UI</h2>
|
||||
<p class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Enterprise
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto py-4 px-2"
|
||||
:class="{ 'px-4': isHorizontal }"
|
||||
>
|
||||
<nav class="space-y-1">
|
||||
<template v-for="section in menuItems" :key="section.title">
|
||||
<!-- Section title (only in vertical layout when not minimized) -->
|
||||
<div
|
||||
v-if="!effectiveIsMinimized && !isHorizontal"
|
||||
class="pt-4 pb-2"
|
||||
>
|
||||
<h2 :class="sectionTitleClasses">
|
||||
{{ section.title }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Navigation items -->
|
||||
<div
|
||||
:class="[
|
||||
'space-y-1',
|
||||
isHorizontal ? 'flex items-center gap-4' : '',
|
||||
]"
|
||||
>
|
||||
<template v-for="item in section.items" :key="item.name">
|
||||
<!-- Items with children -->
|
||||
<template v-if="item.children">
|
||||
<!-- Minimized state -->
|
||||
<template v-if="effectiveIsMinimized">
|
||||
<Dropdown
|
||||
v-model="activePopover"
|
||||
:model-value="activePopover === item.name"
|
||||
placement="right"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<button
|
||||
@click="handlePopoverTrigger(item.name)"
|
||||
class="submenu-trigger relative"
|
||||
:class="[
|
||||
navItemClasses,
|
||||
activePopover === item.name ? 'bg-accent text-foreground' : ''
|
||||
]"
|
||||
>
|
||||
<Icon :name="item.icon" class="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent
|
||||
class="min-w-[200px]"
|
||||
@select="() => activePopover = null"
|
||||
>
|
||||
<div class="py-1.5 px-2 text-sm font-medium border-b">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<template
|
||||
v-for="child in item.children"
|
||||
:key="child.name"
|
||||
>
|
||||
<!-- Handle nested children -->
|
||||
<template v-if="child.children">
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{{ child.name }}
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-for="subChild in child.children"
|
||||
:key="subChild.name"
|
||||
:to="subChild.path"
|
||||
>
|
||||
<DropdownItem @click="activePopover = null">
|
||||
{{ subChild.name }}
|
||||
</DropdownItem>
|
||||
</NuxtLink>
|
||||
<DropdownSeparator v-if="child !== item.children[item.children.length - 1]" />
|
||||
</template>
|
||||
<!-- Regular child item -->
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="child.path"
|
||||
>
|
||||
<DropdownItem @click="activePopover = null">
|
||||
{{ child.name }}
|
||||
</DropdownItem>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<!-- Non-minimized state -->
|
||||
<template v-else>
|
||||
<button
|
||||
@click="toggleExpand(item.name)"
|
||||
:class="[
|
||||
navItemClasses,
|
||||
isParentActive(item) ? 'bg-accent text-foreground' : '',
|
||||
expandedItems.has(item.name) ? '' : '',
|
||||
]"
|
||||
>
|
||||
<Icon :name="item.icon" class="w-4 h-4 shrink-0" />
|
||||
<span
|
||||
class="flex-1"
|
||||
:class="{ 'text-left': !isRTL, 'text-right': isRTL }"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<Icon
|
||||
:name="
|
||||
isRTL
|
||||
? 'lucide:chevron-left'
|
||||
: 'lucide:chevron-right'
|
||||
"
|
||||
class="w-4 h-4 shrink-0 transition-transform duration-300 ease-in-out"
|
||||
:class="[
|
||||
{ 'ml-auto': !isRTL, 'mr-auto': isRTL },
|
||||
expandedItems.has(item.name) ? 'rotate-90' : ''
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Expanded menu in non-minimized state -->
|
||||
<div
|
||||
class="overflow-hidden transition-all duration-500 ease-in-out"
|
||||
:style="{
|
||||
maxHeight: expandedItems.has(item.name) ? '500px' : '0'
|
||||
}"
|
||||
>
|
||||
<div class="mt-0.5 relative">
|
||||
<!-- Vertical line for first level -->
|
||||
<div class="absolute left-5 top-0 bottom-0 w-px bg-border"></div>
|
||||
|
||||
<template
|
||||
v-for="(child, childIndex) in item.children"
|
||||
:key="child.name"
|
||||
>
|
||||
<!-- Handle nested children -->
|
||||
<template v-if="child.children">
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="toggleExpand(child.name)"
|
||||
:class="[
|
||||
getNestedItemClasses(1),
|
||||
'flex items-center justify-between group',
|
||||
{
|
||||
'text-foreground font-medium': isParentActive(child)
|
||||
}
|
||||
]"
|
||||
>
|
||||
<span>{{ child.name }}</span>
|
||||
<Icon
|
||||
:name="'lucide:chevron-right'"
|
||||
class="w-4 h-4 shrink-0 opacity-50 group-hover:opacity-100 transition-transform duration-300 ease-in-out"
|
||||
:class="expandedItems.has(child.name) ? 'rotate-90' : ''"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Nested submenu -->
|
||||
<div
|
||||
class="overflow-hidden transition-all duration-500 ease-in-out"
|
||||
:style="{
|
||||
maxHeight: expandedItems.has(child.name) ? '500px' : '0'
|
||||
}"
|
||||
>
|
||||
<div class="py-0.5 relative">
|
||||
<!-- Vertical line for second level -->
|
||||
<div class="absolute left-12 top-0 bottom-0 w-px bg-border"></div>
|
||||
|
||||
<NuxtLink
|
||||
v-for="subChild in child.children"
|
||||
:key="subChild.name"
|
||||
:to="subChild.path"
|
||||
:class="[
|
||||
getNestedItemClasses(2),
|
||||
{
|
||||
'text-foreground font-medium': isActive(subChild.path)
|
||||
}
|
||||
]"
|
||||
>
|
||||
{{ subChild.name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Regular child item -->
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="child.path"
|
||||
:class="[
|
||||
getNestedItemClasses(1),
|
||||
{
|
||||
'text-foreground font-medium': isActive(child.path)
|
||||
}
|
||||
]"
|
||||
>
|
||||
{{ child.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Regular item without children -->
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="item.path"
|
||||
:class="[
|
||||
navItemClasses,
|
||||
isActive(item.path) ? 'bg-accent text-foreground' : '',
|
||||
]"
|
||||
>
|
||||
<Icon :name="item.icon" class="w-4 h-4 shrink-0" />
|
||||
<span v-if="!effectiveIsMinimized" class="flex-1">{{
|
||||
item.name
|
||||
}}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
aside {
|
||||
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Hide scrollbar but maintain functionality */
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
11
components/alert/description.vue
Normal file
11
components/alert/description.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "AlertDescription",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-sm [&_p]:leading-relaxed">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
50
components/alert/index.vue
Normal file
50
components/alert/index.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "Alert",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) =>
|
||||
["default", "danger", "success", "warning", "info"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
default: "ph:bell",
|
||||
danger: "ph:warning-octagon",
|
||||
success: "ph:check-circle",
|
||||
warning: "ph:warning-diamond",
|
||||
info: "ph:info",
|
||||
};
|
||||
|
||||
const baseClasses = "relative w-full rounded-lg border p-4";
|
||||
|
||||
const variantClasses = {
|
||||
default: "bg-background text-foreground border-[rgb(var(--border))]",
|
||||
danger:
|
||||
"border-[rgb(var(--danger))] text-[rgb(var(--danger))] dark:border-[rgb(var(--danger))]",
|
||||
success:
|
||||
"border-[rgb(var(--success))] text-[rgb(var(--success))] dark:border-[rgb(var(--success))]",
|
||||
warning:
|
||||
"border-[rgb(var(--warning))] text-[rgb(var(--warning))] dark:border-[rgb(var(--warning))]",
|
||||
info: "border-[rgb(var(--info))] text-[rgb(var(--info))] dark:border-[rgb(var(--info))]",
|
||||
};
|
||||
|
||||
const alertClasses = computed(() => {
|
||||
return `${baseClasses} ${variantClasses[props.variant]}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="alertClasses" role="alert">
|
||||
<div class="flex items-start gap-4">
|
||||
<Icon v-if="icons[variant]" :name="icons[variant]" class="h-5 w-5 mt-1" />
|
||||
<div class="flex-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
11
components/alert/title.vue
Normal file
11
components/alert/title.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "AlertTitle",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-1 font-medium leading-none tracking-tight">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
42
components/avatar/group.vue
Normal file
42
components/avatar/group.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "AvatarGroup",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
max: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const avatars = computed(() => {
|
||||
const defaultSlot = slots.default?.() || [];
|
||||
return defaultSlot.filter((slot) => slot.type?.name === "Avatar");
|
||||
});
|
||||
|
||||
const visibleAvatars = computed(() => {
|
||||
return avatars.value.slice(0, props.max);
|
||||
});
|
||||
|
||||
const remainingCount = computed(() => {
|
||||
return Math.max(0, avatars.value.length - props.max);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex -space-x-2">
|
||||
<template v-for="(avatar, index) in visibleAvatars" :key="index">
|
||||
<div class="ring-2 ring-background">
|
||||
<component :is="avatar" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="remainingCount > 0"
|
||||
class="relative flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm ring-2 ring-background"
|
||||
>
|
||||
+{{ remainingCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
99
components/avatar/index.vue
Normal file
99
components/avatar/index.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "Avatar",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fallback: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) => ["sm", "default", "lg"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const hasError = ref(false);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const initials = computed(() => {
|
||||
if (props.fallback) {
|
||||
return props.fallback.toUpperCase();
|
||||
}
|
||||
|
||||
if (!props.alt?.trim()) {
|
||||
return 'NA';
|
||||
}
|
||||
|
||||
try {
|
||||
return props.alt
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
} catch (error) {
|
||||
console.warn('Error generating initials:', error);
|
||||
return 'NA';
|
||||
}
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-8 w-8 text-xs",
|
||||
default: "h-10 w-10 text-sm",
|
||||
lg: "h-12 w-12 text-base",
|
||||
};
|
||||
|
||||
const avatarClasses = computed(() => {
|
||||
return `relative flex shrink-0 overflow-hidden rounded-full ${
|
||||
sizeClasses[props.size]
|
||||
}`;
|
||||
});
|
||||
|
||||
// Reset error state when src changes
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
hasError.value = false;
|
||||
isLoading.value = true;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="avatarClasses">
|
||||
<template v-if="src && !hasError">
|
||||
<div
|
||||
v-show="isLoading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-muted"
|
||||
>
|
||||
<Icon name="ph:spinner" class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<img
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
@error="hasError = true"
|
||||
@load="isLoading = false"
|
||||
class="aspect-square h-full w-full transition-opacity duration-300"
|
||||
:class="{ 'opacity-0': isLoading, 'opacity-100': !isLoading }"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground"
|
||||
>
|
||||
{{ initials }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
48
components/badge/index.vue
Normal file
48
components/badge/index.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "Badge",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) =>
|
||||
[
|
||||
"default",
|
||||
"secondary",
|
||||
"info",
|
||||
"success",
|
||||
"warning",
|
||||
"danger",
|
||||
"outline",
|
||||
"ghost",
|
||||
].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const baseClasses =
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2";
|
||||
|
||||
const variantClasses = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
info: "bg-info text-info-foreground hover:bg-info/80",
|
||||
success: "bg-success text-success-foreground hover:bg-success/80",
|
||||
warning: "bg-warning text-warning-foreground hover:bg-warning/80",
|
||||
danger: "bg-danger text-danger-foreground hover:bg-danger/80",
|
||||
outline:
|
||||
"border border-border text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-transparent hover:text-foreground",
|
||||
};
|
||||
|
||||
const badgeClasses = computed(() => {
|
||||
return `${baseClasses} ${variantClasses[props.variant]}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="badgeClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
44
components/breadcrumb/index.vue
Normal file
44
components/breadcrumb/index.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "Breadcrumb",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
separator: {
|
||||
type: String,
|
||||
default: "/",
|
||||
},
|
||||
separatorIcon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const items = computed(() => {
|
||||
const defaultSlot = slots.default?.() || [];
|
||||
return defaultSlot.filter((slot) => slot.type?.name === "BreadcrumbItem");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol class="flex items-center">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<component :is="item" />
|
||||
<li
|
||||
v-if="index < items.length - 1"
|
||||
class="mx-2 select-none text-sm text-muted-foreground"
|
||||
>
|
||||
<Icon
|
||||
v-if="separatorIcon"
|
||||
:name="separatorIcon"
|
||||
class="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else aria-hidden="true">{{ separator }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
31
components/breadcrumb/item.vue
Normal file
31
components/breadcrumb/item.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "BreadcrumbItem",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
href: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="inline-flex items-center">
|
||||
<NuxtLink
|
||||
v-if="href && !disabled"
|
||||
:to="href"
|
||||
class="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<slot />
|
||||
</NuxtLink>
|
||||
<span v-else class="text-sm text-foreground">
|
||||
<slot />
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
72
components/button/index.vue
Normal file
72
components/button/index.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
validator: (value) =>
|
||||
[
|
||||
"primary",
|
||||
"secondary",
|
||||
"info",
|
||||
"success",
|
||||
"warning",
|
||||
"danger",
|
||||
"outline",
|
||||
"ghost",
|
||||
].includes(value),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) => ["default", "sm", "lg", "icon"].includes(value),
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: "button",
|
||||
validator: (value) => ["button", "submit", "reset"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const baseClasses = `
|
||||
inline-flex items-center justify-center rounded-md text-sm font-medium
|
||||
ring-offset-background transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-1
|
||||
focus-visible:ring-ring focus-visible:ring-offset-0
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
`;
|
||||
|
||||
const variantClasses = {
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
|
||||
info: "bg-info text-info-foreground hover:bg-info/90",
|
||||
success: "bg-success text-success-foreground hover:bg-success/90",
|
||||
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||
danger: "bg-danger text-danger-foreground hover:bg-danger/90",
|
||||
outline:
|
||||
"border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
};
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
return `${baseClasses} ${variantClasses[props.variant]} ${
|
||||
sizeClasses[props.size]
|
||||
}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :class="buttonClasses" :disabled="disabled" :type="type">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
11
components/card/content.vue
Normal file
11
components/card/content.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "CardContent",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 pt-0">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
11
components/card/description.vue
Normal file
11
components/card/description.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "CardDescription",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
11
components/card/footer.vue
Normal file
11
components/card/footer.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "CardFooter",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center p-6 pt-0">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
11
components/card/header.vue
Normal file
11
components/card/header.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "CardHeader",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
31
components/card/index.vue
Normal file
31
components/card/index.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "Card",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) => ["default", "muted", "accent"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const baseClasses = "rounded-lg border ";
|
||||
|
||||
const variantClasses = {
|
||||
default: "bg-card text-card-foreground",
|
||||
muted: "bg-muted text-muted-foreground",
|
||||
accent: "bg-accent text-accent-foreground",
|
||||
};
|
||||
|
||||
const cardClasses = computed(() => {
|
||||
return `${baseClasses} ${variantClasses[props.variant]}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cardClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
11
components/card/title.vue
Normal file
11
components/card/title.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "CardTitle",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-lg font-semibold leading-none tracking-tight">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
100
components/carousel/index.vue
Normal file
100
components/carousel/index.vue
Normal file
@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
import { Splide } from "@splidejs/vue-splide";
|
||||
import "@splidejs/splide/css";
|
||||
|
||||
defineOptions({
|
||||
name: "Carousel",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
autoplay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
interval: {
|
||||
type: Number,
|
||||
default: 5000,
|
||||
},
|
||||
wrap: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
controls: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
indicators: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const options = computed(() => ({
|
||||
type: props.wrap ? "loop" : "slide",
|
||||
autoplay: true,
|
||||
interval: 3000,
|
||||
arrows: props.controls,
|
||||
pagination: props.indicators,
|
||||
pauseOnHover: false,
|
||||
pauseOnFocus: false,
|
||||
resetProgress: false,
|
||||
speed: 800,
|
||||
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
rewind: true,
|
||||
rewindSpeed: 800,
|
||||
lazyLoad: "nearby",
|
||||
transition: "slide",
|
||||
drag: true,
|
||||
snap: true,
|
||||
gap: "1rem",
|
||||
}));
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Splide :options="options" class="relative w-full overflow-hidden rounded-lg">
|
||||
<slot />
|
||||
</Splide>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.splide__arrow) {
|
||||
@apply rounded-full bg-background/50 backdrop-blur-sm;
|
||||
@apply hover:bg-background/70;
|
||||
}
|
||||
|
||||
:deep(.splide__arrow svg) {
|
||||
@apply fill-primary transition-transform duration-300;
|
||||
@apply h-4 w-4;
|
||||
}
|
||||
|
||||
:deep(.splide__pagination) {
|
||||
@apply bottom-4 transition-opacity duration-300 z-10;
|
||||
}
|
||||
|
||||
:deep(.splide__pagination__page) {
|
||||
@apply h-3 w-3 bg-white/50 transition-all duration-300 mx-1;
|
||||
@apply hover:scale-125 hover:bg-white;
|
||||
}
|
||||
|
||||
:deep(.splide__pagination__page.is-active) {
|
||||
@apply bg-white scale-125;
|
||||
}
|
||||
|
||||
:deep(.splide__track) {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.splide__slide) {
|
||||
@apply transition-transform duration-500 ease-in-out;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
:deep(.splide__slide.is-active) {
|
||||
@apply z-10;
|
||||
}
|
||||
|
||||
:deep(.splide__slide:not(.is-active)) {
|
||||
@apply z-0;
|
||||
}
|
||||
</style>
|
13
components/carousel/item.vue
Normal file
13
components/carousel/item.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import { SplideSlide } from "@splidejs/vue-splide";
|
||||
|
||||
defineOptions({
|
||||
name: "CarouselItem",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SplideSlide class="relative h-full">
|
||||
<slot />
|
||||
</SplideSlide>
|
||||
</template>
|
129
components/codeBlock/index.vue
Normal file
129
components/codeBlock/index.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
code: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: "markup",
|
||||
validator: (value) =>
|
||||
[
|
||||
"markup",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"vue",
|
||||
"html",
|
||||
"jsx",
|
||||
"tsx",
|
||||
"css",
|
||||
"scss",
|
||||
].includes(value),
|
||||
},
|
||||
showCopy: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
initiallyExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
toggleLabel: {
|
||||
type: String,
|
||||
default: "View Code",
|
||||
},
|
||||
});
|
||||
|
||||
const copied = ref(false);
|
||||
const codeBlockRef = ref(null);
|
||||
const isExpanded = ref(props.initiallyExpanded);
|
||||
|
||||
const copyCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.code);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy code:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCodeBlock = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (import.meta.client) {
|
||||
const Prism = await import("prismjs");
|
||||
await import("prismjs/themes/prism-tomorrow.css");
|
||||
await import("prismjs/components/prism-javascript");
|
||||
await import("prismjs/components/prism-typescript");
|
||||
await import("prismjs/components/prism-jsx");
|
||||
await import("prismjs/components/prism-tsx");
|
||||
await import("prismjs/components/prism-markup");
|
||||
await import("prismjs/components/prism-css");
|
||||
await import("prismjs/components/prism-scss");
|
||||
|
||||
if (codeBlockRef.value) {
|
||||
Prism.default.highlightElement(codeBlockRef.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="my-6 w-full rounded-md border">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center border-b">
|
||||
<!-- Tab-like buttons -->
|
||||
<div class="flex">
|
||||
<button
|
||||
@click="toggleCodeBlock"
|
||||
class="flex h-10 items-center gap-2 border-r px-4 text-sm font-medium transition-colors hover:bg-muted"
|
||||
:class="[
|
||||
isExpanded ? 'bg-muted text-foreground' : 'text-muted-foreground',
|
||||
]"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Copy button -->
|
||||
<div class="ml-auto pr-2">
|
||||
<button
|
||||
v-if="showCopy"
|
||||
@click="copyCode"
|
||||
class="flex h-8 items-center gap-2 rounded-md px-3 text-sm transition-colors hover:bg-muted"
|
||||
>
|
||||
<span
|
||||
v-if="!copied"
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
>
|
||||
<Icon name="lucide:copy" class="h-4 w-4" />
|
||||
Copy
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-1 text-success">
|
||||
<Icon name="lucide:check" class="h-4 w-4" />
|
||||
Copied!
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code Block -->
|
||||
<div
|
||||
v-show="isExpanded"
|
||||
class="relative max-h-[650px] overflow-auto bg-muted"
|
||||
>
|
||||
<pre
|
||||
class="overflow-x-auto py-4 text-[13px] leading-6"
|
||||
><code :class="`language-${language} px-4`" ref="codeBlockRef">{{ code }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
62
components/contextMenu/content.vue
Normal file
62
components/contextMenu/content.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { inject, ref, onMounted, computed } from "vue";
|
||||
|
||||
defineOptions({ name: "ContextMenuContent" });
|
||||
|
||||
const { showMenu, position, closeMenu } = inject("context-menu");
|
||||
const menuRef = ref(null);
|
||||
|
||||
const adjustedPosition = computed(() => {
|
||||
if (!menuRef.value) return { top: position.value.y, left: position.value.x };
|
||||
|
||||
const menu = menuRef.value;
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight;
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
let top = position.value.y;
|
||||
let left = position.value.x;
|
||||
|
||||
// Adjust vertical position
|
||||
if (top + menuRect.height > windowHeight) {
|
||||
top = Math.max(0, windowHeight - menuRect.height);
|
||||
}
|
||||
|
||||
// Adjust horizontal position
|
||||
if (left + menuRect.width > windowWidth) {
|
||||
left = Math.max(0, windowWidth - menuRect.width);
|
||||
}
|
||||
|
||||
return { top, left };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showMenu"
|
||||
ref="menuRef"
|
||||
class="z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
top: `${adjustedPosition.top}px`,
|
||||
left: `${adjustedPosition.left}px`,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
66
components/contextMenu/index.vue
Normal file
66
components/contextMenu/index.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { provide, ref, watch } from "vue";
|
||||
|
||||
defineOptions({ name: "ContextMenu" });
|
||||
|
||||
const showMenu = ref(false);
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
const originalOverflow = ref('');
|
||||
|
||||
const lockScroll = () => {
|
||||
originalOverflow.value = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
};
|
||||
|
||||
const unlockScroll = () => {
|
||||
document.body.style.overflow = originalOverflow.value;
|
||||
};
|
||||
|
||||
// Watch for changes in showMenu to handle scroll locking
|
||||
watch(showMenu, (newValue) => {
|
||||
if (newValue) {
|
||||
lockScroll();
|
||||
} else {
|
||||
unlockScroll();
|
||||
}
|
||||
});
|
||||
|
||||
const handleContextMenu = (event) => {
|
||||
event.preventDefault();
|
||||
showMenu.value = true;
|
||||
position.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
showMenu.value = false;
|
||||
};
|
||||
|
||||
// Provide values to child components
|
||||
provide("context-menu", {
|
||||
showMenu,
|
||||
position,
|
||||
closeMenu,
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", closeMenu);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", closeMenu);
|
||||
// Ensure we unlock scroll when component is destroyed
|
||||
if (showMenu.value) {
|
||||
unlockScroll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div @contextmenu="handleContextMenu">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
40
components/contextMenu/item.vue
Normal file
40
components/contextMenu/item.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { inject } from "vue";
|
||||
|
||||
defineOptions({ name: "ContextMenuItem" });
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inset: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["click"]);
|
||||
const { closeMenu } = inject("context-menu");
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.disabled) {
|
||||
emit("click");
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
|
||||
'focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
inset && 'pl-8',
|
||||
disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer',
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
7
components/contextMenu/separator.vue
Normal file
7
components/contextMenu/separator.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
defineOptions({ name: "ContextMenuSeparator" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="-mx-1 my-1 h-px bg-border" />
|
||||
</template>
|
20
components/contextMenu/trigger.vue
Normal file
20
components/contextMenu/trigger.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineOptions({ name: "ContextMenuTrigger" });
|
||||
|
||||
const props = defineProps({
|
||||
as: {
|
||||
type: String,
|
||||
default: "div",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="as" class="inline-block">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
204
components/dataTable/index.vue
Normal file
204
components/dataTable/index.vue
Normal file
@ -0,0 +1,204 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isClient = ref(false);
|
||||
const {
|
||||
columns,
|
||||
sortBy,
|
||||
sortDesc,
|
||||
searchQuery,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
paginatedData,
|
||||
totalPages,
|
||||
toggleSort,
|
||||
itemsPerPageOptions,
|
||||
columnFilters,
|
||||
setItemsPerPage,
|
||||
setColumnFilter,
|
||||
filteredData,
|
||||
getCellValue,
|
||||
} = useDataTable(props.data, props.columns);
|
||||
|
||||
const showFilters = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
isClient.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isClient" class="dataTable">
|
||||
<!-- Controls -->
|
||||
<div class="mb-6 flex flex-wrap gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<FormKit v-model="searchQuery" type="text" placeholder="Search..." />
|
||||
</div>
|
||||
|
||||
<!-- Right Controls -->
|
||||
<div class="flex items-center gap-4 ml-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Show:</span>
|
||||
<FormKit
|
||||
v-model.number="itemsPerPage"
|
||||
type="select"
|
||||
:options="itemsPerPageOptions"
|
||||
@change="setItemsPerPage($event.target.value)"
|
||||
:classes="{ input: 'w-20' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" @click="showFilters = !showFilters">
|
||||
<Icon name="ph:funnel" class="h-4 w-4" />
|
||||
<span class="ml-2">{{
|
||||
showFilters ? "Hide Filters" : "Filters"
|
||||
}}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Filters -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div v-if="showFilters" class="mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="column in columns.filter((col) => col.filterable)"
|
||||
:key="column.key"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<FormKit
|
||||
type="text"
|
||||
:label="'Filter ' + column.label"
|
||||
v-model="columnFilters[column.key]"
|
||||
@input="setColumnFilter(column.key, columnFilters[column.key])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Table with Horizontal Scroll -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-muted/50">
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="px-4 py-3 text-left text-sm font-medium whitespace-nowrap"
|
||||
:class="{ 'cursor-pointer hover:bg-muted/70': column.sortable }"
|
||||
@click="column.sortable && toggleSort(column)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
{{ column.label }}
|
||||
<template v-if="column.sortable">
|
||||
<Icon
|
||||
v-if="sortBy !== column.key"
|
||||
name="ph:arrows-down-up"
|
||||
class="h-4 w-4 text-gray-400"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:name="
|
||||
sortDesc ? 'ph:sort-descending' : 'ph:sort-ascending'
|
||||
"
|
||||
class="h-4 w-4 text-primary"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr
|
||||
v-for="row in paginatedData"
|
||||
:key="row.id"
|
||||
class="hover:bg-muted/30"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="px-4 py-3 text-sm whitespace-nowrap"
|
||||
>
|
||||
<slot
|
||||
:name="column.key"
|
||||
:row="row"
|
||||
:value="getCellValue(row, column)"
|
||||
>
|
||||
{{ getCellValue(row, column) }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-4 text-sm">
|
||||
<div class="text-muted-foreground">
|
||||
Showing {{ (currentPage - 1) * itemsPerPage + 1 }} to
|
||||
{{ Math.min(currentPage * itemsPerPage, filteredData.length) }} of
|
||||
{{ filteredData.length }} entries
|
||||
</div>
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage === 1"
|
||||
@click="currentPage--"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="currentPage++"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dataTable {
|
||||
@apply w-full;
|
||||
|
||||
table {
|
||||
@apply border-collapse min-w-full table-auto;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply font-medium text-muted-foreground sticky top-0 bg-muted/50;
|
||||
}
|
||||
|
||||
td {
|
||||
@apply align-middle;
|
||||
}
|
||||
}
|
||||
|
||||
.formkit-outer {
|
||||
@apply mb-0;
|
||||
}
|
||||
</style>
|
70
components/dropdown/content.vue
Normal file
70
components/dropdown/content.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { inject, ref, computed } from "vue";
|
||||
|
||||
defineOptions({ name: "DropdownContent" });
|
||||
|
||||
const { isOpen, position, triggerRef } = inject("dropdown");
|
||||
const contentRef = ref(null);
|
||||
|
||||
const adjustedPosition = computed(() => {
|
||||
if (!contentRef.value || !triggerRef.value) return position.value;
|
||||
|
||||
const content = contentRef.value;
|
||||
const trigger = triggerRef.value;
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight;
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
let { x, y } = position.value;
|
||||
|
||||
// Check if dropdown should appear above instead of below
|
||||
const shouldShowAbove = y + contentRect.height > windowHeight;
|
||||
if (shouldShowAbove) {
|
||||
y = triggerRect.top - contentRect.height - 4; // 4px gap
|
||||
}
|
||||
|
||||
// Ensure horizontal alignment stays within bounds
|
||||
if (x + contentRect.width > windowWidth) {
|
||||
// Align to right edge of trigger
|
||||
x = triggerRect.right - contentRect.width;
|
||||
}
|
||||
|
||||
// Prevent going off-screen left
|
||||
x = Math.max(8, x); // 8px minimum margin
|
||||
|
||||
// Prevent going off-screen top
|
||||
y = Math.max(8, y); // 8px minimum margin
|
||||
|
||||
return { x, y };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="contentRef"
|
||||
class="fixed z-[100] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
:style="{
|
||||
top: `${adjustedPosition.y}px`,
|
||||
left: `${adjustedPosition.x}px`,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
88
components/dropdown/index.vue
Normal file
88
components/dropdown/index.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { provide, ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
defineOptions({ name: 'Dropdown' });
|
||||
|
||||
const props = defineProps({
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom'
|
||||
}
|
||||
});
|
||||
|
||||
const isOpen = ref(false);
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
const triggerRef = ref(null);
|
||||
const originalOverflow = ref('');
|
||||
|
||||
const lockScroll = () => {
|
||||
originalOverflow.value = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
};
|
||||
|
||||
const unlockScroll = () => {
|
||||
document.body.style.overflow = originalOverflow.value;
|
||||
};
|
||||
|
||||
// Watch for changes in isOpen to handle scroll locking
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
lockScroll();
|
||||
} else {
|
||||
unlockScroll();
|
||||
}
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
if (isOpen.value && triggerRef.value) {
|
||||
const rect = triggerRef.value.getBoundingClientRect();
|
||||
|
||||
if (props.placement === 'right') {
|
||||
position.value = {
|
||||
x: rect.right + 8, // Add small gap
|
||||
y: rect.top,
|
||||
};
|
||||
} else {
|
||||
position.value = {
|
||||
x: rect.left,
|
||||
y: rect.bottom + 4, // Add small gap
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
provide('dropdown', {
|
||||
isOpen,
|
||||
position,
|
||||
triggerRef,
|
||||
toggle,
|
||||
close,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!triggerRef.value?.contains(target)) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// Ensure we unlock scroll when component is destroyed
|
||||
if (isOpen.value) {
|
||||
unlockScroll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative inline-block text-left z-[100]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
36
components/dropdown/item.vue
Normal file
36
components/dropdown/item.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'DropdownItem' });
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inset: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.disabled) {
|
||||
emit('click');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
|
||||
'focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground z-[100]',
|
||||
inset && 'pl-8',
|
||||
disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer',
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
7
components/dropdown/separator.vue
Normal file
7
components/dropdown/separator.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'DropdownSeparator' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="-mx-1 my-1 h-px bg-border" />
|
||||
</template>
|
13
components/dropdown/trigger.vue
Normal file
13
components/dropdown/trigger.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
defineOptions({ name: 'DropdownTrigger' });
|
||||
|
||||
const { triggerRef, toggle } = inject('dropdown');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="triggerRef" @click="toggle">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
24
components/formkit/index.js
Normal file
24
components/formkit/index.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { createInput } from "@formkit/vue";
|
||||
import otp from "./inputs/otp";
|
||||
import dropzone from "./inputs/dropzone";
|
||||
import switchInput from "./inputs/switch";
|
||||
import mask from "./inputs/mask";
|
||||
import combobox from "./inputs/combobox";
|
||||
|
||||
export default {
|
||||
otp: createInput(otp, {
|
||||
props: ["length"],
|
||||
}),
|
||||
dropzone: createInput(dropzone, {
|
||||
props: ["multiple", "accept", "maxSize", "maxFiles"],
|
||||
}),
|
||||
switch: createInput(switchInput, {
|
||||
props: ["size"],
|
||||
}),
|
||||
mask: createInput(mask, {
|
||||
props: ["mask"],
|
||||
}),
|
||||
combobox: createInput(combobox, {
|
||||
props: ["options"],
|
||||
}),
|
||||
};
|
167
components/formkit/inputs/combobox/index.vue
Normal file
167
components/formkit/inputs/combobox/index.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, shallowRef } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
context: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref("");
|
||||
const selectedItems = ref([]);
|
||||
|
||||
// Use shallowRef for options since we don't need deep reactivity
|
||||
const options = shallowRef([]);
|
||||
|
||||
// Cache options on mount
|
||||
onMounted(() => {
|
||||
options.value = props.context.options || [];
|
||||
});
|
||||
|
||||
// Debounced search implementation
|
||||
let searchTimeout;
|
||||
const debouncedSearch = (query) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchQuery.value = query;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// Memoize filtered options with more efficient filtering
|
||||
const filteredOptions = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
if (!query) {
|
||||
return options.value.filter(
|
||||
(option) => !selectedItems.value.includes(option)
|
||||
);
|
||||
}
|
||||
return options.value.filter(
|
||||
(option) =>
|
||||
!selectedItems.value.includes(option) &&
|
||||
option.label.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Handle option selection
|
||||
const selectOption = (option) => {
|
||||
selectedItems.value.push(option);
|
||||
props.context.node.input(selectedItems.value);
|
||||
searchQuery.value = "";
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Handle option removal
|
||||
const removeOption = (optionToRemove) => {
|
||||
selectedItems.value = selectedItems.value.filter(
|
||||
(option) => option !== optionToRemove
|
||||
);
|
||||
props.context.node.input(selectedItems.value);
|
||||
};
|
||||
|
||||
// Handle input focus
|
||||
const handleFocus = () => {
|
||||
isOpen.value = true;
|
||||
};
|
||||
|
||||
// Handle input blur
|
||||
const handleBlur = (event) => {
|
||||
// Check if the related target is within the component
|
||||
if (!event.relatedTarget?.closest(".formkit-combobox")) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
isOpen.value = false;
|
||||
} else if (
|
||||
event.key === "Backspace" &&
|
||||
!searchQuery.value &&
|
||||
selectedItems.value.length > 0
|
||||
) {
|
||||
removeOption(selectedItems.value[selectedItems.value.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize value from context
|
||||
if (props.context.value) {
|
||||
selectedItems.value = Array.isArray(props.context.value)
|
||||
? props.context.value
|
||||
: [props.context.value];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="formkit-combobox">
|
||||
<!-- Selected items and input container -->
|
||||
<div
|
||||
:class="[
|
||||
context.classes.input,
|
||||
context.disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-text hover:/80',
|
||||
]"
|
||||
@click="handleFocus"
|
||||
>
|
||||
<!-- Selected items pills -->
|
||||
<span
|
||||
v-for="item in selectedItems"
|
||||
:key="item.value"
|
||||
:class="context.classes.pill"
|
||||
>
|
||||
{{ item.label }}
|
||||
<button
|
||||
type="button"
|
||||
:class="context.classes.removeButton"
|
||||
@click.stop="removeOption(item)"
|
||||
:disabled="context.disabled"
|
||||
>
|
||||
<Icon name="ph:x" :class="context.classes.removeIcon" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<!-- Search input -->
|
||||
<div :class="context.classes.inputWrapper">
|
||||
<input
|
||||
type="text"
|
||||
:class="context.classes.searchInput"
|
||||
:placeholder="selectedItems.length === 0 ? context.placeholder : ''"
|
||||
:value="searchQuery"
|
||||
@input="(e) => debouncedSearch(e.target.value)"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown="handleKeydown"
|
||||
:disabled="context.disabled"
|
||||
/>
|
||||
<Icon
|
||||
:name="isOpen ? 'ph:caret-up' : 'ph:caret-down'"
|
||||
:class="[
|
||||
context.classes.caret,
|
||||
context.disabled ? 'opacity-30' : 'group-hover:opacity-70',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div
|
||||
v-if="isOpen && filteredOptions.length > 0"
|
||||
:class="context.classes.dropdown"
|
||||
>
|
||||
<div :class="context.classes.dropdownInner">
|
||||
<button
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
:class="context.classes.option"
|
||||
@click="selectOption(option)"
|
||||
@mousedown.prevent
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
238
components/formkit/inputs/dropzone/index.vue
Normal file
238
components/formkit/inputs/dropzone/index.vue
Normal file
@ -0,0 +1,238 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
context: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const dragCounter = ref(0);
|
||||
const fileInput = ref(null);
|
||||
const isDragging = computed(() => dragCounter.value > 0);
|
||||
|
||||
// Get props directly from context
|
||||
const multiple = computed(() => props.context.multiple || false);
|
||||
const accept = computed(() => props.context.accept);
|
||||
const maxSize = computed(() => props.context.maxSize || 5242880); // 5MB default
|
||||
const maxFiles = computed(() => props.context.maxFiles || 10);
|
||||
|
||||
// Initialize files array if multiple is true
|
||||
const currentFiles = ref([]);
|
||||
|
||||
// Handle file selection
|
||||
const handleFiles = (files) => {
|
||||
const selectedFiles = Array.from(files);
|
||||
|
||||
// Validate number of files
|
||||
if (!multiple.value && selectedFiles.length > 1) {
|
||||
props.context.node.setErrors(["Only one file can be uploaded"]);
|
||||
return;
|
||||
}
|
||||
|
||||
// For multiple files, check total count including existing files
|
||||
if (multiple.value) {
|
||||
const totalFiles = [...(currentFiles.value || []), ...selectedFiles];
|
||||
if (totalFiles.length > maxFiles.value) {
|
||||
props.context.node.setErrors([`Maximum ${maxFiles.value} files allowed`]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file size and type
|
||||
const invalidFiles = selectedFiles.filter((file) => {
|
||||
if (file.size > maxSize.value) {
|
||||
return true;
|
||||
}
|
||||
if (accept.value) {
|
||||
const acceptedTypes = accept.value.split(",").map((type) => type.trim());
|
||||
const fileType = file.type;
|
||||
const fileExtension = `.${file.name.split(".").pop()}`;
|
||||
return !acceptedTypes.some((type) => {
|
||||
if (type.startsWith(".")) {
|
||||
return type === fileExtension;
|
||||
}
|
||||
if (type.endsWith("/*")) {
|
||||
return fileType.startsWith(type.slice(0, -2));
|
||||
}
|
||||
return type === fileType;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (invalidFiles.length) {
|
||||
props.context.node.setErrors([
|
||||
`Some files are invalid. Please check file type and size (max ${
|
||||
maxSize.value / 1024 / 1024
|
||||
}MB)`,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update value based on multiple flag
|
||||
if (multiple.value) {
|
||||
// Append new files to existing ones
|
||||
const updatedFiles = [...(currentFiles.value || []), ...selectedFiles];
|
||||
currentFiles.value = updatedFiles;
|
||||
props.context.node.input(updatedFiles);
|
||||
} else {
|
||||
// Single file mode
|
||||
currentFiles.value = [selectedFiles[0]];
|
||||
props.context.node.input(selectedFiles[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Add remove file functionality
|
||||
const removeFile = (fileToRemove) => {
|
||||
if (multiple.value) {
|
||||
const updatedFiles = currentFiles.value.filter(
|
||||
(file) => file !== fileToRemove
|
||||
);
|
||||
currentFiles.value = updatedFiles;
|
||||
props.context.node.input(updatedFiles);
|
||||
} else {
|
||||
currentFiles.value = [];
|
||||
props.context.node.input(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.value++;
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.value--;
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.value = 0;
|
||||
handleFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Click handler
|
||||
const handleClick = () => {
|
||||
fileInput.value?.click();
|
||||
};
|
||||
|
||||
// File input change handler
|
||||
const handleChange = (e) => {
|
||||
handleFiles(e.target.files);
|
||||
};
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// Watch for external value changes
|
||||
watch(
|
||||
() => props.context.value,
|
||||
(newValue) => {
|
||||
if (newValue === null || newValue === undefined) {
|
||||
currentFiles.value = [];
|
||||
} else if (multiple.value && Array.isArray(newValue)) {
|
||||
currentFiles.value = newValue;
|
||||
} else if (!multiple.value) {
|
||||
currentFiles.value = [newValue];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
context.classes.dropzone,
|
||||
isDragging ? 'border-primary ring-2 ring-primary/20' : '',
|
||||
context.disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
]"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
@dragover="handleDragOver"
|
||||
@drop="handleDrop"
|
||||
@click="handleClick"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
class="hidden"
|
||||
@change="handleChange"
|
||||
:disabled="context.disabled"
|
||||
/>
|
||||
|
||||
<!-- Upload Area -->
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-4 p-4 border border-dashed rounded-lg cursor-pointer"
|
||||
>
|
||||
<div class="rounded-full bg-muted p-4">
|
||||
<Icon name="ph:upload-simple" class="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">
|
||||
{{ isDragging ? "Drop files here" : "Upload your files" }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Drag & drop or click to select
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<p>Maximum file size: {{ formatFileSize(maxSize) }}</p>
|
||||
<p v-if="accept">Accepted files: {{ accept }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview -->
|
||||
<div v-if="currentFiles.length > 0" class="border-t mt-4 pt-4">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="file in currentFiles"
|
||||
:key="file.name"
|
||||
class="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Icon
|
||||
name="ph:file-text"
|
||||
class="h-4 w-4 flex-shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm truncate">{{ file.name }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ formatFileSize(file.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 rounded-md p-1 hover:bg-background flex items-center justify-center"
|
||||
@click.stop="removeFile(file)"
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<Icon
|
||||
name="ph:x"
|
||||
class="h-4 w-4 text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
105
components/formkit/inputs/mask/index.vue
Normal file
105
components/formkit/inputs/mask/index.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
context: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const input = ref(null);
|
||||
const inputValue = ref("");
|
||||
|
||||
// Get mask pattern from context
|
||||
const mask = computed(() => props.context.mask || "");
|
||||
|
||||
// Format value according to mask
|
||||
const formatValue = (value) => {
|
||||
if (!value) return "";
|
||||
|
||||
let formatted = "";
|
||||
let valueIndex = 0;
|
||||
|
||||
for (let i = 0; i < mask.value.length && valueIndex < value.length; i++) {
|
||||
const maskChar = mask.value[i];
|
||||
const valueChar = value[valueIndex];
|
||||
|
||||
if (maskChar === "#") {
|
||||
// Only allow numbers
|
||||
if (/\d/.test(valueChar)) {
|
||||
formatted += valueChar;
|
||||
valueIndex++;
|
||||
}
|
||||
} else if (maskChar === "A") {
|
||||
// Only allow letters
|
||||
if (/[a-zA-Z]/.test(valueChar)) {
|
||||
formatted += valueChar;
|
||||
valueIndex++;
|
||||
}
|
||||
} else if (maskChar === "*") {
|
||||
// Allow any character
|
||||
formatted += valueChar;
|
||||
valueIndex++;
|
||||
} else {
|
||||
// Add mask character
|
||||
formatted += maskChar;
|
||||
if (valueChar === maskChar) {
|
||||
valueIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
// Handle input changes
|
||||
const handleInput = (event) => {
|
||||
const value = event.target.value.replace(/\D+/g, "");
|
||||
const formatted = formatValue(value);
|
||||
inputValue.value = formatted;
|
||||
props.context.node.input(formatted);
|
||||
};
|
||||
|
||||
// Handle keydown for backspace
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key === "Backspace" && input.value) {
|
||||
event.preventDefault();
|
||||
const selectionStart = input.value.selectionStart;
|
||||
const selectionEnd = input.value.selectionEnd;
|
||||
|
||||
if (selectionStart === selectionEnd) {
|
||||
const value = inputValue.value.slice(0, -1);
|
||||
const formatted = formatValue(value.replace(/\D+/g, ""));
|
||||
inputValue.value = formatted;
|
||||
props.context.node.input(formatted);
|
||||
} else {
|
||||
const before = inputValue.value.slice(0, selectionStart);
|
||||
const after = inputValue.value.slice(selectionEnd);
|
||||
const formatted = formatValue((before + after).replace(/\D+/g, ""));
|
||||
inputValue.value = formatted;
|
||||
props.context.node.input(formatted);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for external value changes
|
||||
watch(() => props.context.value, (newValue) => {
|
||||
if (newValue !== inputValue.value) {
|
||||
inputValue.value = formatValue(String(newValue || "").replace(/\D+/g, ""));
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
ref="input"
|
||||
type="text"
|
||||
:value="inputValue"
|
||||
:placeholder="context.placeholder || mask"
|
||||
:disabled="context.disabled"
|
||||
:class="context.classes.input"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
</template>
|
101
components/formkit/inputs/otp/index.vue
Normal file
101
components/formkit/inputs/otp/index.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { computed, ref, nextTick } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
context: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const digits = ref([]);
|
||||
const inputs = ref([]);
|
||||
|
||||
// Create an array of empty strings based on length
|
||||
const length = computed(() => props.context.length || 6);
|
||||
|
||||
// Initialize digits array when component mounts
|
||||
onMounted(() => {
|
||||
digits.value = Array(length.value).fill("");
|
||||
});
|
||||
|
||||
// Update the FormKit value when digits change
|
||||
watch(
|
||||
digits,
|
||||
(newDigits) => {
|
||||
const value = newDigits.join("");
|
||||
props.context.node.input(value);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Handle input on each digit
|
||||
const handleInput = async (index, event) => {
|
||||
const value = event.target.value;
|
||||
const lastChar = value.slice(-1);
|
||||
|
||||
// Only allow numbers
|
||||
if (!/^\d*$/.test(lastChar)) {
|
||||
digits.value[index] = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the digit
|
||||
digits.value[index] = lastChar;
|
||||
|
||||
// Move to next input if available
|
||||
if (lastChar && index < length.value - 1) {
|
||||
await nextTick();
|
||||
inputs.value[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle backspace
|
||||
const handleKeydown = async (index, event) => {
|
||||
if (event.key === "Backspace" && !digits.value[index]) {
|
||||
// Move to previous input if current is empty
|
||||
if (index > 0) {
|
||||
await nextTick();
|
||||
inputs.value[index - 1]?.focus();
|
||||
digits.value[index - 1] = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle paste
|
||||
const handlePaste = (event) => {
|
||||
event.preventDefault();
|
||||
const pastedData = event.clipboardData.getData("text");
|
||||
const numbers = pastedData.replace(/\D/g, "").split("");
|
||||
|
||||
numbers.forEach((num, index) => {
|
||||
if (index < length.value) {
|
||||
digits.value[index] = num;
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the next empty input or the last input
|
||||
const nextEmptyIndex = digits.value.findIndex((d) => !d);
|
||||
const focusIndex = nextEmptyIndex === -1 ? length.value - 1 : nextEmptyIndex;
|
||||
inputs.value[focusIndex]?.focus();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="context.classes.inner" @paste="handlePaste">
|
||||
<template v-for="(digit, index) in length" :key="index">
|
||||
<input
|
||||
ref="inputs"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:value="digits[index]"
|
||||
:class="context.classes.digit"
|
||||
:disabled="context.disabled"
|
||||
:aria-label="`Digit ${index + 1}`"
|
||||
@input="handleInput(index, $event)"
|
||||
@keydown="handleKeydown(index, $event)"
|
||||
maxlength="1"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
46
components/formkit/inputs/switch/index.vue
Normal file
46
components/formkit/inputs/switch/index.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
context: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const checked = computed({
|
||||
get: () => props.context.value ?? false,
|
||||
set: (value) => props.context.node.input(value),
|
||||
});
|
||||
|
||||
const id = computed(() => props.context.id);
|
||||
|
||||
// Handle keyboard events for accessibility
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
checked.value = !checked.value;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:id="id"
|
||||
:aria-checked="checked"
|
||||
:disabled="context.disabled"
|
||||
:tabindex="context.disabled ? -1 : 0"
|
||||
class="relative inline-flex h-[20px] w-[36px] flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="[checked ? 'bg-primary' : 'bg-muted']"
|
||||
@click="checked = !checked"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-background shadow-lg ring-0 transition duration-200 ease-in-out"
|
||||
:class="[checked ? 'translate-x-4' : 'translate-x-0']"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
123
components/hoverCard/content.vue
Normal file
123
components/hoverCard/content.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import { inject, ref, computed, onBeforeUnmount } from "vue";
|
||||
|
||||
defineOptions({ name: "HoverCardContent" });
|
||||
|
||||
const props = defineProps({
|
||||
side: {
|
||||
type: String,
|
||||
default: "bottom",
|
||||
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
default: "center",
|
||||
validator: (value) => ["start", "center", "end"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const { isOpen, position, triggerRef, hide, setIsHoveringContent } = inject("hover-card");
|
||||
const contentRef = ref(null);
|
||||
|
||||
const adjustedPosition = computed(() => {
|
||||
if (!contentRef.value || !triggerRef.value) return position.value;
|
||||
|
||||
const content = contentRef.value;
|
||||
const trigger = triggerRef.value;
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight;
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
let { x, y } = position.value;
|
||||
|
||||
// Check if there's room below
|
||||
const hasSpaceBelow = triggerRect.bottom + contentRect.height <= windowHeight;
|
||||
|
||||
// If no space below, show above
|
||||
if (!hasSpaceBelow && props.side === "bottom") {
|
||||
y = triggerRect.top - contentRect.height - 8;
|
||||
}
|
||||
|
||||
// Adjust based on side with safe area consideration
|
||||
if (props.side === "top") {
|
||||
y = triggerRect.top - contentRect.height - 8;
|
||||
} else if (props.side === "right") {
|
||||
x = triggerRect.right + 8;
|
||||
y = Math.min(y, triggerRect.bottom - 20);
|
||||
} else if (props.side === "left") {
|
||||
x = triggerRect.left - contentRect.width - 8;
|
||||
y = Math.min(y, triggerRect.bottom - 20);
|
||||
} else {
|
||||
x = x - 20;
|
||||
}
|
||||
|
||||
// Adjust based on alignment
|
||||
if (props.align === "start") {
|
||||
// No adjustment needed
|
||||
} else if (props.align === "center") {
|
||||
x = triggerRect.left + triggerRect.width / 2 - contentRect.width / 2;
|
||||
} else if (props.align === "end") {
|
||||
x = triggerRect.right - contentRect.width;
|
||||
}
|
||||
|
||||
// Ensure the content stays within viewport
|
||||
if (x + contentRect.width > windowWidth) {
|
||||
x = windowWidth - contentRect.width - 8;
|
||||
}
|
||||
if (x < 8) x = 8;
|
||||
if (y < 8) y = 8;
|
||||
if (y + contentRect.height > windowHeight) {
|
||||
y = windowHeight - contentRect.height - 8;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHoveringContent(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHoveringContent(false);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="hover-card">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="contentRef"
|
||||
class="absolute z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in zoom-in-90"
|
||||
:class="{
|
||||
'data-[side=bottom]:slide-in-from-top-2': side === 'bottom',
|
||||
'data-[side=left]:slide-in-from-right-2': side === 'left',
|
||||
'data-[side=right]:slide-in-from-left-2': side === 'right',
|
||||
'data-[side=top]:slide-in-from-bottom-2': side === 'top',
|
||||
}"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
top: `${adjustedPosition.y}px`,
|
||||
left: `${adjustedPosition.x}px`,
|
||||
}"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hover-card-enter-active,
|
||||
.hover-card-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.hover-card-enter-from,
|
||||
.hover-card-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
85
components/hoverCard/index.vue
Normal file
85
components/hoverCard/index.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { provide, ref, watch, onBeforeUnmount } from 'vue';
|
||||
|
||||
defineOptions({ name: 'HoverCard' });
|
||||
|
||||
const isOpen = ref(false);
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
const triggerRef = ref(null);
|
||||
const isHoveringTrigger = ref(false);
|
||||
const isHoveringContent = ref(false);
|
||||
const originalOverflow = ref('');
|
||||
|
||||
const lockScroll = () => {
|
||||
originalOverflow.value = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
};
|
||||
|
||||
const unlockScroll = () => {
|
||||
document.body.style.overflow = originalOverflow.value;
|
||||
};
|
||||
|
||||
// Watch for changes in isOpen to handle scroll locking
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
lockScroll();
|
||||
} else {
|
||||
unlockScroll();
|
||||
}
|
||||
});
|
||||
|
||||
const show = () => {
|
||||
if (triggerRef.value) {
|
||||
const rect = triggerRef.value.getBoundingClientRect();
|
||||
position.value = {
|
||||
x: rect.left,
|
||||
y: rect.bottom + 4,
|
||||
};
|
||||
isOpen.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
// Only hide if neither trigger nor content is being hovered
|
||||
if (!isHoveringTrigger.value && !isHoveringContent.value) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setIsHoveringTrigger = (value) => {
|
||||
isHoveringTrigger.value = value;
|
||||
if (!value && !isHoveringContent.value) {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const setIsHoveringContent = (value) => {
|
||||
isHoveringContent.value = value;
|
||||
if (!value && !isHoveringTrigger.value) {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
provide('hover-card', {
|
||||
isOpen,
|
||||
position,
|
||||
triggerRef,
|
||||
show,
|
||||
hide,
|
||||
setIsHoveringContent,
|
||||
setIsHoveringTrigger,
|
||||
});
|
||||
|
||||
// Cleanup on component destruction
|
||||
onBeforeUnmount(() => {
|
||||
if (isOpen.value) {
|
||||
unlockScroll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
41
components/hoverCard/trigger.vue
Normal file
41
components/hoverCard/trigger.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { inject, onBeforeUnmount } from 'vue';
|
||||
|
||||
defineOptions({ name: 'HoverCardTrigger' });
|
||||
|
||||
const props = defineProps({
|
||||
delay: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const { triggerRef, show, setIsHoveringTrigger } = inject('hover-card');
|
||||
|
||||
let timeout;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
clearTimeout(timeout);
|
||||
setIsHoveringTrigger(true);
|
||||
timeout = setTimeout(show, props.delay);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(timeout);
|
||||
setIsHoveringTrigger(false);
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="triggerRef"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
11
components/modal/body.vue
Normal file
11
components/modal/body.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "ModalBody",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative pt-0">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
18
components/modal/close.vue
Normal file
18
components/modal/close.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "ModalClose",
|
||||
});
|
||||
|
||||
const closeModal = inject("closeModal");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
@click="closeModal"
|
||||
>
|
||||
<Icon name="ph:x" class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</button>
|
||||
</template>
|
11
components/modal/description.vue
Normal file
11
components/modal/description.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "ModalDescription",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
11
components/modal/footer.vue
Normal file
11
components/modal/footer.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "ModalFooter",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
11
components/modal/header.vue
Normal file
11
components/modal/header.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "ModalHeader",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1.5 text-center sm:text-left mb-4">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
129
components/modal/index.vue
Normal file
129
components/modal/index.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "Modal",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'xl',
|
||||
validator: (value) => ['sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', 'full'].includes(value) || /^\d+px$/.test(value) || /^\d+rem$/.test(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:open"]);
|
||||
const originalOverflow = ref("");
|
||||
|
||||
const modalSize = computed(() => {
|
||||
const sizes = {
|
||||
sm: '384px',
|
||||
md: '448px',
|
||||
lg: '512px',
|
||||
xl: '576px',
|
||||
'2xl': '672px',
|
||||
'3xl': '768px',
|
||||
'4xl': '896px',
|
||||
'5xl': '1024px',
|
||||
'full': '100%'
|
||||
};
|
||||
|
||||
// If it's a predefined size, return the corresponding width
|
||||
if (sizes[props.size]) {
|
||||
return sizes[props.size];
|
||||
}
|
||||
|
||||
// If it's a custom size (e.g., '600px' or '20rem'), return it directly
|
||||
if (/^\d+px$/.test(props.size) || /^\d+rem$/.test(props.size)) {
|
||||
return props.size;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return sizes.xl;
|
||||
});
|
||||
|
||||
const lockScroll = () => {
|
||||
originalOverflow.value = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
};
|
||||
|
||||
const unlockScroll = () => {
|
||||
document.body.style.overflow = originalOverflow.value;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit("update:open", false);
|
||||
};
|
||||
|
||||
// Watch for changes in open state to handle scroll locking
|
||||
watch(
|
||||
() => props.open,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
lockScroll();
|
||||
} else {
|
||||
unlockScroll();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Close on escape key and handle scroll locking
|
||||
onMounted(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === "Escape" && props.open) close();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
// Initial scroll lock if modal is open on mount
|
||||
if (props.open) {
|
||||
lockScroll();
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
// Ensure we unlock scroll when component is destroyed
|
||||
if (props.open) {
|
||||
unlockScroll();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
provide("closeModal", close);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="open" class="fixed inset-0 z-[9999]">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/70"
|
||||
@click="close"
|
||||
/>
|
||||
<!-- Modal Content -->
|
||||
<div
|
||||
class="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%]"
|
||||
>
|
||||
<div class="w-full mx-auto px-4 sm:px-0" :style="{ width: modalSize }">
|
||||
<div
|
||||
class="relative rounded-lg border bg-background p-6 shadow-lg"
|
||||
@click.stop
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
11
components/modal/title.vue
Normal file
11
components/modal/title.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "ModalTitle",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="text-lg font-semibold leading-none tracking-tight">
|
||||
<slot />
|
||||
</h2>
|
||||
</template>
|
239
components/popover/content.vue
Normal file
239
components/popover/content.vue
Normal file
@ -0,0 +1,239 @@
|
||||
<script setup>
|
||||
import { inject, ref, computed } from "vue";
|
||||
|
||||
defineOptions({ name: "PopoverContent" });
|
||||
|
||||
const props = defineProps({
|
||||
side: {
|
||||
type: String,
|
||||
default: "bottom",
|
||||
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
default: "center",
|
||||
validator: (value) => ["start", "center", "end"].includes(value),
|
||||
},
|
||||
sideOffset: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
alignOffset: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { isOpen, position, triggerRef } = inject("popover");
|
||||
const contentRef = ref(null);
|
||||
|
||||
const adjustedPosition = computed(() => {
|
||||
if (!contentRef.value || !triggerRef.value) return position.value;
|
||||
|
||||
const content = contentRef.value;
|
||||
const trigger = triggerRef.value;
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight;
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
let { x, y } = position.value;
|
||||
let adjustedSide = props.side;
|
||||
|
||||
// Check available space in all directions
|
||||
const spaceAbove = triggerRect.top;
|
||||
const spaceBelow = windowHeight - triggerRect.bottom;
|
||||
const spaceLeft = triggerRect.left;
|
||||
const spaceRight = windowWidth - triggerRect.right;
|
||||
|
||||
// Automatically switch sides if there's not enough space
|
||||
if (
|
||||
adjustedSide === "bottom" &&
|
||||
spaceBelow < contentRect.height &&
|
||||
spaceAbove > spaceBelow
|
||||
) {
|
||||
adjustedSide = "top";
|
||||
} else if (
|
||||
adjustedSide === "top" &&
|
||||
spaceAbove < contentRect.height &&
|
||||
spaceBelow > spaceAbove
|
||||
) {
|
||||
adjustedSide = "bottom";
|
||||
} else if (
|
||||
adjustedSide === "right" &&
|
||||
spaceRight < contentRect.width &&
|
||||
spaceLeft > spaceRight
|
||||
) {
|
||||
adjustedSide = "left";
|
||||
} else if (
|
||||
adjustedSide === "left" &&
|
||||
spaceLeft < contentRect.width &&
|
||||
spaceRight > spaceLeft
|
||||
) {
|
||||
adjustedSide = "right";
|
||||
}
|
||||
|
||||
// Position based on adjusted side
|
||||
switch (adjustedSide) {
|
||||
case "top":
|
||||
y = triggerRect.top - contentRect.height - props.sideOffset;
|
||||
break;
|
||||
case "right":
|
||||
x = triggerRect.right + props.sideOffset;
|
||||
break;
|
||||
case "bottom":
|
||||
y = triggerRect.bottom + props.sideOffset;
|
||||
break;
|
||||
case "left":
|
||||
x = triggerRect.left - contentRect.width - props.sideOffset;
|
||||
break;
|
||||
}
|
||||
|
||||
// Adjust alignment
|
||||
switch (props.align) {
|
||||
case "start":
|
||||
if (adjustedSide === "top" || adjustedSide === "bottom") {
|
||||
x = triggerRect.left + props.alignOffset;
|
||||
} else {
|
||||
y = triggerRect.top + props.alignOffset;
|
||||
}
|
||||
break;
|
||||
case "center":
|
||||
if (adjustedSide === "top" || adjustedSide === "bottom") {
|
||||
x =
|
||||
triggerRect.left +
|
||||
triggerRect.width / 2 -
|
||||
contentRect.width / 2 +
|
||||
props.alignOffset;
|
||||
} else {
|
||||
y =
|
||||
triggerRect.top +
|
||||
triggerRect.height / 2 -
|
||||
contentRect.height / 2 +
|
||||
props.alignOffset;
|
||||
}
|
||||
break;
|
||||
case "end":
|
||||
if (adjustedSide === "top" || adjustedSide === "bottom") {
|
||||
x = triggerRect.right - contentRect.width + props.alignOffset;
|
||||
} else {
|
||||
y = triggerRect.bottom - contentRect.height + props.alignOffset;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Ensure the content stays within viewport bounds with padding
|
||||
const VIEWPORT_PADDING = 8;
|
||||
|
||||
if (x + contentRect.width > windowWidth - VIEWPORT_PADDING) {
|
||||
x = windowWidth - contentRect.width - VIEWPORT_PADDING;
|
||||
}
|
||||
if (x < VIEWPORT_PADDING) {
|
||||
x = VIEWPORT_PADDING;
|
||||
}
|
||||
if (y + contentRect.height > windowHeight - VIEWPORT_PADDING) {
|
||||
y = windowHeight - contentRect.height - VIEWPORT_PADDING;
|
||||
}
|
||||
if (y < VIEWPORT_PADDING) {
|
||||
y = VIEWPORT_PADDING;
|
||||
}
|
||||
|
||||
return { x, y, adjustedSide };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="popover">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="contentRef"
|
||||
class="popover-content absolute z-50 min-w-[8rem] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in"
|
||||
:class="{
|
||||
'slide-in-from-top-2': adjustedPosition.adjustedSide === 'bottom',
|
||||
'slide-in-from-right-2': adjustedPosition.adjustedSide === 'left',
|
||||
'slide-in-from-left-2': adjustedPosition.adjustedSide === 'right',
|
||||
'slide-in-from-bottom-2': adjustedPosition.adjustedSide === 'top',
|
||||
}"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
top: `${adjustedPosition.y}px`,
|
||||
left: `${adjustedPosition.x}px`,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.popover-enter-active,
|
||||
.popover-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.popover-enter-from,
|
||||
.popover-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.slide-in-from-top-2 {
|
||||
animation: slideInFromTop 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-in-from-right-2 {
|
||||
animation: slideInFromRight 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-in-from-bottom-2 {
|
||||
animation: slideInFromBottom 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-in-from-left-2 {
|
||||
animation: slideInFromLeft 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideInFromTop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInFromRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(0.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInFromBottom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInFromLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
74
components/popover/index.vue
Normal file
74
components/popover/index.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { provide, ref, watch } from 'vue';
|
||||
|
||||
defineOptions({ name: 'Popover' });
|
||||
|
||||
const isOpen = ref(false);
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
const triggerRef = ref(null);
|
||||
const originalOverflow = ref('');
|
||||
|
||||
const lockScroll = () => {
|
||||
originalOverflow.value = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
};
|
||||
|
||||
const unlockScroll = () => {
|
||||
document.body.style.overflow = originalOverflow.value;
|
||||
};
|
||||
|
||||
// Watch for changes in isOpen to handle scroll locking
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
lockScroll();
|
||||
} else {
|
||||
unlockScroll();
|
||||
}
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
if (isOpen.value && triggerRef.value) {
|
||||
const rect = triggerRef.value.getBoundingClientRect();
|
||||
position.value = {
|
||||
x: rect.left,
|
||||
y: rect.bottom + 4,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
provide('popover', {
|
||||
isOpen,
|
||||
position,
|
||||
triggerRef,
|
||||
toggle,
|
||||
close,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!triggerRef.value?.contains(target) && !event.target.closest('.popover-content')) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', close);
|
||||
// Ensure we unlock scroll when component is destroyed
|
||||
if (isOpen.value) {
|
||||
unlockScroll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
13
components/popover/trigger.vue
Normal file
13
components/popover/trigger.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
defineOptions({ name: 'PopoverTrigger' });
|
||||
|
||||
const { triggerRef, toggle } = inject('popover');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="triggerRef" @click.stop="toggle">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
132
components/progress/index.vue
Normal file
132
components/progress/index.vue
Normal file
@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
defineOptions({ name: "Progress" });
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: (value) => value >= 0 && value <= 100,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) => ["sm", "default", "lg"].includes(value),
|
||||
},
|
||||
showValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) =>
|
||||
["default", "success", "info", "warning", "danger"].includes(value),
|
||||
},
|
||||
animated: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
striped: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "!h-2",
|
||||
default: "!h-4",
|
||||
lg: "!h-6",
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: "bg-primary",
|
||||
success: "bg-[rgb(var(--success))]",
|
||||
info: "bg-[rgb(var(--info))]",
|
||||
warning: "bg-[rgb(var(--warning))]",
|
||||
danger: "bg-[rgb(var(--danger))]",
|
||||
};
|
||||
|
||||
const textVariantClasses = {
|
||||
default: "!text-primary-foreground",
|
||||
success: "!text-[rgb(var(--success-foreground))]",
|
||||
info: "!text-[rgb(var(--info-foreground))]",
|
||||
warning: "!text-[rgb(var(--warning-foreground))]",
|
||||
danger: "!text-[rgb(var(--danger-foreground))]",
|
||||
};
|
||||
|
||||
const progressClasses = computed(() => {
|
||||
return [
|
||||
sizeClasses[props.size],
|
||||
variantClasses[props.variant],
|
||||
{
|
||||
"animate-progress": props.animated,
|
||||
"bg-striped": props.striped,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const valueStyles = computed(() => ({
|
||||
width: `${props.value}%`,
|
||||
transition: props.animated ? "width 0.3s ease-in-out" : "none",
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden rounded-full bg-secondary"
|
||||
role="progressbar"
|
||||
:aria-valuenow="value"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div
|
||||
:class="progressClasses"
|
||||
class="h-full w-full flex-1 rounded-full transition-all"
|
||||
:style="valueStyles"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showValue"
|
||||
class="absolute inset-0 flex items-center justify-center text-xs font-medium"
|
||||
:class="textVariantClasses[variant]"
|
||||
>
|
||||
{{ value }}%
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-striped {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.15) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
rgba(255, 255, 255, 0.15) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 1rem 1rem;
|
||||
}
|
||||
|
||||
.animate-progress {
|
||||
animation: progress-animation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-animation {
|
||||
from {
|
||||
background-position: 1rem 0;
|
||||
}
|
||||
to {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure text is visible on all backgrounds */
|
||||
.text-xs {
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
206
components/scrollArea/index.vue
Normal file
206
components/scrollArea/index.vue
Normal file
@ -0,0 +1,206 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'ScrollArea' });
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'hover',
|
||||
validator: (value) => ['always', 'scroll', 'hover', 'auto'].includes(value),
|
||||
},
|
||||
orientation: {
|
||||
type: String,
|
||||
default: 'vertical',
|
||||
validator: (value) => ['vertical', 'horizontal', 'both'].includes(value),
|
||||
},
|
||||
scrollHideDelay: {
|
||||
type: Number,
|
||||
default: 600,
|
||||
},
|
||||
});
|
||||
|
||||
const viewport = ref(null);
|
||||
const scrollbar = ref(null);
|
||||
const thumb = ref(null);
|
||||
|
||||
let isDragging = false;
|
||||
let startPosition = { x: 0, y: 0 };
|
||||
let startScroll = { left: 0, top: 0 };
|
||||
let hideScrollbarTimeout;
|
||||
|
||||
const showScrollbar = ref(props.type === 'always');
|
||||
|
||||
const isVertical = computed(() => props.orientation !== 'horizontal');
|
||||
const isHorizontal = computed(() => props.orientation !== 'vertical');
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (props.type === 'hover') {
|
||||
showScrollbar.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.type === 'hover' && !isDragging) {
|
||||
showScrollbar.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (props.type === 'scroll') {
|
||||
showScrollbar.value = true;
|
||||
clearTimeout(hideScrollbarTimeout);
|
||||
hideScrollbarTimeout = setTimeout(() => {
|
||||
if (!isDragging) showScrollbar.value = false;
|
||||
}, props.scrollHideDelay);
|
||||
}
|
||||
updateThumbPosition();
|
||||
};
|
||||
|
||||
const updateThumbPosition = () => {
|
||||
if (!viewport.value || !thumb.value) return;
|
||||
|
||||
const {
|
||||
scrollTop,
|
||||
scrollLeft,
|
||||
scrollHeight,
|
||||
scrollWidth,
|
||||
clientHeight,
|
||||
clientWidth,
|
||||
} = viewport.value;
|
||||
|
||||
if (isVertical.value) {
|
||||
const thumbHeight = (clientHeight / scrollHeight) * clientHeight;
|
||||
const thumbPosition = (scrollTop / scrollHeight) * clientHeight;
|
||||
thumb.value.style.height = `${thumbHeight}px`;
|
||||
thumb.value.style.transform = `translateY(${thumbPosition}px)`;
|
||||
thumb.value.style.width = '6px';
|
||||
}
|
||||
|
||||
if (isHorizontal.value) {
|
||||
const thumbWidth = (clientWidth / scrollWidth) * clientWidth;
|
||||
const thumbPosition = (scrollLeft / scrollWidth) * clientWidth;
|
||||
thumb.value.style.width = `${thumbWidth}px`;
|
||||
thumb.value.style.transform = `translateX(${thumbPosition}px)`;
|
||||
thumb.value.style.height = '6px';
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbMousedown = (e) => {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
startPosition = { x: e.clientX, y: e.clientY };
|
||||
startScroll = {
|
||||
left: viewport.value.scrollLeft,
|
||||
top: viewport.value.scrollTop,
|
||||
};
|
||||
document.addEventListener('mousemove', handleThumbMousemove);
|
||||
document.addEventListener('mouseup', handleThumbMouseup);
|
||||
};
|
||||
|
||||
const handleThumbMousemove = (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const { scrollHeight, scrollWidth, clientHeight, clientWidth } = viewport.value;
|
||||
|
||||
if (isVertical.value) {
|
||||
const delta = e.clientY - startPosition.y;
|
||||
const scrollFactor = scrollHeight / clientHeight;
|
||||
viewport.value.scrollTop = startScroll.top + delta * scrollFactor;
|
||||
}
|
||||
|
||||
if (isHorizontal.value) {
|
||||
const delta = e.clientX - startPosition.x;
|
||||
const scrollFactor = scrollWidth / clientWidth;
|
||||
viewport.value.scrollLeft = startScroll.left + delta * scrollFactor;
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbMouseup = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleThumbMousemove);
|
||||
document.removeEventListener('mouseup', handleThumbMouseup);
|
||||
if (props.type === 'hover') {
|
||||
handleMouseLeave();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateThumbPosition();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(hideScrollbarTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div
|
||||
ref="viewport"
|
||||
class="scrollarea-viewport h-full w-full overflow-auto"
|
||||
:class="{
|
||||
'overflow-y-auto overflow-x-hidden': orientation === 'vertical',
|
||||
'overflow-x-auto overflow-y-hidden': orientation === 'horizontal',
|
||||
'overflow-auto': orientation === 'both',
|
||||
}"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="scrollarea-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="showScrollbar"
|
||||
ref="scrollbar"
|
||||
class="scrollarea-scrollbar touch-none select-none transition-colors"
|
||||
:class="{
|
||||
'right-0.5 top-0.5 bottom-0.5 w-2': orientation === 'vertical',
|
||||
'bottom-0.5 left-0.5 right-0.5 h-2': orientation === 'horizontal',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
ref="thumb"
|
||||
class="scrollarea-thumb relative rounded-full bg-border hover:bg-foreground/50"
|
||||
@mousedown="handleThumbMousedown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrollarea-viewport {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scrollarea-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollarea-scrollbar {
|
||||
position: absolute;
|
||||
background-color: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.scrollarea-thumb {
|
||||
position: absolute;
|
||||
background-color: rgb(var(--border));
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.scrollarea-thumb:hover {
|
||||
background-color: rgb(var(--foreground), 0.5);
|
||||
}
|
||||
|
||||
.scrollarea-viewport {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
</style>
|
39
components/separator/index.vue
Normal file
39
components/separator/index.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'Separator' });
|
||||
|
||||
const props = defineProps({
|
||||
orientation: {
|
||||
type: String,
|
||||
default: 'horizontal',
|
||||
validator: (value) => ['horizontal', 'vertical'].includes(value),
|
||||
},
|
||||
decorative: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Compute ARIA attributes based on decorative prop
|
||||
const ariaAttributes = computed(() => {
|
||||
if (props.decorative) {
|
||||
return {
|
||||
role: 'none',
|
||||
'aria-hidden': 'true',
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: 'separator',
|
||||
'aria-orientation': props.orientation,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-bind="ariaAttributes"
|
||||
:class="[
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
]"
|
||||
/>
|
||||
</template>
|
59
components/skeleton/index.vue
Normal file
59
components/skeleton/index.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'Skeleton' });
|
||||
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'card', 'avatar', 'title', 'text'].includes(value),
|
||||
},
|
||||
animate: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'card':
|
||||
return 'h-48 w-full rounded-lg';
|
||||
case 'avatar':
|
||||
return 'h-12 w-12 rounded-full';
|
||||
case 'title':
|
||||
return 'h-4 w-1/4 rounded-lg';
|
||||
case 'text':
|
||||
return 'h-4 w-full rounded-lg';
|
||||
default:
|
||||
return props.className || 'h-4 w-full rounded-lg';
|
||||
}
|
||||
});
|
||||
|
||||
const classes = computed(() => [
|
||||
'bg-muted animate-pulse',
|
||||
variantClasses.value,
|
||||
{ 'animate-none': !props.animate },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
</style>
|
9
components/stepper/description.vue
Normal file
9
components/stepper/description.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'StepperDescription' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
52
components/stepper/index.vue
Normal file
52
components/stepper/index.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { provide, ref, watch } from 'vue';
|
||||
|
||||
defineOptions({ name: 'Stepper' });
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
orientation: {
|
||||
type: String,
|
||||
default: 'horizontal',
|
||||
validator: (value) => ['horizontal', 'vertical'].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const currentStep = ref(props.modelValue);
|
||||
|
||||
// Create stepper context
|
||||
const stepperContext = {
|
||||
currentStep,
|
||||
updateStep: (step) => {
|
||||
currentStep.value = step;
|
||||
emit('update:modelValue', step);
|
||||
},
|
||||
orientation: props.orientation
|
||||
};
|
||||
|
||||
// Provide stepper context to child components
|
||||
provide('stepper', stepperContext);
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
currentStep.value = newValue;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'flex',
|
||||
orientation === 'horizontal'
|
||||
? 'flex-row items-center space-x-1'
|
||||
: 'flex-col space-y-1'
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
34
components/stepper/indicator.vue
Normal file
34
components/stepper/indicator.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { inject, computed } from "vue";
|
||||
|
||||
defineOptions({ name: "StepperIndicator" });
|
||||
|
||||
const props = defineProps({
|
||||
step: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { currentStep } = inject("stepper");
|
||||
|
||||
const status = computed(() => {
|
||||
if (currentStep.value === props.step) return "current";
|
||||
if (currentStep.value > props.step) return "complete";
|
||||
return "pending";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-full border-2 transition-colors',
|
||||
status === 'complete' &&
|
||||
'bg-primary border-primary text-primary-foreground',
|
||||
status === 'current' && 'border-primary text-primary',
|
||||
status === 'pending' && ' text-muted-foreground',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
40
components/stepper/item.vue
Normal file
40
components/stepper/item.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'StepperItem' });
|
||||
|
||||
const props = defineProps({
|
||||
step: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const stepper = inject('stepper');
|
||||
|
||||
const status = computed(() => {
|
||||
if (!stepper) return 'pending';
|
||||
if (stepper.currentStep.value === props.step) return 'current';
|
||||
if (stepper.currentStep.value > props.step) return 'complete';
|
||||
return 'pending';
|
||||
});
|
||||
|
||||
const orientation = computed(() => stepper?.orientation || 'horizontal');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'relative flex',
|
||||
orientation === 'horizontal' ? 'items-center' : 'flex-col',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
|
||||
]"
|
||||
:data-status="status"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
19
components/stepper/separator.vue
Normal file
19
components/stepper/separator.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'StepperSeparator' });
|
||||
|
||||
const stepper = inject('stepper');
|
||||
const orientation = computed(() => stepper?.orientation || 'horizontal');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'transition-colors',
|
||||
orientation === 'horizontal'
|
||||
? 'h-[2px] w-10 bg-border mx-1'
|
||||
: 'w-[2px] h-10 bg-border my-1 ml-4'
|
||||
]"
|
||||
/>
|
||||
</template>
|
9
components/stepper/title.vue
Normal file
9
components/stepper/title.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'StepperTitle' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 class="font-medium leading-none">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
28
components/stepper/trigger.vue
Normal file
28
components/stepper/trigger.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
defineOptions({ name: 'StepperTrigger' });
|
||||
|
||||
const props = defineProps({
|
||||
step: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const { updateStep } = inject('stepper');
|
||||
|
||||
const handleClick = () => {
|
||||
updateStep(props.step);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 group"
|
||||
role="button"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
9
components/table/body.vue
Normal file
9
components/table/body.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
defineOptions({ name: "TableBody" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
9
components/table/cell.vue
Normal file
9
components/table/cell.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
defineOptions({ name: "TableCell" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td class="px-4 py-2 align-middle">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
11
components/table/head.vue
Normal file
11
components/table/head.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineOptions({ name: "TableHead" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th
|
||||
class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
9
components/table/header.vue
Normal file
9
components/table/header.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
defineOptions({ name: "TableHeader" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead class="[&_tr]:border-b bg-muted/50">
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
18
components/table/index.vue
Normal file
18
components/table/index.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
defineOptions({ name: "Table" });
|
||||
|
||||
const props = defineProps({
|
||||
hover: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full overflow-auto">
|
||||
<table class="w-full caption-bottom text-sm">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
9
components/table/row.vue
Normal file
9
components/table/row.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
defineOptions({ name: "TableRow" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="border-b transition-colors hover:bg-muted/50">
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
71
components/tabs/content.vue
Normal file
71
components/tabs/content.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'TabsContent' });
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const { activeTab, orientation } = inject('tabs');
|
||||
|
||||
const isActive = computed(() => activeTab.value === props.value);
|
||||
|
||||
const transitionClasses = computed(() => {
|
||||
const baseClasses = 'transition-all duration-300';
|
||||
|
||||
if (orientation === 'responsive') {
|
||||
return {
|
||||
[baseClasses]: true,
|
||||
'translate-x-0 opacity-100': isActive.value,
|
||||
'translate-x-2 opacity-0': !isActive.value,
|
||||
'md:translate-x-0 md:translate-y-0': isActive.value,
|
||||
'md:translate-x-0 md:translate-y-2 md:opacity-0': !isActive.value,
|
||||
'hidden': !isActive.value
|
||||
};
|
||||
}
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
return {
|
||||
[baseClasses]: true,
|
||||
'translate-y-0 opacity-100': isActive.value,
|
||||
'translate-y-2 opacity-0': !isActive.value,
|
||||
'hidden': !isActive.value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
[baseClasses]: true,
|
||||
'translate-x-0 opacity-100': isActive.value,
|
||||
'translate-x-2 opacity-0': !isActive.value,
|
||||
'hidden': !isActive.value
|
||||
};
|
||||
});
|
||||
|
||||
const contentClasses = computed(() => [
|
||||
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'mt-2',
|
||||
transitionClasses.value
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="tabpanel"
|
||||
:class="contentClasses"
|
||||
:tabindex="isActive ? 0 : -1"
|
||||
:aria-hidden="!isActive"
|
||||
>
|
||||
<slot v-if="isActive" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
[role="tabpanel"] {
|
||||
transform-origin: top left;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
</style>
|
73
components/tabs/index.vue
Normal file
73
components/tabs/index.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { provide, ref, watch } from 'vue';
|
||||
|
||||
defineOptions({ name: 'Tabs' });
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
},
|
||||
defaultValue: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
orientation: {
|
||||
type: String,
|
||||
default: 'horizontal',
|
||||
validator: (value) => ['horizontal', 'vertical', 'responsive'].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const activeTab = ref(props.modelValue);
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
activeTab.value = newValue;
|
||||
}, { immediate: true });
|
||||
|
||||
const updateTab = (value) => {
|
||||
activeTab.value = value;
|
||||
emit('update:modelValue', value);
|
||||
};
|
||||
|
||||
provide('tabs', {
|
||||
activeTab,
|
||||
updateTab,
|
||||
orientation: props.orientation
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'w-full',
|
||||
{
|
||||
'flex gap-6': orientation === 'vertical',
|
||||
'block': orientation === 'horizontal',
|
||||
'block md:flex md:gap-6': orientation === 'responsive'
|
||||
}
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'relative',
|
||||
{
|
||||
'flex-1': orientation === 'vertical',
|
||||
'w-full': orientation === 'horizontal',
|
||||
'w-full md:flex-1': orientation === 'responsive'
|
||||
}
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.relative {
|
||||
perspective: 1000px;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
</style>
|
32
components/tabs/list.vue
Normal file
32
components/tabs/list.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'TabsList' });
|
||||
|
||||
const { orientation } = inject('tabs');
|
||||
|
||||
const listClasses = computed(() => {
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-[var(--radius)] bg-muted p-1 text-muted-foreground';
|
||||
|
||||
if (orientation === 'responsive') {
|
||||
return [
|
||||
baseClasses,
|
||||
'h-10 w-full space-x-1',
|
||||
'md:h-fit md:w-full md:flex-col md:space-x-0 md:space-y-1'
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
baseClasses,
|
||||
orientation === 'vertical'
|
||||
? 'flex-col h-fit w-full space-y-1'
|
||||
: 'h-10 w-full space-x-1'
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="listClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
58
components/tabs/trigger.vue
Normal file
58
components/tabs/trigger.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { inject, computed } from "vue";
|
||||
|
||||
defineOptions({ name: "TabsTrigger" });
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { activeTab, updateTab, orientation } = inject("tabs");
|
||||
|
||||
const isActive = computed(() => activeTab.value === props.value);
|
||||
|
||||
const triggerClasses = computed(() => [
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius)] px-3 py-1.5 text-sm font-medium ring-offset-background transition-all duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 relative",
|
||||
{
|
||||
"w-full": orientation === "vertical",
|
||||
"flex-1": orientation === "horizontal",
|
||||
"flex-1 md:w-full": orientation === "responsive",
|
||||
},
|
||||
isActive.value && "bg-background text-foreground shadow",
|
||||
!props.disabled && "hover:text-foreground",
|
||||
]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.disabled) {
|
||||
updateTab(props.value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:disabled="disabled"
|
||||
:data-state="isActive ? 'active' : 'inactive'"
|
||||
:class="triggerClasses"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
button {
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
}
|
||||
</style>
|
58
components/theme/switcher.vue
Normal file
58
components/theme/switcher.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "ThemeSwitcher",
|
||||
});
|
||||
|
||||
const { currentTheme, setTheme } = useTheme();
|
||||
|
||||
const themes = [
|
||||
{
|
||||
value: "default",
|
||||
label: "Light",
|
||||
icon: "ph:sun",
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: "ph:moon",
|
||||
},
|
||||
];
|
||||
|
||||
const toggle = () => {
|
||||
const currentIndex = themes.findIndex(
|
||||
(theme) => theme.value === currentTheme.value
|
||||
);
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
setTheme(themes[nextIndex].value);
|
||||
};
|
||||
|
||||
// Watch for theme changes to save to localStorage
|
||||
watch(currentTheme, (newTheme) => {
|
||||
localStorage.setItem("theme", newTheme);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="toggle"
|
||||
class="relative"
|
||||
title="Toggle theme"
|
||||
>
|
||||
<ClientOnly>
|
||||
<Icon
|
||||
v-for="theme in themes"
|
||||
:key="theme.value"
|
||||
:name="theme.icon"
|
||||
class="h-5 w-5 transition-all"
|
||||
:class="{
|
||||
'scale-100 duration-300 rotate-0': currentTheme === theme.value,
|
||||
'scale-0 absolute duration-200 rotate-90':
|
||||
currentTheme !== theme.value,
|
||||
}"
|
||||
/>
|
||||
</ClientOnly>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</template>
|
100
components/toast/index.vue
Normal file
100
components/toast/index.vue
Normal file
@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
import { toasts } from "~/composables/useToast";
|
||||
import { TransitionGroup } from "vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
position: {
|
||||
type: String,
|
||||
default: "bottom-right",
|
||||
validator: (value) =>
|
||||
["top-left", "top-right", "bottom-left", "bottom-right"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const positionClasses = {
|
||||
"top-left": "top-0 left-0",
|
||||
"top-right": "top-0 right-0",
|
||||
"bottom-left": "bottom-0 left-0",
|
||||
"bottom-right": "bottom-0 right-0",
|
||||
};
|
||||
|
||||
// Compute animation classes based on position
|
||||
const getAnimationClasses = (position) => {
|
||||
const isTop = position.startsWith("top");
|
||||
return {
|
||||
enter: {
|
||||
active: "transition ease-out duration-300",
|
||||
from: `opacity-0 ${isTop ? "-translate-y-2" : "translate-y-2"}`,
|
||||
to: "opacity-100 translate-y-0",
|
||||
},
|
||||
leave: {
|
||||
active: "transition ease-in duration-200",
|
||||
from: "opacity-100 translate-y-0",
|
||||
to: `opacity-0 ${isTop ? "-translate-y-2" : "translate-y-2"}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const animations = computed(() => getAnimationClasses(props.position));
|
||||
|
||||
// Compute whether there are active toasts
|
||||
const hasActiveToasts = computed(() => toasts[props.position].value.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'fixed z-[100] flex max-h-screen w-full flex-col gap-2 p-4 sm:max-w-[420px]',
|
||||
positionClasses[position],
|
||||
{
|
||||
'flex-col': position.startsWith('top'),
|
||||
'flex-col-reverse': position.startsWith('bottom'),
|
||||
'pointer-events-none': !hasActiveToasts,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<TransitionGroup
|
||||
:enter-active-class="animations.enter.active"
|
||||
:enter-from-class="animations.enter.from"
|
||||
:enter-to-class="animations.enter.to"
|
||||
:leave-active-class="animations.leave.active"
|
||||
:leave-from-class="animations.leave.from"
|
||||
:leave-to-class="animations.leave.to"
|
||||
>
|
||||
<div
|
||||
v-for="toast in toasts[position].value"
|
||||
:key="toast.id"
|
||||
class="group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all"
|
||||
:class="{
|
||||
'bg-background text-foreground': toast.type === 'default',
|
||||
'bg-danger text-danger-foreground': toast.type === 'error',
|
||||
'bg-primary text-primary-foreground': toast.type === 'success',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon
|
||||
v-if="toast.type === 'error'"
|
||||
name="ph:x-circle"
|
||||
class="h-5 w-5 text-danger-foreground"
|
||||
/>
|
||||
<Icon
|
||||
v-if="toast.type === 'success'"
|
||||
name="ph:check-circle"
|
||||
class="h-5 w-5 text-primary-foreground"
|
||||
/>
|
||||
<div class="grid gap-1">
|
||||
<div class="text-sm font-semibold">{{ toast.title }}</div>
|
||||
<div class="text-sm opacity-90">{{ toast.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100"
|
||||
@click="useToast().remove(toast.id, toast.position)"
|
||||
>
|
||||
<Icon name="ph:x" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
53
components/tooltip/content.vue
Normal file
53
components/tooltip/content.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { inject, watch } from "vue";
|
||||
|
||||
defineOptions({ name: "TooltipContent" });
|
||||
|
||||
const props = defineProps({
|
||||
side: {
|
||||
type: String,
|
||||
default: "top",
|
||||
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
|
||||
},
|
||||
sideOffset: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const { floating, isOpen, floatingStyles, arrowRef, arrowStyles, placement } =
|
||||
inject("tooltip");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="floating"
|
||||
:style="floatingStyles"
|
||||
class="z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 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"
|
||||
:data-side="placement?.split('-')[0]"
|
||||
role="tooltip"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
ref="arrowRef"
|
||||
:style="arrowStyles"
|
||||
class="absolute z-[-1] h-2 w-2 rotate-45 bg-popover border"
|
||||
:class="{
|
||||
'-top-1 border-b border-r': placement?.includes('bottom'),
|
||||
'-bottom-1 border-t border-l': placement?.includes('top'),
|
||||
'-right-1 border-b border-l': placement?.includes('left'),
|
||||
'-left-1 border-t border-r': placement?.includes('right')
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
91
components/tooltip/index.vue
Normal file
91
components/tooltip/index.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { useFloating, offset, flip, shift, arrow } from '@floating-ui/vue';
|
||||
import { computed, ref, provide } from 'vue';
|
||||
|
||||
defineOptions({ name: 'Tooltip' });
|
||||
|
||||
const props = defineProps({
|
||||
side: {
|
||||
type: String,
|
||||
default: 'top',
|
||||
validator: (value) => ['top', 'right', 'bottom', 'left'].includes(value),
|
||||
},
|
||||
delayDuration: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
skipDelayDuration: {
|
||||
type: Number,
|
||||
default: 300,
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = ref(false);
|
||||
const arrowRef = ref(null);
|
||||
const reference = ref(null);
|
||||
const floating = ref(null);
|
||||
|
||||
const { floatingStyles, middlewareData, placement } = useFloating(reference, floating, {
|
||||
placement: computed(() => props.side || 'top'),
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip(),
|
||||
shift(),
|
||||
arrow({ element: arrowRef }),
|
||||
],
|
||||
});
|
||||
|
||||
const arrowStyles = computed(() => {
|
||||
if (!middlewareData.value.arrow) return {};
|
||||
|
||||
const { x, y } = middlewareData.value.arrow;
|
||||
|
||||
const staticSide = {
|
||||
top: 'bottom',
|
||||
right: 'left',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
}[placement.value.split('-')[0]];
|
||||
|
||||
return {
|
||||
left: x != null ? `${x}px` : '',
|
||||
top: y != null ? `${y}px` : '',
|
||||
[staticSide]: '-4px',
|
||||
};
|
||||
});
|
||||
|
||||
let showTimeout;
|
||||
let hideTimeout;
|
||||
|
||||
const show = () => {
|
||||
clearTimeout(hideTimeout);
|
||||
showTimeout = setTimeout(() => {
|
||||
isOpen.value = true;
|
||||
}, props.delayDuration);
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
clearTimeout(showTimeout);
|
||||
hideTimeout = setTimeout(() => {
|
||||
isOpen.value = false;
|
||||
}, props.skipDelayDuration);
|
||||
};
|
||||
|
||||
provide('tooltip', {
|
||||
reference,
|
||||
floating,
|
||||
arrowRef,
|
||||
isOpen,
|
||||
floatingStyles,
|
||||
arrowStyles,
|
||||
show,
|
||||
hide,
|
||||
placement,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
19
components/tooltip/trigger.vue
Normal file
19
components/tooltip/trigger.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
defineOptions({ name: 'TooltipTrigger' });
|
||||
|
||||
const { reference, show, hide } = inject('tooltip');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="reference"
|
||||
@mouseenter="show"
|
||||
@mouseleave="hide"
|
||||
@focus="show"
|
||||
@blur="hide"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
166
composables/dataTable.js
Normal file
166
composables/dataTable.js
Normal file
@ -0,0 +1,166 @@
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
|
||||
export const useDataTable = (initialData, initialColumns) => {
|
||||
const data = ref(initialData);
|
||||
const columns = ref(initialColumns);
|
||||
const sortBy = ref("");
|
||||
const sortDesc = ref(false);
|
||||
const searchQuery = ref("");
|
||||
const currentPage = ref(1);
|
||||
const itemsPerPage = ref(10);
|
||||
const itemsPerPageOptions = ref([5, 10, 25, 50, 100]);
|
||||
const columnFilters = ref({});
|
||||
|
||||
// Debounced search to prevent excessive filtering
|
||||
const debouncedSearch = ref(searchQuery.value);
|
||||
watchDebounced(
|
||||
searchQuery,
|
||||
(newValue) => {
|
||||
debouncedSearch.value = newValue;
|
||||
currentPage.value = 1;
|
||||
},
|
||||
{ debounce: 300 }
|
||||
);
|
||||
|
||||
// Memoize cell value getter
|
||||
const getCellValue = memoize((item, column) => {
|
||||
if (column.path) {
|
||||
return column.path.split(".").reduce((obj, key) => obj?.[key], item);
|
||||
}
|
||||
return item[column.key];
|
||||
});
|
||||
|
||||
// Create index for faster searching
|
||||
const searchIndex = computed(() => {
|
||||
const index = new Map();
|
||||
data.value.forEach((item, idx) => {
|
||||
const searchableValues = columns.value
|
||||
.map((column) => getCellValue(item, column))
|
||||
.filter(Boolean)
|
||||
.map((value) => value.toString().toLowerCase())
|
||||
.join(" ");
|
||||
index.set(idx, searchableValues);
|
||||
});
|
||||
return index;
|
||||
});
|
||||
|
||||
const filteredData = computed(() => {
|
||||
// Start with indices instead of full objects for better memory usage
|
||||
let indices = [...data.value.keys()];
|
||||
|
||||
// Apply column filters
|
||||
Object.entries(columnFilters.value).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
const column = columns.value.find((col) => col.key === key);
|
||||
indices = indices.filter((idx) => {
|
||||
const item = data.value[idx];
|
||||
const cellValue = getCellValue(item, column);
|
||||
return cellValue
|
||||
?.toString()
|
||||
.toLowerCase()
|
||||
.includes(value.toLowerCase());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Apply search filter using index
|
||||
if (debouncedSearch.value) {
|
||||
const searchTerm = debouncedSearch.value.toLowerCase();
|
||||
indices = indices.filter((idx) =>
|
||||
searchIndex.value.get(idx).includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sorting only to visible page
|
||||
if (sortBy.value) {
|
||||
const sortColumn = columns.value.find((col) => col.key === sortBy.value);
|
||||
indices.sort((aIdx, bIdx) => {
|
||||
const aVal = getCellValue(data.value[aIdx], sortColumn);
|
||||
const bVal = getCellValue(data.value[bIdx], sortColumn);
|
||||
|
||||
if (sortDesc.value) {
|
||||
return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
|
||||
}
|
||||
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
return indices;
|
||||
});
|
||||
|
||||
const paginatedData = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value;
|
||||
const end = start + itemsPerPage.value;
|
||||
// Only map indices to full objects for visible page
|
||||
return filteredData.value.slice(start, end).map((idx) => data.value[idx]);
|
||||
});
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil(filteredData.value.length / itemsPerPage.value)
|
||||
);
|
||||
|
||||
// Cache sort results
|
||||
const sortCache = new Map();
|
||||
const toggleSort = (column) => {
|
||||
if (column.sortable) {
|
||||
if (sortBy.value === column.key) {
|
||||
sortDesc.value = !sortDesc.value;
|
||||
} else {
|
||||
sortBy.value = column.key;
|
||||
sortDesc.value = false;
|
||||
}
|
||||
// Clear cache when sort changes
|
||||
sortCache.clear();
|
||||
}
|
||||
};
|
||||
|
||||
const setItemsPerPage = (value) => {
|
||||
itemsPerPage.value = parseInt(value, 10);
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
// Debounced column filter
|
||||
const setColumnFilter = debounce((column, value) => {
|
||||
columnFilters.value[column] = value;
|
||||
currentPage.value = 1;
|
||||
}, 300);
|
||||
|
||||
return {
|
||||
data,
|
||||
columns,
|
||||
sortBy,
|
||||
sortDesc,
|
||||
searchQuery,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
paginatedData,
|
||||
totalPages,
|
||||
toggleSort,
|
||||
itemsPerPageOptions,
|
||||
columnFilters,
|
||||
setItemsPerPage,
|
||||
setColumnFilter,
|
||||
filteredData,
|
||||
getCellValue,
|
||||
};
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
function memoize(fn) {
|
||||
const cache = new Map();
|
||||
return (...args) => {
|
||||
const key = JSON.stringify(args);
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, fn(...args));
|
||||
}
|
||||
return cache.get(key);
|
||||
};
|
||||
}
|
||||
|
||||
function debounce(fn, delay) {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
22
composables/theme.js
Normal file
22
composables/theme.js
Normal file
@ -0,0 +1,22 @@
|
||||
export const useTheme = () => {
|
||||
const currentTheme = useState("theme", () => "default");
|
||||
|
||||
const setTheme = (theme) => {
|
||||
// Remove existing theme classes
|
||||
if (process.client) {
|
||||
document.documentElement.classList.remove("theme-dark", "theme-purple");
|
||||
|
||||
// Add new theme class if not default
|
||||
if (theme !== "default") {
|
||||
document.documentElement.classList.add(`theme-${theme}`);
|
||||
}
|
||||
}
|
||||
|
||||
currentTheme.value = theme;
|
||||
};
|
||||
|
||||
return {
|
||||
currentTheme,
|
||||
setTheme,
|
||||
};
|
||||
};
|
60
composables/useLayoutConfig.js
Normal file
60
composables/useLayoutConfig.js
Normal file
@ -0,0 +1,60 @@
|
||||
export const useLayoutConfig = () => {
|
||||
const layout = useState("layout-config", () => ({
|
||||
isRTL: false,
|
||||
sidebarLayout: "vertical", // 'vertical' | 'horizontal'
|
||||
isMinimized: false,
|
||||
}));
|
||||
|
||||
// Initialize layout on client side
|
||||
if (process.client) {
|
||||
const savedLayout = localStorage.getItem("layout-config");
|
||||
if (savedLayout) {
|
||||
layout.value = JSON.parse(savedLayout);
|
||||
// Apply RTL direction if saved
|
||||
document.documentElement.dir = layout.value.isRTL ? "rtl" : "ltr";
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDirection = () => {
|
||||
layout.value.isRTL = !layout.value.isRTL;
|
||||
document.documentElement.dir = layout.value.isRTL ? "rtl" : "ltr";
|
||||
// Save to localStorage
|
||||
if (process.client) {
|
||||
localStorage.setItem("layout-config", JSON.stringify(layout.value));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSidebarLayout = () => {
|
||||
layout.value.sidebarLayout =
|
||||
layout.value.sidebarLayout === "vertical" ? "horizontal" : "vertical";
|
||||
// Save to localStorage
|
||||
if (process.client) {
|
||||
localStorage.setItem("layout-config", JSON.stringify(layout.value));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMinimize = () => {
|
||||
layout.value.isMinimized = !layout.value.isMinimized;
|
||||
if (process.client) {
|
||||
localStorage.setItem("layout-config", JSON.stringify(layout.value));
|
||||
}
|
||||
};
|
||||
|
||||
const setLayout = (config) => {
|
||||
layout.value = { ...layout.value, ...config };
|
||||
if (process.client) {
|
||||
localStorage.setItem("layout-config", JSON.stringify(layout.value));
|
||||
if ("isRTL" in config) {
|
||||
document.documentElement.dir = config.isRTL ? "rtl" : "ltr";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
layout,
|
||||
toggleDirection,
|
||||
toggleSidebarLayout,
|
||||
toggleMinimize,
|
||||
setLayout,
|
||||
};
|
||||
};
|
20
composables/useNavigation.js
Normal file
20
composables/useNavigation.js
Normal file
@ -0,0 +1,20 @@
|
||||
import adminNavigation from '~/navigation/admin.json'
|
||||
|
||||
export const useNavigation = () => {
|
||||
const getAdminNavigation = () => {
|
||||
// Here you could add logic to fetch from an API or local storage
|
||||
// to support menu editing functionality
|
||||
return adminNavigation.menu
|
||||
}
|
||||
|
||||
const saveAdminNavigation = async (navigation) => {
|
||||
// This is where you would implement the logic to save
|
||||
// the updated navigation, either to an API or local storage
|
||||
console.log('Saving navigation:', navigation)
|
||||
}
|
||||
|
||||
return {
|
||||
getAdminNavigation,
|
||||
saveAdminNavigation
|
||||
}
|
||||
}
|
78
composables/useToast.js
Normal file
78
composables/useToast.js
Normal file
@ -0,0 +1,78 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const TOAST_TIMEOUT = 5000
|
||||
|
||||
// Create separate toast lists for each position
|
||||
export const toasts = {
|
||||
'top-left': ref([]),
|
||||
'top-right': ref([]),
|
||||
'bottom-left': ref([]),
|
||||
'bottom-right': ref([])
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const add = ({
|
||||
title,
|
||||
description,
|
||||
type = 'default',
|
||||
duration = TOAST_TIMEOUT,
|
||||
position = 'bottom-right',
|
||||
action,
|
||||
}) => {
|
||||
// Validate position
|
||||
if (!['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(position)) {
|
||||
position = 'bottom-right' // fallback to default if invalid
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).slice(2, 9)
|
||||
const toast = {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
action,
|
||||
position
|
||||
}
|
||||
|
||||
// Add to the correct position array
|
||||
toasts[position].value.push(toast)
|
||||
|
||||
if (duration !== Infinity) {
|
||||
setTimeout(() => {
|
||||
remove(id, position)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
const remove = (id, position = null) => {
|
||||
// If position is provided, only check that position's array
|
||||
if (position && toasts[position]) {
|
||||
const index = toasts[position].value.findIndex((toast) => toast.id === id)
|
||||
if (index !== -1) {
|
||||
toasts[position].value.splice(index, 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If position not provided or toast not found in specified position, check all positions
|
||||
Object.values(toasts).forEach(positionToasts => {
|
||||
const index = positionToasts.value.findIndex((toast) => toast.id === id)
|
||||
if (index !== -1) {
|
||||
positionToasts.value.splice(index, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getToasts = (position = 'bottom-right') => {
|
||||
return toasts[position]?.value || []
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
remove,
|
||||
getToasts,
|
||||
toasts
|
||||
}
|
||||
}
|
55
error.vue
Normal file
55
error.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
error: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const handleError = () => {
|
||||
clearError({ redirect: "/" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<div
|
||||
class="min-h-screen bg-background flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="text-center space-y-6">
|
||||
<!-- Error Icon -->
|
||||
<div class="flex justify-center">
|
||||
<div
|
||||
class="w-24 h-24 bg-danger/10 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<Icon
|
||||
:name="error?.statusCode === 404 ? 'ph:warning' : 'ph:x-circle'"
|
||||
class="w-12 h-12 text-danger"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-4xl font-bold">
|
||||
{{
|
||||
error?.statusCode === 404 ? "Page Not Found" : "An Error Occurred"
|
||||
}}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-lg">
|
||||
{{
|
||||
error?.message ||
|
||||
"Sorry, we couldn't find the page you're looking for."
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-center gap-4">
|
||||
<Button variant="outline" @click="handleError">Go Home</Button>
|
||||
<Button @click="handleError">Try Again</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
69
formkit.config.js
Normal file
69
formkit.config.js
Normal file
@ -0,0 +1,69 @@
|
||||
// formkit.config.ts
|
||||
import { generateClasses } from "@formkit/themes";
|
||||
import defaultTheme from "./formkit.theme.js";
|
||||
import customInputs from "./components/formkit";
|
||||
import { genesisIcons } from "@formkit/icons";
|
||||
import {
|
||||
createAutoHeightTextareaPlugin,
|
||||
createMultiStepPlugin,
|
||||
} from "@formkit/addons";
|
||||
import "@formkit/addons/css/multistep";
|
||||
|
||||
const legends = ["checkbox_multi", "radio_multi", "repeater", "transferlist"];
|
||||
|
||||
function addAsteriskPlugin(node) {
|
||||
if (
|
||||
["button", "submit", "hidden", "group", "list", "meta"].includes(
|
||||
node.props.type
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
node.on("created", () => {
|
||||
const legendOrLabel = legends.includes(
|
||||
`${node.props.type}${node.props.options ? "_multi" : ""}`
|
||||
)
|
||||
? "legend"
|
||||
: "label";
|
||||
|
||||
if (node.props.definition.schemaMemoKey) {
|
||||
node.props.definition.schemaMemoKey += `${
|
||||
node.props.options ? "_multi" : ""
|
||||
}_add_asterisk`;
|
||||
}
|
||||
|
||||
const schemaFn = node.props.definition.schema;
|
||||
node.props.definition.schema = (sectionsSchema = {}) => {
|
||||
sectionsSchema[legendOrLabel] = {
|
||||
children: [
|
||||
"$label",
|
||||
{
|
||||
$el: "span",
|
||||
if: "$state.required",
|
||||
attrs: {
|
||||
class: "$classes.asterisk",
|
||||
},
|
||||
children: ["*"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return schemaFn(sectionsSchema);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
addAsteriskPlugin,
|
||||
createAutoHeightTextareaPlugin(),
|
||||
createMultiStepPlugin(),
|
||||
],
|
||||
icons: {
|
||||
...genesisIcons,
|
||||
},
|
||||
config: {
|
||||
classes: generateClasses(defaultTheme),
|
||||
},
|
||||
inputs: customInputs,
|
||||
};
|
374
formkit.theme.js
Normal file
374
formkit.theme.js
Normal file
@ -0,0 +1,374 @@
|
||||
// Base classifications for common styles
|
||||
const baseStyles = {
|
||||
label:
|
||||
"block text-sm font-medium text-foreground mb-2 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
message: "text-sm text-danger mt-1",
|
||||
asterisk: "text-danger",
|
||||
inner:
|
||||
"relative flex items-center gap-2 border border-border rounded-md bg-background text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:border-foreground focus-within:ring-1 focus-within:ring-foreground focus:outline-0 focus:outline-transparent focus:border-primary focus:ring-0 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 shadow rounded-[var(--radius)]",
|
||||
};
|
||||
|
||||
// Input group classifications
|
||||
const inputClassifications = {
|
||||
// Text-like inputs base styling
|
||||
text: {
|
||||
...baseStyles,
|
||||
outer: "relative",
|
||||
input: `
|
||||
flex w-full
|
||||
border-none
|
||||
focus:border-none
|
||||
focus:ring-0
|
||||
focus:ring-transparent
|
||||
focus:outline-none
|
||||
bg-background text-sm
|
||||
text-foreground
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
read-only:cursor-not-allowed read-only:opacity-50
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
prefix: "absolute inset-y-0 left-0 flex items-center pl-3 text-foreground",
|
||||
suffix:
|
||||
"absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground pointer-events-none",
|
||||
},
|
||||
|
||||
// Box-type inputs (checkbox/radio)
|
||||
box: {
|
||||
inner: "relative flex items-start",
|
||||
fieldset: "border-0 p-0 m-0",
|
||||
legend:
|
||||
"text-sm font-medium text-foreground mb-4 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
wrapper:
|
||||
"flex items-center h-4 mb-2 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
help: "text-sm text-muted-foreground",
|
||||
label:
|
||||
"ml-2 text-sm font-normal text-foreground cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
message: "text-sm text-danger mt-1",
|
||||
},
|
||||
|
||||
// Special inputs
|
||||
button: {
|
||||
wrapper: "relative",
|
||||
input: `
|
||||
inline-flex items-center justify-center
|
||||
rounded-[var(--radius)] text-sm font-medium
|
||||
ring-offset-background transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-1
|
||||
focus-visible:ring-ring focus-visible:ring-offset-0
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
bg-primary text-primary-foreground
|
||||
hover:bg-primary/90 px-4 py-2
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
},
|
||||
|
||||
otp: {
|
||||
...baseStyles,
|
||||
inner: "flex gap-2",
|
||||
digit: `
|
||||
flex w-10
|
||||
rounded-[var(--radius)]
|
||||
border border-border
|
||||
bg-background text-center text-sm
|
||||
ring-offset-background
|
||||
focus:outline-0 focus:outline-transparent
|
||||
focus:border-primary
|
||||
focus:ring-0 focus:ring-ring
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
},
|
||||
|
||||
color: {
|
||||
label:
|
||||
"block mb-1 font-bold text-sm disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
input:
|
||||
"w-16 cursor-pointer rounded-[var(--radius)] mb-2 border-none appearance-none bg-transparent",
|
||||
},
|
||||
|
||||
file: {
|
||||
...baseStyles,
|
||||
inner: "flex flex-col",
|
||||
input: `
|
||||
flex h-9 w-full
|
||||
rounded-[var(--radius)]
|
||||
border border-border
|
||||
bg-background text-sm
|
||||
ring-offset-background
|
||||
file:h-full
|
||||
file:mr-4
|
||||
file:px-4
|
||||
file:border-0
|
||||
file:bg-transparent
|
||||
file:text-sm
|
||||
file:font-medium
|
||||
file:!bg-primary
|
||||
file:!text-primary-foreground
|
||||
file:hover:!bg-primary/90
|
||||
file:transition-colors
|
||||
file:cursor-pointer
|
||||
cursor-pointer
|
||||
focus-visible:outline-none focus-visible:ring-1
|
||||
focus:ring-ring focus-visible:ring-offset-0
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
shadow
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
fileList: "flex flex-col gap-1 mt-1",
|
||||
fileItem: "p-1 border-b border-border rounded-md",
|
||||
fileRemove: "flex text-danger mt-1",
|
||||
noFiles: "text-muted-foreground text-sm",
|
||||
},
|
||||
|
||||
range: {
|
||||
input:
|
||||
"appearance-none w-full h-2 p-0 rounded-[var(--radius)] focus:outline-none focus:ring-0 focus:shadow-none bg-primary text-primary-foreground",
|
||||
},
|
||||
|
||||
dropzone: {
|
||||
...baseStyles,
|
||||
dropzone: `
|
||||
w-full
|
||||
rounded-lg
|
||||
border
|
||||
border-dashed
|
||||
border-border
|
||||
bg-background
|
||||
text-center
|
||||
hover:cursor-pointer
|
||||
hover:border-primary
|
||||
hover:bg-background/50
|
||||
focus-visible:outline-none
|
||||
focus-visible:ring-2
|
||||
focus-visible:ring-ring
|
||||
focus-visible:ring-offset-2
|
||||
disabled:cursor-not-allowed
|
||||
disabled:opacity-50
|
||||
transition-colors
|
||||
duration-200
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
preview: `
|
||||
mt-4
|
||||
space-y-2
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
fileItem: `
|
||||
flex
|
||||
items-center
|
||||
justify-between
|
||||
p-2
|
||||
rounded-md
|
||||
bg-muted
|
||||
text-sm
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
fileInfo: `
|
||||
flex
|
||||
items-center
|
||||
space-x-2
|
||||
text-muted-foreground
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
removeButton: `
|
||||
p-1
|
||||
hover:bg-background
|
||||
rounded-full
|
||||
transition-colors
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
},
|
||||
|
||||
switch: {
|
||||
wrapper: "flex flex-col gap-2",
|
||||
help: "text-sm text-muted-foreground",
|
||||
inner: "flex items-center gap-2",
|
||||
input: `
|
||||
relative inline-flex h-[20px] w-[36px] flex-shrink-0 cursor-pointer
|
||||
rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out
|
||||
focus:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
focus-visible:ring-offset-background
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
label:
|
||||
"text-sm font-medium text-foreground disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
message: "text-sm text-danger mt-1",
|
||||
},
|
||||
|
||||
// Add specific styles for date-like inputs
|
||||
date: {
|
||||
...baseStyles,
|
||||
outer: "relative",
|
||||
input: `
|
||||
w-full rounded-md
|
||||
border border-border
|
||||
bg-background px-3 py-2 text-sm
|
||||
text-foreground
|
||||
ring-offset-background
|
||||
placeholder:text-muted-foreground
|
||||
focus:outline-0 focus:outline-transparent
|
||||
focus:border-primary
|
||||
focus:ring-0 focus:ring-ring
|
||||
disabled:cursor-not-allowed disabled:opacity-50 shadow
|
||||
rounded-[var(--radius)]
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
suffix:
|
||||
"absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground pointer-events-none",
|
||||
},
|
||||
|
||||
combobox: {
|
||||
...baseStyles,
|
||||
inner: "relative",
|
||||
outer: "relative",
|
||||
input: `
|
||||
group flex min- w-full flex-wrap items-center gap-1.5
|
||||
rounded-md border border-border bg-background px-2
|
||||
text-sm ring-offset-background
|
||||
focus-within:ring-1 focus-within:ring-ring focus-within:ring-offset-0 shadow
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
inputWrapper: "flex flex-1 items-center gap-2 focus:border-none",
|
||||
searchInput: `
|
||||
flex-1 bg-transparent outline-none border-none
|
||||
placeholder:text-muted-foreground
|
||||
disabled:cursor-not-allowed focus:border-none focus:ring-0 focus:outline-none focus:ring-transparent
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
caret: "h-4 w-4 shrink-0 opacity-50 transition-transform",
|
||||
pill: `
|
||||
inline-flex items-center gap-1 rounded-md
|
||||
bg-secondary px-2 py-0.5 text-sm
|
||||
text-secondary-foreground
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
removeButton: `
|
||||
p-0.5
|
||||
hover:text-primary
|
||||
focus:text-primary
|
||||
focus:outline-none
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
removeIcon: "h-3 w-3 text-secondary-foreground/70",
|
||||
dropdown: `
|
||||
absolute z-50 mt-2 max-h-[200px] w-full
|
||||
overflow-auto rounded-md border
|
||||
bg-popover text-popover-foreground shadow-md
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
dropdownInner: "p-1",
|
||||
option: `
|
||||
relative flex w-full select-none items-center
|
||||
rounded-sm px-2 py-1.5 text-sm outline-none
|
||||
data-[highlighted]:bg-accent
|
||||
data-[highlighted]:text-accent-foreground
|
||||
hover:bg-accent hover:text-accent-foreground
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
},
|
||||
};
|
||||
|
||||
// Global styles that apply to all inputs
|
||||
const globalStyles = {
|
||||
label:
|
||||
"block text-sm font-medium text-foreground disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
outer: "mb-4",
|
||||
help: "text-sm text-muted-foreground mt-1",
|
||||
messages: "list-none p-0 mt-1 mb-2",
|
||||
message: "text-sm text-danger mt-1",
|
||||
wrapper: "relative",
|
||||
};
|
||||
|
||||
// Export theme configuration
|
||||
export default {
|
||||
global: globalStyles,
|
||||
|
||||
// Text-like inputs
|
||||
text: inputClassifications.text,
|
||||
email: inputClassifications.text,
|
||||
password: inputClassifications.text,
|
||||
url: inputClassifications.text,
|
||||
tel: inputClassifications.text,
|
||||
search: inputClassifications.text,
|
||||
number: inputClassifications.text,
|
||||
date: {
|
||||
...inputClassifications.date,
|
||||
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
|
||||
inner: "relative",
|
||||
},
|
||||
time: {
|
||||
...inputClassifications.date,
|
||||
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
|
||||
inner: "relative",
|
||||
},
|
||||
month: {
|
||||
...inputClassifications.date,
|
||||
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
|
||||
inner: "relative",
|
||||
},
|
||||
week: {
|
||||
...inputClassifications.date,
|
||||
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
|
||||
inner: "relative",
|
||||
},
|
||||
"datetime-local": {
|
||||
...inputClassifications.date,
|
||||
suffix: `${inputClassifications.date.suffix} [&>svg]:h-4 [&>svg]:w-4`,
|
||||
inner: "relative",
|
||||
},
|
||||
mask: inputClassifications.text,
|
||||
|
||||
// Box-type inputs
|
||||
checkbox: {
|
||||
...inputClassifications.box,
|
||||
input:
|
||||
"h-4 w-4 rounded-[calc(var(--radius)-2px)] border-border bg-background cursor-pointer checked:bg-primary focus:ring-1 focus:ring-offset-0 focus:ring-primary focus:checked:bg-primary hover:bg-transparent hover:checked:bg-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
},
|
||||
radio: {
|
||||
...inputClassifications.box,
|
||||
input:
|
||||
"h-4 w-4 rounded-full border-border bg-background cursor-pointer checked:bg-primary focus:ring-1 focus:ring-offset-0 focus:ring-primary focus:checked:bg-primary hover:bg-transparent hover:checked:bg-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
},
|
||||
|
||||
// Special inputs
|
||||
button: inputClassifications.button,
|
||||
submit: inputClassifications.button,
|
||||
color: inputClassifications.color,
|
||||
file: inputClassifications.file,
|
||||
range: inputClassifications.range,
|
||||
otp: inputClassifications.otp,
|
||||
|
||||
// Complex inputs
|
||||
select: { ...inputClassifications.text, option: "p-2" },
|
||||
textarea: {
|
||||
...inputClassifications.text,
|
||||
},
|
||||
dropzone: {
|
||||
...inputClassifications.dropzone,
|
||||
inner: "formkit-inner-dropzone",
|
||||
dropzone: "formkit-dropzone",
|
||||
},
|
||||
|
||||
switch: inputClassifications.switch,
|
||||
combobox: inputClassifications.combobox,
|
||||
};
|
107
layouts/admin.vue
Normal file
107
layouts/admin.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
const isOpen = ref(false);
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
// Initialize layout on mount
|
||||
onMounted(() => {
|
||||
layoutStore.initLayout();
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
isOpen.value = !isOpen.value;
|
||||
} else {
|
||||
layoutStore.toggleMinimize();
|
||||
}
|
||||
};
|
||||
|
||||
const isHorizontal = computed(() => layoutStore.sidebarLayout === "horizontal");
|
||||
const isRTL = computed(() => layoutStore.isRTL);
|
||||
const isMinimized = computed(() => layoutStore.isMinimized);
|
||||
|
||||
// Dynamic classes for the main content
|
||||
const mainContentClasses = computed(() => {
|
||||
if (isHorizontal.value) {
|
||||
return "mt-14";
|
||||
}
|
||||
const sidebarWidth = isMinimized.value ? "md:ml-14" : "md:ml-56";
|
||||
return isRTL.value
|
||||
? `md:mr-${isMinimized.value ? "16" : "56"}`
|
||||
: sidebarWidth;
|
||||
});
|
||||
|
||||
// Add overlay click handler
|
||||
const handleOverlayClick = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-background"
|
||||
:class="{ rtl: isRTL }"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<!-- Overlay for mobile -->
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-background/70 z-40 md:hidden"
|
||||
@click="handleOverlayClick"
|
||||
></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<AdminSideMenu
|
||||
:is-open="isOpen"
|
||||
:is-minimized="isMinimized"
|
||||
class="bg-sidebar"
|
||||
@toggle="toggleSidebar"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div
|
||||
:class="mainContentClasses"
|
||||
class="transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<AdminHeader
|
||||
:is-minimized="isMinimized"
|
||||
class="bg-header"
|
||||
@toggle="toggleSidebar"
|
||||
/>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="p-6 bg-content min-h-screen z-0">
|
||||
<div class="max-w-[1920px] mx-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* RTL specific styles */
|
||||
.rtl .md\:mr-56 {
|
||||
margin-right: 14rem;
|
||||
}
|
||||
|
||||
.rtl .md\:mr-16 {
|
||||
margin-right: 4rem;
|
||||
}
|
||||
|
||||
.rtl .md\:ml-56 {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.rtl .md\:ml-16 {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Add transition for overlay */
|
||||
.bg-foreground\/50 {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
6
layouts/default.vue
Normal file
6
layouts/default.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<script setup></script>
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
180
navigation/admin.json
Normal file
180
navigation/admin.json
Normal file
@ -0,0 +1,180 @@
|
||||
{
|
||||
"menu": [
|
||||
{
|
||||
"type": "section",
|
||||
"title": "Platform",
|
||||
"items": [
|
||||
{
|
||||
"name": "Home",
|
||||
"icon": "ph:house",
|
||||
"path": "/admin"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"title": "Components",
|
||||
"items": [
|
||||
{
|
||||
"name": "Actions",
|
||||
"icon": "ph:cursor-click",
|
||||
"children": [{ "name": "Button", "path": "/admin/component/button" }]
|
||||
},
|
||||
{
|
||||
"name": "Data Display",
|
||||
"icon": "ph:squares-four",
|
||||
"children": [
|
||||
{ "name": "Accordion", "path": "/admin/component/accordion" },
|
||||
{ "name": "Avatar", "path": "/admin/component/avatar" },
|
||||
{ "name": "Badge", "path": "/admin/component/badge" },
|
||||
{ "name": "Card", "path": "/admin/component/card" },
|
||||
{ "name": "Data Table", "path": "/admin/component/data-table" },
|
||||
{ "name": "Progress", "path": "/admin/component/progress" },
|
||||
{ "name": "Skeleton", "path": "/admin/component/skeleton" },
|
||||
{ "name": "Table", "path": "/admin/component/table" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Feedback",
|
||||
"icon": "ph:bell",
|
||||
"children": [
|
||||
{ "name": "Alert", "path": "/admin/component/alert" },
|
||||
{ "name": "Toast", "path": "/admin/component/toast" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Navigation",
|
||||
"icon": "ph:navigation-arrow",
|
||||
"children": [
|
||||
{ "name": "Breadcrumb", "path": "/admin/component/breadcrumb" },
|
||||
{ "name": "Stepper", "path": "/admin/component/stepper" },
|
||||
{ "name": "Tabs", "path": "/admin/component/tabs" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Overlay",
|
||||
"icon": "ph:stack",
|
||||
"children": [
|
||||
{ "name": "Context Menu", "path": "/admin/component/context-menu" },
|
||||
{ "name": "Dropdown", "path": "/admin/component/dropdown" },
|
||||
{ "name": "Hover Card", "path": "/admin/component/hover-card" },
|
||||
{ "name": "Modal", "path": "/admin/component/modal" },
|
||||
{ "name": "Popover", "path": "/admin/component/popover" },
|
||||
{ "name": "Tooltip", "path": "/admin/component/tooltip" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Layout",
|
||||
"icon": "ph:layout",
|
||||
"children": [
|
||||
{ "name": "Carousel", "path": "/admin/component/carousel" },
|
||||
{ "name": "Scroll Area", "path": "/admin/component/scroll-area" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Forms",
|
||||
"icon": "ph:text-columns",
|
||||
"children": [
|
||||
{
|
||||
"name": "Basic Inputs",
|
||||
"children": [
|
||||
{ "name": "Text Input", "path": "/admin/form/element/input" },
|
||||
{ "name": "Textarea", "path": "/admin/form/element/textarea" },
|
||||
{ "name": "Select", "path": "/admin/form/element/select" },
|
||||
{ "name": "Radio", "path": "/admin/form/element/radio" },
|
||||
{ "name": "Checkbox", "path": "/admin/form/element/checkbox" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Advanced Inputs",
|
||||
"children": [
|
||||
{ "name": "OTP Input", "path": "/admin/form/element/otp" },
|
||||
{
|
||||
"name": "Input Mask",
|
||||
"path": "/admin/form/element/input-mask"
|
||||
},
|
||||
{ "name": "Combobox", "path": "/admin/form/element/combobox" },
|
||||
{ "name": "Switch", "path": "/admin/form/element/switch" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Date & Time",
|
||||
"children": [
|
||||
{ "name": "Date Picker", "path": "/admin/form/element/date" },
|
||||
{ "name": "Time Picker", "path": "/admin/form/element/time" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Rich Inputs",
|
||||
"children": [
|
||||
{ "name": "Color Picker", "path": "/admin/form/element/color" },
|
||||
{ "name": "File Upload", "path": "/admin/form/element/file" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Form Layouts",
|
||||
"children": [
|
||||
{
|
||||
"name": "Form Section",
|
||||
"path": "/admin/form/layout/section"
|
||||
},
|
||||
{ "name": "Form Grid", "path": "/admin/form/layout/grid" },
|
||||
{ "name": "Form Wizard", "path": "/admin/form/layout/wizard" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"title": "Setting",
|
||||
"items": [
|
||||
{
|
||||
"name": "Settings",
|
||||
"icon": "ph:gear",
|
||||
"children": [
|
||||
{
|
||||
"name": "Profile",
|
||||
"path": "/admin/settings/profile",
|
||||
"icon": "ph:user"
|
||||
},
|
||||
{
|
||||
"name": "Appearance",
|
||||
"path": "/admin/settings/appearance",
|
||||
"icon": "ph:paint-brush"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"title": "Examples",
|
||||
"items": [
|
||||
{
|
||||
"name": "Management",
|
||||
"icon": "ph:folder",
|
||||
"children": [
|
||||
{
|
||||
"name": "Users",
|
||||
"children": [
|
||||
{ "name": "List", "path": "#" },
|
||||
{ "name": "Create", "path": "#" },
|
||||
{ "name": "Groups", "path": "#" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Products",
|
||||
"children": [
|
||||
{ "name": "Catalog", "path": "#" },
|
||||
{ "name": "Categories", "path": "#" },
|
||||
{ "name": "Inventory", "path": "#" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
42
nuxt.config.ts
Normal file
42
nuxt.config.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2024-04-03",
|
||||
devtools: { enabled: false },
|
||||
ssr: false,
|
||||
modules: [
|
||||
"@nuxtjs/tailwindcss",
|
||||
"@nuxt/icon",
|
||||
"@nuxt/fonts",
|
||||
"@formkit/nuxt",
|
||||
"@pinia/nuxt",
|
||||
"@pinia-plugin-persistedstate/nuxt",
|
||||
],
|
||||
tailwindcss: {
|
||||
cssPath: ["~/assets/css/tailwind.css", { injectPosition: 0 }],
|
||||
viewer: false,
|
||||
},
|
||||
fonts: {
|
||||
priority: ["google"],
|
||||
provider: "google",
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
script: [
|
||||
{
|
||||
innerHTML: `(function() {
|
||||
try {
|
||||
const theme = localStorage.getItem('theme') ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default');
|
||||
|
||||
if (theme !== 'default') {
|
||||
document.documentElement.classList.add('theme-' + theme);
|
||||
}
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {}
|
||||
})()`,
|
||||
type: "text/javascript",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
40
package.json
Normal file
40
package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/vue": "^1.1.5",
|
||||
"@formkit/addons": "^1.6.9",
|
||||
"@formkit/icons": "^1.6.9",
|
||||
"@formkit/nuxt": "^1.6.7",
|
||||
"@nuxt/fonts": "^0.10.2",
|
||||
"@nuxt/icon": "^1.7.2",
|
||||
"@splidejs/vue-splide": "^0.6.12",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tiptap/extension-link": "^2.11.5",
|
||||
"@tiptap/pm": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@tiptap/vue-3": "^2.11.5",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"meilisearch": "^0.48.2",
|
||||
"nuxt": "^3.15.4",
|
||||
"prismjs": "^1.29.0",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest"
|
||||
},
|
||||
"packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1",
|
||||
"devDependencies": {
|
||||
"@iconify-json/ph": "^1.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||
"@pinia-plugin-persistedstate/nuxt": "^1.2.1",
|
||||
"@pinia/nuxt": "^0.5.0",
|
||||
"sass": "^1.81.0"
|
||||
}
|
||||
}
|
304
pages/admin/component/accordion/index.vue
Normal file
304
pages/admin/component/accordion/index.vue
Normal file
@ -0,0 +1,304 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Accordion",
|
||||
layout: "admin",
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Component",
|
||||
path: "/admin/component",
|
||||
},
|
||||
{
|
||||
name: "Accordion",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const basicCode = `<Accordion type="single" defaultValue="item-1">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Is it accessible?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It adheres to the WAI-ARIA design pattern.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Is it styled?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It comes with default styles that match your theme.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>`;
|
||||
|
||||
const multipleCode = `<Accordion type="multiple" :defaultValue="['item-1', 'item-2']">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Can I open multiple items?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. Just set the type prop to "multiple".
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Can I customize the styles?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. The component accepts custom classes and styles.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Is it animated?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. The content slides smoothly with a transition effect.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>`;
|
||||
|
||||
const customCode = `<Accordion type="single" defaultValue="item-1" class="w-full max-w-md">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger class="hover:bg-muted/50 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="ph:gear" class="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="px-4">
|
||||
Manage your account settings and preferences.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>`;
|
||||
|
||||
const faqCode = `<Accordion type="single" class="w-full max-w-3xl">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>What payment methods do you accept?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
We accept all major credit cards, PayPal, and bank transfers.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>How long does shipping take?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Shipping typically takes 3-5 business days for domestic orders.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Introduction -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold">Accordion</h1>
|
||||
<p class="text-gray-600">A vertically stacked set of interactive headings that each reveal a section of content. Built with accessibility and customization in mind.</p>
|
||||
</div>
|
||||
|
||||
<!-- Basic Usage -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Basic Usage</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
A simple accordion with single item expansion.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-6">
|
||||
<Accordion type="single" defaultValue="item-1">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Is it accessible?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It adheres to the WAI-ARIA design pattern.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Is it styled?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It comes with default styles that match your theme.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="basicCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Multiple -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Multiple Items</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Allow multiple items to be expanded simultaneously.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-6">
|
||||
<Accordion type="multiple" :defaultValue="['item-1', 'item-2']">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Can I open multiple items?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. Just set the type prop to "multiple".
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Can I customize the styles?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. The component accepts custom classes and styles.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Is it animated?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. The content slides smoothly with a transition effect.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="multipleCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Custom Styling -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Custom Styling</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the accordion with icons and additional styling.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-6">
|
||||
<Accordion type="single" defaultValue="item-1" class="w-full max-w-md">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger class="hover:bg-muted/50 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="ph:gear" class="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="px-4">
|
||||
Manage your account settings and preferences.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="customCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- FAQ Example -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">FAQ Example</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Using the accordion for a FAQ section.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-6">
|
||||
<Accordion type="single" class="w-full max-w-3xl">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>What payment methods do you accept?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
We accept all major credit cards, PayPal, and bank transfers.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>How long does shipping take?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Shipping typically takes 3-5 business days for domestic orders.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="faqCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Props Documentation -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Props & Components</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Available props and subcomponents for building accordions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="relative overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="text-xs uppercase bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3">Component</th>
|
||||
<th class="px-6 py-3">Prop</th>
|
||||
<th class="px-6 py-3">Type</th>
|
||||
<th class="px-6 py-3">Default</th>
|
||||
<th class="px-6 py-3">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">Accordion</td>
|
||||
<td class="px-6 py-4">type</td>
|
||||
<td class="px-6 py-4">string</td>
|
||||
<td class="px-6 py-4">'single'</td>
|
||||
<td class="px-6 py-4">
|
||||
Type of accordion ('single' or 'multiple')
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">Accordion</td>
|
||||
<td class="px-6 py-4">defaultValue</td>
|
||||
<td class="px-6 py-4">string | array</td>
|
||||
<td class="px-6 py-4">''</td>
|
||||
<td class="px-6 py-4">
|
||||
Default expanded item(s)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">AccordionItem</td>
|
||||
<td class="px-6 py-4">value</td>
|
||||
<td class="px-6 py-4">string</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">Unique identifier for the item</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">AccordionTrigger</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">Clickable trigger element</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">AccordionContent</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">Expandable content section</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
252
pages/admin/component/alert/index.vue
Normal file
252
pages/admin/component/alert/index.vue
Normal file
@ -0,0 +1,252 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Alert",
|
||||
layout: "admin",
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Component",
|
||||
path: "/admin/component",
|
||||
},
|
||||
{
|
||||
name: "Alert",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const basicCode = `<Alert>
|
||||
<AlertTitle>Default Alert</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is a default alert — check it out!
|
||||
</AlertDescription>
|
||||
</Alert>`;
|
||||
|
||||
const variantsCode = `<Alert variant="default">
|
||||
<AlertTitle>Default Alert</AlertTitle>
|
||||
<AlertDescription>This is a default alert.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Info Alert</AlertTitle>
|
||||
<AlertDescription>This is an info alert.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert variant="success">
|
||||
<AlertTitle>Success Alert</AlertTitle>
|
||||
<AlertDescription>This is a success alert.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Warning Alert</AlertTitle>
|
||||
<AlertDescription>This is a warning alert.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert variant="danger">
|
||||
<AlertTitle>Danger Alert</AlertTitle>
|
||||
<AlertDescription>This is a danger alert.</AlertDescription>
|
||||
</Alert>`;
|
||||
|
||||
const simpleCode = `<Alert variant="info">
|
||||
<AlertTitle>Browser Update Available</AlertTitle>
|
||||
<AlertDescription>
|
||||
A new version of your browser is available. Please update for the best experience.
|
||||
</AlertDescription>
|
||||
</Alert>`;
|
||||
|
||||
const customCode = `<Alert variant="success" class="max-w-md">
|
||||
<AlertTitle class="flex items-center gap-2">
|
||||
<Icon name="ph:check-circle" class="h-5 w-5" />
|
||||
Payment Successful
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your payment has been successfully processed. We've sent you a confirmation email.
|
||||
</AlertDescription>
|
||||
</Alert>`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Introduction -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold">Alert</h1>
|
||||
<p class="text-gray-600">Displays important messages and feedback to users with various styles and states. Built with accessibility in mind.</p>
|
||||
</div>
|
||||
|
||||
<!-- Basic Usage -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Basic Usage</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
A simple alert with title and description.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-4 mb-6">
|
||||
<Alert>
|
||||
<AlertTitle>Default Alert</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is a default alert — check it out!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="basicCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Variants -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Variants</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Different alert styles to convey various types of messages.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-4 mb-6">
|
||||
<Alert variant="default">
|
||||
<AlertTitle>Default Alert</AlertTitle>
|
||||
<AlertDescription>This is a default alert.</AlertDescription>
|
||||
</Alert>
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Info Alert</AlertTitle>
|
||||
<AlertDescription>This is an info alert.</AlertDescription>
|
||||
</Alert>
|
||||
<Alert variant="success">
|
||||
<AlertTitle>Success Alert</AlertTitle>
|
||||
<AlertDescription>This is a success alert.</AlertDescription>
|
||||
</Alert>
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Warning Alert</AlertTitle>
|
||||
<AlertDescription>This is a warning alert.</AlertDescription>
|
||||
</Alert>
|
||||
<Alert variant="danger">
|
||||
<AlertTitle>Danger Alert</AlertTitle>
|
||||
<AlertDescription>This is a danger alert.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="variantsCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Simple Example -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Simple Example</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
A practical example of using the Alert component.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-4 mb-6">
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Browser Update Available</AlertTitle>
|
||||
<AlertDescription>
|
||||
A new version of your browser is available. Please update for the best experience.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="simpleCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Custom Example -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Custom Example</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Customizing the Alert component with icons and additional styling.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-4 mb-6">
|
||||
<Alert variant="success" class="max-w-md">
|
||||
<AlertTitle class="flex items-center gap-2">
|
||||
<Icon name="ph:check-circle" class="h-5 w-5" />
|
||||
Payment Successful
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your payment has been successfully processed. We've sent you a confirmation email.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="customCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Props Documentation -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Props & Components</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Available props and subcomponents for building alerts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="relative overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="text-xs uppercase bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3">Component</th>
|
||||
<th class="px-6 py-3">Prop</th>
|
||||
<th class="px-6 py-3">Type</th>
|
||||
<th class="px-6 py-3">Default</th>
|
||||
<th class="px-6 py-3">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">Alert</td>
|
||||
<td class="px-6 py-4">variant</td>
|
||||
<td class="px-6 py-4">string</td>
|
||||
<td class="px-6 py-4">'default'</td>
|
||||
<td class="px-6 py-4">
|
||||
Alert style variant (default, info, success, warning, danger)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">AlertTitle</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">Title component for the alert</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">AlertDescription</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">-</td>
|
||||
<td class="px-6 py-4">Description component for the alert</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
217
pages/admin/component/avatar/index.vue
Normal file
217
pages/admin/component/avatar/index.vue
Normal file
@ -0,0 +1,217 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Avatar",
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const basicCode = `<Avatar src="/path/to/image.jpg" alt="User Name" />
|
||||
<Avatar alt="John Doe" fallback="JD" />
|
||||
<Avatar alt="No Image" />`;
|
||||
|
||||
const sizesCode = `<Avatar src="/path/to/image.jpg" alt="User Name" size="sm" />
|
||||
<Avatar src="/path/to/image.jpg" alt="User Name" size="default" />
|
||||
<Avatar src="/path/to/image.jpg" alt="User Name" size="lg" />`;
|
||||
|
||||
const fallbackCode = `<Avatar alt="John Doe" fallback="JD" />
|
||||
<Avatar alt="Alice Smith" /> <!-- Will show "AS" -->
|
||||
<Avatar alt="No Image" /> <!-- Will show "NA" -->`;
|
||||
|
||||
const groupCode = `<AvatarGroup :max="3">
|
||||
<Avatar src="/user1.jpg" alt="User 1" />
|
||||
<Avatar src="/user2.jpg" alt="User 2" />
|
||||
<Avatar src="/user3.jpg" alt="User 3" />
|
||||
<Avatar src="/user4.jpg" alt="User 4" />
|
||||
<Avatar src="/user5.jpg" alt="User 5" />
|
||||
</AvatarGroup>`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Introduction -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold">Avatar</h1>
|
||||
<p class="text-gray-600">A versatile avatar component for displaying user images with fallback support and group functionality.</p>
|
||||
</div>
|
||||
|
||||
<!-- Basic Usage -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Basic Usage</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Basic avatar examples showcasing image, fallback, and initial states.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
<Avatar src="/path/to/image.jpg" alt="User Name" />
|
||||
<Avatar alt="John Doe" fallback="JD" />
|
||||
<Avatar alt="No Image" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="basicCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Sizes -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Sizes</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Different avatar sizes for various use cases.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||
<Avatar src="/path/to/image.jpg" alt="User Name" size="sm" />
|
||||
<Avatar src="/path/to/image.jpg" alt="User Name" size="default" />
|
||||
<Avatar src="/path/to/image.jpg" alt="User Name" size="lg" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="sizesCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Fallback -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Fallback Behavior</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Avatar fallback states when images are unavailable.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||
<Avatar alt="John Doe" fallback="JD" />
|
||||
<Avatar alt="Alice Smith" />
|
||||
<Avatar alt="No Image" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="fallbackCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Avatar Group -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Avatar Group</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Group multiple avatars with overflow count.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||
<AvatarGroup :max="3">
|
||||
<Avatar src="/path/to/image1.jpg" alt="User 1" />
|
||||
<Avatar src="/path/to/image2.jpg" alt="User 2" />
|
||||
<Avatar src="/path/to/image3.jpg" alt="User 3" />
|
||||
<Avatar src="/path/to/image4.jpg" alt="User 4" />
|
||||
<Avatar src="/path/to/image5.jpg" alt="User 5" />
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<ClientOnly>
|
||||
<CodeBlock :code="groupCode" language="markup" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Props Documentation -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2 class="text-xl font-semibold">Props</h2>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Available props for customizing the Avatar components.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">Avatar Props</h3>
|
||||
<div class="relative overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="text-xs uppercase bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3">Prop</th>
|
||||
<th class="px-6 py-3">Type</th>
|
||||
<th class="px-6 py-3">Default</th>
|
||||
<th class="px-6 py-3">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">src</td>
|
||||
<td class="px-6 py-4">string</td>
|
||||
<td class="px-6 py-4">''</td>
|
||||
<td class="px-6 py-4">URL of the avatar image</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">alt</td>
|
||||
<td class="px-6 py-4">string</td>
|
||||
<td class="px-6 py-4">required</td>
|
||||
<td class="px-6 py-4">Alternative text and used for generating initials</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">fallback</td>
|
||||
<td class="px-6 py-4">string</td>
|
||||
<td class="px-6 py-4">''</td>
|
||||
<td class="px-6 py-4">Custom fallback text when image fails to load</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">size</td>
|
||||
<td class="px-6 py-4">string</td>
|
||||
<td class="px-6 py-4">'default'</td>
|
||||
<td class="px-6 py-4">Size of the avatar (sm, default, lg)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">Avatar Group Props</h3>
|
||||
<div class="relative overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="text-xs uppercase bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3">Prop</th>
|
||||
<th class="px-6 py-3">Type</th>
|
||||
<th class="px-6 py-3">Default</th>
|
||||
<th class="px-6 py-3">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b">
|
||||
<td class="px-6 py-4 font-medium">max</td>
|
||||
<td class="px-6 py-4">number</td>
|
||||
<td class="px-6 py-4">4</td>
|
||||
<td class="px-6 py-4">Maximum number of avatars to display before showing count</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user