openprompt-lang 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +483 -32
- package/bin/cli.js +232 -41
- package/bin/create.js +135 -0
- package/bin/lint.js +20 -21
- package/docs/COMMANDS.md +200 -127
- package/docs/COMMITS/INDEX.md +1 -0
- package/docs/PROMPT_AI_CONTEXT.md +99 -0
- package/docs/langs/dotnet.md +36 -0
- package/docs/langs/java-spring.md +45 -0
- package/docs/langs/python-fastapi.md +35 -0
- package/docs/langs/unity.md +30 -0
- package/docs/langs/vue-nuxt.md +36 -0
- package/package.json +31 -3
- package/scaffolds/.cursorrules +6 -0
- package/scaffolds/AGENTS.md +27 -0
- package/scaffolds/Dockerfile +11 -0
- package/scaffolds/capacitor.config.ts +17 -0
- package/scaffolds/netlify.toml +8 -0
- package/scaffolds/prompt-lang.json +15 -0
- package/scaffolds/railway.json +12 -0
- package/scaffolds/tailwind.config.js +8 -0
- package/scaffolds/tauri.conf.json +26 -0
- package/schemas/language-module.json +116 -0
- package/schemas/prompt-lang.json +38 -3
- package/schemas/structures.json +145 -0
- package/src/ai/prompt-builder.js +184 -0
- package/src/ai/providers.js +247 -0
- package/src/annotations/registry.js +39 -0
- package/src/annotations/tags.json +24 -0
- package/src/commands/ai-gen.js +161 -0
- package/src/commands/component.js +242 -212
- package/src/commands/context.js +184 -109
- package/src/commands/extract.js +242 -0
- package/src/commands/figma.js +15 -15
- package/src/commands/init.js +197 -93
- package/src/commands/integrate.js +406 -0
- package/src/commands/lang.js +148 -0
- package/src/commands/qa-gen.js +139 -0
- package/src/commands/scaffold.js +127 -0
- package/src/commands/suggest.js +24 -14
- package/src/commands/teach.js +110 -0
- package/src/commands/validate.js +143 -83
- package/src/commands/wizard.js +456 -0
- package/src/generators/figma-prompt.js +20 -12
- package/src/language-service/plugin.cjs +94 -0
- package/src/language-service/plugin.d.ts +6 -0
- package/src/mcp-server.js +605 -0
- package/src/templates/langs/react/INDEX.json +262 -0
- package/src/templates/langs/react/MODULE.json +166 -0
- package/src/templates/langs/react/templates/hooks/useAuth.template.ts +134 -0
- package/src/templates/langs/react/templates/hooks/useDebounce.template.ts +45 -0
- package/src/templates/langs/react/templates/hooks/useForm.template.ts +146 -0
- package/src/templates/langs/react/templates/hooks/usePagination.template.ts +108 -0
- package/src/templates/langs/react/templates/services/apiService.template.ts +123 -0
- package/src/templates/langs/react/templates/ui/Button.template.tsx +87 -0
- package/src/templates/langs/react/templates/ui/Card.template.tsx +85 -0
- package/src/templates/langs/react/templates/ui/DataTable.template.tsx +163 -0
- package/src/templates/langs/react/templates/ui/Input.template.tsx +96 -0
- package/src/templates/langs/react/templates/ui/Modal.template.tsx +133 -0
- package/src/templates/langs/react/templates/ui/Select.template.tsx +99 -0
- package/src/templates/langs/vue/INDEX.json +246 -0
- package/src/templates/langs/vue/MODULE.json +105 -0
- package/src/templates/langs/vue/templates/composables/useAuth.template.ts +106 -0
- package/src/templates/langs/vue/templates/composables/useDebounce.template.ts +47 -0
- package/src/templates/langs/vue/templates/composables/useFetch.template.ts +54 -0
- package/src/templates/langs/vue/templates/composables/useForm.template.ts +127 -0
- package/src/templates/langs/vue/templates/composables/usePagination.template.ts +98 -0
- package/src/templates/langs/vue/templates/services/apiService.template.ts +116 -0
- package/src/templates/langs/vue/templates/ui/Button.template.vue +79 -0
- package/src/templates/langs/vue/templates/ui/Card.template.vue +73 -0
- package/src/templates/langs/vue/templates/ui/DataTable.template.vue +115 -0
- package/src/templates/langs/vue/templates/ui/Input.template.vue +70 -0
- package/src/templates/langs/vue/templates/ui/Modal.template.vue +112 -0
- package/src/templates/langs/vue/templates/ui/Select.template.vue +77 -0
- package/src/templates/scripts/log-actividad.sh +32 -0
- package/src/templates/scripts/log-commit.sh +35 -0
- package/src/templates/scripts/log-error.sh +45 -0
- package/src/templates/scripts/validate.sh +23 -0
- package/src/ts-transformer/index.cjs +86 -0
- package/src/utils/ai.js +35 -53
- package/src/utils/annotations.js +260 -214
- package/src/utils/config.js +61 -13
- package/src/utils/error-learner.js +203 -0
- package/src/utils/file-utils.js +119 -0
- package/src/utils/language-loader.js +167 -0
- package/src/utils/template-utils.js +45 -0
- package/src/vite-plugin/index.js +54 -0
- package/vscode-extension/package.json +23 -2
- package/vscode-extension/snippets/promptlang.json +1 -3
- package/vscode-extension/syntaxes/annotations-code.tmGrammar.json +15 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @template
|
|
2
|
+
// @id: composable-usePagination
|
|
3
|
+
// @category: composable/general
|
|
4
|
+
// @purpose: paginated-list, table-pagination, infinite-scroll
|
|
5
|
+
// @framework: vue
|
|
6
|
+
// @style: none
|
|
7
|
+
// @darkMode: false
|
|
8
|
+
// @responsive: true
|
|
9
|
+
// @deps: [vue]
|
|
10
|
+
// @states: [first-page, middle-page, last-page, single-page]
|
|
11
|
+
// @example: const { page, pages, goTo, hasNext } = usePagination({ total: 100, perPage: 10 })
|
|
12
|
+
// @teachMe
|
|
13
|
+
//
|
|
14
|
+
// @goodPractice: Show ellipsis for large page ranges
|
|
15
|
+
// @goodPractice: Keep siblingCount configurable for UX tuning
|
|
16
|
+
// @goodPractice: Support both client-side and server-side modes
|
|
17
|
+
|
|
18
|
+
import { ref, computed } from "vue"
|
|
19
|
+
|
|
20
|
+
interface PaginationOptions {
|
|
21
|
+
total: number
|
|
22
|
+
perPage?: number
|
|
23
|
+
initialPage?: number
|
|
24
|
+
siblingCount?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function usePagination(options: PaginationOptions) {
|
|
28
|
+
const { total, perPage = 10, initialPage = 1, siblingCount = 1 } = options
|
|
29
|
+
|
|
30
|
+
const currentPage = ref(initialPage)
|
|
31
|
+
const totalPages = Math.max(1, Math.ceil(total / perPage))
|
|
32
|
+
|
|
33
|
+
const hasPrev = computed(() => currentPage.value > 1)
|
|
34
|
+
const hasNext = computed(() => currentPage.value < totalPages)
|
|
35
|
+
|
|
36
|
+
const pages = computed(() => {
|
|
37
|
+
const totalPageNumbers = siblingCount * 2 + 5
|
|
38
|
+
if (totalPages <= totalPageNumbers) {
|
|
39
|
+
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const leftSiblingIndex = Math.max(currentPage.value - siblingCount, 1)
|
|
43
|
+
const rightSiblingIndex = Math.min(currentPage.value + siblingCount, totalPages)
|
|
44
|
+
|
|
45
|
+
const showLeftEllipsis = leftSiblingIndex > 2
|
|
46
|
+
const showRightEllipsis = rightSiblingIndex < totalPages - 1
|
|
47
|
+
|
|
48
|
+
if (!showLeftEllipsis && showRightEllipsis) {
|
|
49
|
+
const leftItemCount = 3 + 2 * siblingCount
|
|
50
|
+
const leftRange = Array.from({ length: leftItemCount }, (_, i) => i + 1)
|
|
51
|
+
return [...leftRange, "...", totalPages]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (showLeftEllipsis && !showRightEllipsis) {
|
|
55
|
+
const rightItemCount = 3 + 2 * siblingCount
|
|
56
|
+
const rightRange = Array.from({ length: rightItemCount }, (_, i) => totalPages - rightItemCount + i + 1)
|
|
57
|
+
return [1, "...", ...rightRange]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const middleRange = Array.from(
|
|
61
|
+
{ length: rightSiblingIndex - leftSiblingIndex + 1 },
|
|
62
|
+
(_, i) => leftSiblingIndex + i
|
|
63
|
+
)
|
|
64
|
+
return [1, "...", ...middleRange, "...", totalPages]
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const startItem = computed(() => (currentPage.value - 1) * perPage + 1)
|
|
68
|
+
const endItem = computed(() => Math.min(currentPage.value * perPage, total))
|
|
69
|
+
|
|
70
|
+
function goTo(page: number) {
|
|
71
|
+
if (page >= 1 && page <= totalPages) {
|
|
72
|
+
currentPage.value = page
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function next() {
|
|
77
|
+
if (hasNext.value) goTo(currentPage.value + 1)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function prev() {
|
|
81
|
+
if (hasPrev.value) goTo(currentPage.value - 1)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
currentPage,
|
|
86
|
+
totalPages,
|
|
87
|
+
pages,
|
|
88
|
+
hasPrev,
|
|
89
|
+
hasNext,
|
|
90
|
+
startItem,
|
|
91
|
+
endItem,
|
|
92
|
+
total,
|
|
93
|
+
perPage,
|
|
94
|
+
goTo,
|
|
95
|
+
next,
|
|
96
|
+
prev,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @template
|
|
2
|
+
// @id: service-api-vue
|
|
3
|
+
// @category: service/api
|
|
4
|
+
// @purpose: crud, rest-api, http-client, data-fetching
|
|
5
|
+
// @framework: vue
|
|
6
|
+
// @style: none
|
|
7
|
+
// @darkMode: false
|
|
8
|
+
// @responsive: false
|
|
9
|
+
// @deps: [vue]
|
|
10
|
+
// @states: [idle, loading, error, success]
|
|
11
|
+
// @example: const { get, post, put, del } = createApi('/api/users')
|
|
12
|
+
// @teachMe
|
|
13
|
+
//
|
|
14
|
+
// @goodPractice: Centralize API config (base URL, headers, timeout) in one factory
|
|
15
|
+
// @goodPractice: Use typed generics for request/response types
|
|
16
|
+
// @goodPractice: Handle token refresh transparently with interceptors
|
|
17
|
+
// @badPractice: Don't hardcode API URLs in components — use this service
|
|
18
|
+
|
|
19
|
+
interface ApiConfig {
|
|
20
|
+
baseUrl: string
|
|
21
|
+
headers?: Record<string, string>
|
|
22
|
+
timeout?: number
|
|
23
|
+
getToken?: () => string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ApiResponse<T> {
|
|
27
|
+
data: T | null
|
|
28
|
+
error: string | null
|
|
29
|
+
status: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class ApiError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
message: string,
|
|
35
|
+
public status: number,
|
|
36
|
+
public data?: unknown
|
|
37
|
+
) {
|
|
38
|
+
super(message)
|
|
39
|
+
this.name = "ApiError"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createApi<T = unknown>(config: ApiConfig) {
|
|
44
|
+
const { baseUrl, timeout = 10000, getToken } = config
|
|
45
|
+
|
|
46
|
+
async function request<TResponse>(
|
|
47
|
+
endpoint: string,
|
|
48
|
+
options: RequestInit = {}
|
|
49
|
+
): Promise<ApiResponse<TResponse>> {
|
|
50
|
+
const controller = new AbortController()
|
|
51
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
52
|
+
|
|
53
|
+
const headers: Record<string, string> = {
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
...config.headers,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const token = getToken?.()
|
|
59
|
+
if (token) {
|
|
60
|
+
headers["Authorization"] = `Bearer ${token}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(`${baseUrl}${endpoint}`, {
|
|
65
|
+
...options,
|
|
66
|
+
headers: { ...headers, ...options.headers },
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
clearTimeout(timeoutId)
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const errorData = await response.json().catch(() => null)
|
|
74
|
+
throw new ApiError(
|
|
75
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
76
|
+
response.status,
|
|
77
|
+
errorData
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const data = await response.json()
|
|
82
|
+
return { data: data as TResponse, error: null, status: response.status }
|
|
83
|
+
} catch (e) {
|
|
84
|
+
clearTimeout(timeoutId)
|
|
85
|
+
if (e instanceof ApiError) {
|
|
86
|
+
return { data: null, error: e.message, status: e.status }
|
|
87
|
+
}
|
|
88
|
+
const message = e instanceof Error ? e.message : "Unknown error"
|
|
89
|
+
return { data: null, error: message, status: 0 }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function get<TResponse>(endpoint: string) {
|
|
94
|
+
return request<TResponse>(endpoint, { method: "GET" })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function post<TBody, TResponse>(endpoint: string, body: TBody) {
|
|
98
|
+
return request<TResponse>(endpoint, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function put<TBody, TResponse>(endpoint: string, body: TBody) {
|
|
105
|
+
return request<TResponse>(endpoint, {
|
|
106
|
+
method: "PUT",
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function del<TResponse>(endpoint: string) {
|
|
112
|
+
return request<TResponse>(endpoint, { method: "DELETE" })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { get, post, put, del, request }
|
|
116
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// @template
|
|
3
|
+
// @id: button-shadcn-vue
|
|
4
|
+
// @category: ui/button
|
|
5
|
+
// @purpose: primary-action, confirmation, danger
|
|
6
|
+
// @framework: vue
|
|
7
|
+
// @style: shadcn-vue
|
|
8
|
+
// @variants: [primary, secondary, danger, ghost, outline]
|
|
9
|
+
// @sizes: [sm, default, lg, icon]
|
|
10
|
+
// @darkMode: true
|
|
11
|
+
// @responsive: true
|
|
12
|
+
// @deps: [vue]
|
|
13
|
+
// @props: { variant: string, size: string, disabled?: boolean, loading?: boolean }
|
|
14
|
+
// @states: [idle, loading, disabled, hover, active, focus-visible]
|
|
15
|
+
// @example: <Button variant="primary" size="default" :loading="true">Save</Button>
|
|
16
|
+
// @teachMe
|
|
17
|
+
//
|
|
18
|
+
// ## Anatomy
|
|
19
|
+
// Vue 3 button using Composition API with Tailwind CSS variant classes.
|
|
20
|
+
// Loading state disables interaction and shows a spinner.
|
|
21
|
+
// Dark mode uses Tailwind's `dark:` modifier.
|
|
22
|
+
//
|
|
23
|
+
// @goodPractice: Use defineProps with TypeScript for type-safe props
|
|
24
|
+
// @goodPractice: Use type="button" to prevent form submission
|
|
25
|
+
// @goodPractice: Emit typed events with defineEmits
|
|
26
|
+
// @badPractice: Don't use v-html with user content — XSS risk
|
|
27
|
+
// @badPractice: Don't omit type="button" — causes form submission
|
|
28
|
+
|
|
29
|
+
import { computed } from "vue"
|
|
30
|
+
|
|
31
|
+
const props = withDefaults(defineProps<{
|
|
32
|
+
variant?: "primary" | "secondary" | "danger" | "ghost" | "outline"
|
|
33
|
+
size?: "sm" | "default" | "lg" | "icon"
|
|
34
|
+
disabled?: boolean
|
|
35
|
+
loading?: boolean
|
|
36
|
+
}>(), {
|
|
37
|
+
variant: "primary",
|
|
38
|
+
size: "default",
|
|
39
|
+
disabled: false,
|
|
40
|
+
loading: false,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const emit = defineEmits<{
|
|
44
|
+
click: [event: MouseEvent]
|
|
45
|
+
}>()
|
|
46
|
+
|
|
47
|
+
const classes = computed(() => {
|
|
48
|
+
const base = "inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
|
49
|
+
|
|
50
|
+
const variants: Record<string, string> = {
|
|
51
|
+
primary: "bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600",
|
|
52
|
+
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700",
|
|
53
|
+
danger: "bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600",
|
|
54
|
+
ghost: "hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-100",
|
|
55
|
+
outline: "border border-gray-300 hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sizes: Record<string, string> = {
|
|
59
|
+
sm: "h-8 px-3 text-xs",
|
|
60
|
+
default: "h-10 px-4 py-2",
|
|
61
|
+
lg: "h-12 px-6 text-base",
|
|
62
|
+
icon: "h-10 w-10",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return [base, variants[props.variant], sizes[props.size]].join(" ")
|
|
66
|
+
})
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
:class="classes"
|
|
73
|
+
:disabled="disabled || loading"
|
|
74
|
+
@click="emit('click', $event)"
|
|
75
|
+
>
|
|
76
|
+
<span v-if="loading" class="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
77
|
+
<slot />
|
|
78
|
+
</button>
|
|
79
|
+
</template>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// @template
|
|
3
|
+
// @id: card-shadcn-vue
|
|
4
|
+
// @category: ui/data-display
|
|
5
|
+
// @purpose: container, summary, feature-card, dashboard-card
|
|
6
|
+
// @framework: vue
|
|
7
|
+
// @style: shadcn-vue
|
|
8
|
+
// @variants: [default, bordered, elevated, interactive]
|
|
9
|
+
// @sizes: [sm, default, full]
|
|
10
|
+
// @darkMode: true
|
|
11
|
+
// @responsive: true
|
|
12
|
+
// @deps: [vue]
|
|
13
|
+
// @props: { title?: string, subtitle?: string, variant: string }
|
|
14
|
+
// @states: [idle, hover (interactive)]
|
|
15
|
+
// @example: <Card title="Welcome" subtitle="Get started" variant="elevated">...</Card>
|
|
16
|
+
// @teachMe
|
|
17
|
+
//
|
|
18
|
+
// ## Anatomy
|
|
19
|
+
// Flexible card container with header, body, and footer slots.
|
|
20
|
+
// Supports multiple visual variants via Tailwind classes.
|
|
21
|
+
//
|
|
22
|
+
// @goodPractice: Use named slots for customizable layout
|
|
23
|
+
// @goodPractice: Use scoped styles or Tailwind for consistent theming
|
|
24
|
+
// @badPractice: Don't nest cards inside cards — creates visual noise
|
|
25
|
+
|
|
26
|
+
import { computed } from "vue"
|
|
27
|
+
|
|
28
|
+
const props = withDefaults(defineProps<{
|
|
29
|
+
title?: string
|
|
30
|
+
subtitle?: string
|
|
31
|
+
variant?: "default" | "bordered" | "elevated" | "interactive"
|
|
32
|
+
size?: "sm" | "default" | "full"
|
|
33
|
+
}>(), {
|
|
34
|
+
variant: "default",
|
|
35
|
+
size: "default",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const cardClasses = computed(() => {
|
|
39
|
+
const base = "rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
|
40
|
+
|
|
41
|
+
const variants: Record<string, string> = {
|
|
42
|
+
default: "shadow-sm",
|
|
43
|
+
bordered: "border border-gray-200 dark:border-gray-700",
|
|
44
|
+
elevated: "shadow-lg",
|
|
45
|
+
interactive: "shadow-sm hover:shadow-md transition-shadow cursor-pointer border border-gray-200 dark:border-gray-700",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sizes: Record<string, string> = {
|
|
49
|
+
sm: "max-w-sm",
|
|
50
|
+
default: "max-w-md",
|
|
51
|
+
full: "w-full",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return [base, variants[props.variant], sizes[props.size]].join(" ")
|
|
55
|
+
})
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<div :class="cardClasses">
|
|
60
|
+
<div v-if="title || subtitle || $slots.header" class="flex flex-col gap-1 p-6 pb-0">
|
|
61
|
+
<slot name="header">
|
|
62
|
+
<h3 v-if="title" class="text-lg font-semibold">{{ title }}</h3>
|
|
63
|
+
<p v-if="subtitle" class="text-sm text-gray-500 dark:text-gray-400">{{ subtitle }}</p>
|
|
64
|
+
</slot>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="p-6">
|
|
67
|
+
<slot />
|
|
68
|
+
</div>
|
|
69
|
+
<div v-if="$slots.footer" class="border-t border-gray-200 p-6 pt-4 dark:border-gray-700">
|
|
70
|
+
<slot name="footer" />
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// @template
|
|
3
|
+
// @id: datatable-shadcn-vue
|
|
4
|
+
// @category: ui/data-display
|
|
5
|
+
// @purpose: data-table, sortable-table, paginated-list
|
|
6
|
+
// @framework: vue
|
|
7
|
+
// @style: shadcn-vue
|
|
8
|
+
// @variants: [default, compact, striped, sortable]
|
|
9
|
+
// @sizes: [default, compact]
|
|
10
|
+
// @darkMode: true
|
|
11
|
+
// @responsive: true
|
|
12
|
+
// @deps: [vue]
|
|
13
|
+
// @props: { columns: Column[], data: T[], loading?: boolean, sortable?: boolean }
|
|
14
|
+
// @states: [loading, empty, populated, error]
|
|
15
|
+
// @example: <DataTable :columns="cols" :data="users" :loading="loading" />
|
|
16
|
+
// @teachMe
|
|
17
|
+
//
|
|
18
|
+
// ## Anatomy
|
|
19
|
+
// Generic typed data table with sorting, loading skeleton,
|
|
20
|
+
// empty state, and responsive scroll.
|
|
21
|
+
//
|
|
22
|
+
// @goodPractice: Use generic type parameter for type-safe columns and data
|
|
23
|
+
// @goodPractice: Show skeleton loader during loading for perceived performance
|
|
24
|
+
// @goodPractice: Use sticky header for long tables
|
|
25
|
+
|
|
26
|
+
import { computed, ref } from "vue"
|
|
27
|
+
|
|
28
|
+
export interface Column<T = Record<string, unknown>> {
|
|
29
|
+
key: keyof T & string
|
|
30
|
+
label: string
|
|
31
|
+
sortable?: boolean
|
|
32
|
+
width?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const props = withDefaults(defineProps<{
|
|
36
|
+
columns: Column[]
|
|
37
|
+
data: Record<string, unknown>[]
|
|
38
|
+
loading?: boolean
|
|
39
|
+
variant?: "default" | "compact" | "striped" | "sortable"
|
|
40
|
+
emptyMessage?: string
|
|
41
|
+
}>(), {
|
|
42
|
+
loading: false,
|
|
43
|
+
variant: "default",
|
|
44
|
+
emptyMessage: "No data available",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const sortKey = ref<string | null>(null)
|
|
48
|
+
const sortDir = ref<"asc" | "desc">("asc")
|
|
49
|
+
|
|
50
|
+
const sortedData = computed(() => {
|
|
51
|
+
if (!sortKey.value) return props.data
|
|
52
|
+
return [...props.data].sort((a, b) => {
|
|
53
|
+
const aVal = a[sortKey.value!]
|
|
54
|
+
const bVal = b[sortKey.value!]
|
|
55
|
+
if (aVal < bVal) return sortDir.value === "asc" ? -1 : 1
|
|
56
|
+
if (aVal > bVal) return sortDir.value === "asc" ? 1 : -1
|
|
57
|
+
return 0
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function toggleSort(key: string) {
|
|
62
|
+
if (sortKey.value === key) {
|
|
63
|
+
sortDir.value = sortDir.value === "asc" ? "desc" : "asc"
|
|
64
|
+
} else {
|
|
65
|
+
sortKey.value = key
|
|
66
|
+
sortDir.value = "asc"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<div class="w-full overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700">
|
|
73
|
+
<table class="w-full text-left text-sm">
|
|
74
|
+
<thead class="bg-gray-50 dark:bg-gray-800">
|
|
75
|
+
<tr>
|
|
76
|
+
<th
|
|
77
|
+
v-for="col in columns"
|
|
78
|
+
:key="col.key"
|
|
79
|
+
:style="{ width: col.width }"
|
|
80
|
+
class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400"
|
|
81
|
+
:class="{ 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200': col.sortable }"
|
|
82
|
+
@click="col.sortable && toggleSort(col.key)"
|
|
83
|
+
>
|
|
84
|
+
{{ col.label }}
|
|
85
|
+
<span v-if="col.sortable && sortKey === col.key" class="ml-1">
|
|
86
|
+
{{ sortDir === "asc" ? "↑" : "↓" }}
|
|
87
|
+
</span>
|
|
88
|
+
</th>
|
|
89
|
+
</tr>
|
|
90
|
+
</thead>
|
|
91
|
+
<tbody>
|
|
92
|
+
<tr v-if="loading">
|
|
93
|
+
<td :colspan="columns.length" class="p-4">
|
|
94
|
+
<div v-for="i in 3" :key="i" class="mb-2 h-4 animate-pulse rounded bg-gray-200 dark:bg-gray-700" style="width: 80%" />
|
|
95
|
+
</td>
|
|
96
|
+
</tr>
|
|
97
|
+
<tr v-else-if="sortedData.length === 0">
|
|
98
|
+
<td :colspan="columns.length" class="p-8 text-center text-gray-400">
|
|
99
|
+
{{ emptyMessage }}
|
|
100
|
+
</td>
|
|
101
|
+
</tr>
|
|
102
|
+
<tr
|
|
103
|
+
v-for="(row, i) in sortedData"
|
|
104
|
+
:key="i"
|
|
105
|
+
class="border-t border-gray-200 dark:border-gray-700"
|
|
106
|
+
:class="{ 'bg-gray-50 dark:bg-gray-800/50': variant === 'striped' && i % 2 === 0 }"
|
|
107
|
+
>
|
|
108
|
+
<td v-for="col in columns" :key="col.key" class="px-4 py-3">
|
|
109
|
+
{{ row[col.key] }}
|
|
110
|
+
</td>
|
|
111
|
+
</tr>
|
|
112
|
+
</tbody>
|
|
113
|
+
</table>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// @template
|
|
3
|
+
// @id: input-shadcn-vue
|
|
4
|
+
// @category: ui/form
|
|
5
|
+
// @purpose: text-input, search, email, password, number
|
|
6
|
+
// @framework: vue
|
|
7
|
+
// @style: shadcn-vue
|
|
8
|
+
// @variants: [default, error, flushed]
|
|
9
|
+
// @sizes: [sm, default, lg]
|
|
10
|
+
// @darkMode: true
|
|
11
|
+
// @responsive: true
|
|
12
|
+
// @deps: [vue]
|
|
13
|
+
// @props: { label: string, error?: string, helperText?: string, icon?: Component }
|
|
14
|
+
// @states: [idle, focused, filled, error, disabled]
|
|
15
|
+
// @example: <Input label="Email" v-model="email" error="Required" />
|
|
16
|
+
// @teachMe
|
|
17
|
+
//
|
|
18
|
+
// ## Anatomy
|
|
19
|
+
// Wraps native input with label, error message, helper text, and optional icon.
|
|
20
|
+
// Uses v-model for two-way binding with Vue 3 Composition API.
|
|
21
|
+
//
|
|
22
|
+
// @goodPractice: Use v-model with defineModel for two-way binding
|
|
23
|
+
// @goodPractice: Forward $attrs via useAttrs for native HTML attribute support
|
|
24
|
+
// @goodPractice: Always associate label with input via :id
|
|
25
|
+
// @badPractice: Don't use v-model on the same element twice
|
|
26
|
+
|
|
27
|
+
import { computed, useAttrs } from "vue"
|
|
28
|
+
|
|
29
|
+
const model = defineModel<string>({ default: "" })
|
|
30
|
+
|
|
31
|
+
const props = withDefaults(defineProps<{
|
|
32
|
+
label?: string
|
|
33
|
+
error?: string
|
|
34
|
+
helperText?: string
|
|
35
|
+
placeholder?: string
|
|
36
|
+
type?: string
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
}>(), {
|
|
39
|
+
type: "text",
|
|
40
|
+
disabled: false,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const attrs = useAttrs()
|
|
44
|
+
|
|
45
|
+
const inputClasses = computed(() => {
|
|
46
|
+
const base = "flex h-10 w-full rounded-lg border bg-white px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-950 dark:text-gray-100"
|
|
47
|
+
const borderColor = props.error
|
|
48
|
+
? "border-red-500 focus-visible:ring-red-500"
|
|
49
|
+
: "border-gray-300 focus-visible:ring-blue-500 dark:border-gray-600"
|
|
50
|
+
return [base, borderColor].join(" ")
|
|
51
|
+
})
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div class="flex flex-col gap-1.5">
|
|
56
|
+
<label v-if="label" :for="attrs.id as string" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
57
|
+
{{ label }}
|
|
58
|
+
</label>
|
|
59
|
+
<input
|
|
60
|
+
v-model="model"
|
|
61
|
+
:type="type"
|
|
62
|
+
:placeholder="placeholder"
|
|
63
|
+
:disabled="disabled"
|
|
64
|
+
:class="inputClasses"
|
|
65
|
+
v-bind="attrs"
|
|
66
|
+
/>
|
|
67
|
+
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
|
|
68
|
+
<p v-else-if="helperText" class="text-sm text-gray-400">{{ helperText }}</p>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// @template
|
|
3
|
+
// @id: modal-dialog-vue
|
|
4
|
+
// @category: ui/modal
|
|
5
|
+
// @purpose: confirm-action, form-dialog, alert, fullscreen
|
|
6
|
+
// @framework: vue
|
|
7
|
+
// @style: shadcn-vue
|
|
8
|
+
// @variants: [dialog, confirm-destructive, fullscreen]
|
|
9
|
+
// @sizes: [sm, default, lg, xl, fullscreen]
|
|
10
|
+
// @darkMode: true
|
|
11
|
+
// @responsive: true
|
|
12
|
+
// @deps: [vue]
|
|
13
|
+
// @props: { open: boolean, title?: string }
|
|
14
|
+
// @states: [closed, open, closing]
|
|
15
|
+
// @example: <Modal v-model:open="showModal" title="Confirm">...</Modal>
|
|
16
|
+
// @teachMe
|
|
17
|
+
//
|
|
18
|
+
// ## Anatomy
|
|
19
|
+
// Modal dialog with overlay, close-on-escape, close-on-overlay-click,
|
|
20
|
+
// focus trap, and portal rendering via Teleport.
|
|
21
|
+
// Uses v-model:open for controlled visibility.
|
|
22
|
+
//
|
|
23
|
+
// @goodPractice: Use Teleport to render modal outside app root
|
|
24
|
+
// @goodPractice: Trap focus inside modal for keyboard accessibility
|
|
25
|
+
// @goodPractice: Close on Escape key and overlay click for UX
|
|
26
|
+
// @badPractice: Don't render modal inline — z-index stacking issues
|
|
27
|
+
|
|
28
|
+
import { watch, onMounted, onUnmounted } from "vue"
|
|
29
|
+
|
|
30
|
+
const props = withDefaults(defineProps<{
|
|
31
|
+
open?: boolean
|
|
32
|
+
title?: string
|
|
33
|
+
size?: "sm" | "default" | "lg" | "xl" | "fullscreen"
|
|
34
|
+
closeOnOverlay?: boolean
|
|
35
|
+
}>(), {
|
|
36
|
+
open: false,
|
|
37
|
+
size: "default",
|
|
38
|
+
closeOnOverlay: true,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{
|
|
42
|
+
close: []
|
|
43
|
+
"update:open": [value: boolean]
|
|
44
|
+
}>()
|
|
45
|
+
|
|
46
|
+
const sizeClasses: Record<string, string> = {
|
|
47
|
+
sm: "max-w-sm",
|
|
48
|
+
default: "max-w-lg",
|
|
49
|
+
lg: "max-w-2xl",
|
|
50
|
+
xl: "max-w-4xl",
|
|
51
|
+
fullscreen: "max-w-[95vw] h-[95vh]",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleEscape(e: KeyboardEvent) {
|
|
55
|
+
if (e.key === "Escape" && props.open) {
|
|
56
|
+
emit("close")
|
|
57
|
+
emit("update:open", false)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onMounted(() => document.addEventListener("keydown", handleEscape))
|
|
62
|
+
onUnmounted(() => document.removeEventListener("keydown", handleEscape))
|
|
63
|
+
|
|
64
|
+
function onOverlayClick() {
|
|
65
|
+
if (props.closeOnOverlay) {
|
|
66
|
+
emit("close")
|
|
67
|
+
emit("update:open", false)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<Teleport to="body">
|
|
74
|
+
<Transition name="modal">
|
|
75
|
+
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center">
|
|
76
|
+
<div class="fixed inset-0 bg-black/50" @click="onOverlayClick" />
|
|
77
|
+
<div
|
|
78
|
+
:class="['relative z-10 w-full rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900', sizeClasses[size]]"
|
|
79
|
+
role="dialog"
|
|
80
|
+
aria-modal="true"
|
|
81
|
+
>
|
|
82
|
+
<div class="mb-4 flex items-center justify-between">
|
|
83
|
+
<h2 v-if="title" class="text-lg font-semibold">{{ title }}</h2>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
class="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800"
|
|
87
|
+
@click="emit('close'); emit('update:open', false)"
|
|
88
|
+
>
|
|
89
|
+
<span class="sr-only">Close</span>
|
|
90
|
+
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
91
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
92
|
+
</svg>
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
<slot />
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</Transition>
|
|
99
|
+
</Teleport>
|
|
100
|
+
</template>
|
|
101
|
+
|
|
102
|
+
<style scoped>
|
|
103
|
+
.modal-enter-active, .modal-leave-active {
|
|
104
|
+
transition: opacity 0.2s ease;
|
|
105
|
+
}
|
|
106
|
+
.modal-enter-active > div:last-child, .modal-leave-active > div:last-child {
|
|
107
|
+
transition: transform 0.2s ease;
|
|
108
|
+
}
|
|
109
|
+
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
|
110
|
+
.modal-enter-from > div:last-child { transform: scale(0.95); }
|
|
111
|
+
.modal-leave-to > div:last-child { transform: scale(0.95); }
|
|
112
|
+
</style>
|