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,146 @@
|
|
|
1
|
+
// @template
|
|
2
|
+
// @id: hook-useForm
|
|
3
|
+
// @category: ui/form
|
|
4
|
+
// @purpose: form-state, validation, submission, async-submit
|
|
5
|
+
// @framework: react
|
|
6
|
+
// @style: generic
|
|
7
|
+
// @variants: [controlled, uncontrolled]
|
|
8
|
+
// @sizes: []
|
|
9
|
+
// @darkMode: false
|
|
10
|
+
// @responsive: false
|
|
11
|
+
// @deps: []
|
|
12
|
+
// @props: { initialValues: T, validate?: (values: T) => Partial<Record<keyof T, string>>, onSubmit: (values: T) => Promise<void> }
|
|
13
|
+
// @states: [idle, validating, submitting, success, error]
|
|
14
|
+
// @example: const { values, errors, handleChange, handleSubmit, isSubmitting } = useForm({ initialValues: { email: '' }, onSubmit: handleSave })
|
|
15
|
+
// @teachMe
|
|
16
|
+
//
|
|
17
|
+
// ## Anatomy
|
|
18
|
+
// Lightweight form state manager without external dependencies.
|
|
19
|
+
// Handles field values, validation errors, touched state, and async submission.
|
|
20
|
+
// Use `handleChange` for input onChange, `handleSubmit` for form onSubmit.
|
|
21
|
+
// Validate function receives current values and returns error map.
|
|
22
|
+
//
|
|
23
|
+
// @goodPractice: Validate on blur (touched) + on submit — not on every keystroke
|
|
24
|
+
// @goodPractice: Disable submit button while isSubmitting to prevent double-submit
|
|
25
|
+
// @goodPractice: Set focus to first error field after validation for UX
|
|
26
|
+
// @goodPractice: Use generic <T> for type-safe field names and values
|
|
27
|
+
// @badPractice: Don't store entire form state in a single useState — use useReducer for complex forms
|
|
28
|
+
// @badPractice: Don't call setValues for every keystroke on large forms — batch updates
|
|
29
|
+
// @badPractice: Don't forget to reset form after successful submission
|
|
30
|
+
|
|
31
|
+
import { useState, useCallback, type FormEvent, type ChangeEvent } from "react"
|
|
32
|
+
|
|
33
|
+
type ValidationErrors<T> = Partial<Record<keyof T, string>>
|
|
34
|
+
|
|
35
|
+
interface UseFormOptions<T extends Record<string, unknown>> {
|
|
36
|
+
initialValues: T
|
|
37
|
+
validate?: (values: T) => ValidationErrors<T>
|
|
38
|
+
onSubmit: (values: T) => Promise<void> | void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface UseFormReturn<T> {
|
|
42
|
+
values: T
|
|
43
|
+
errors: ValidationErrors<T>
|
|
44
|
+
touched: Partial<Record<keyof T, boolean>>
|
|
45
|
+
isSubmitting: boolean
|
|
46
|
+
isDirty: boolean
|
|
47
|
+
handleChange: (field: keyof T) => (e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void
|
|
48
|
+
handleSubmit: (e: FormEvent) => Promise<void>
|
|
49
|
+
setFieldValue: (field: keyof T, value: unknown) => void
|
|
50
|
+
setFieldTouched: (field: keyof T) => void
|
|
51
|
+
resetForm: (newValues?: T) => void
|
|
52
|
+
setErrors: (errors: ValidationErrors<T>) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function useForm<T extends Record<string, unknown>>({
|
|
56
|
+
initialValues,
|
|
57
|
+
validate,
|
|
58
|
+
onSubmit,
|
|
59
|
+
}: UseFormOptions<T>): UseFormReturn<T> {
|
|
60
|
+
const [values, setValues] = useState<T>(initialValues)
|
|
61
|
+
const [errors, setErrors] = useState<ValidationErrors<T>>({})
|
|
62
|
+
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
|
|
63
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
64
|
+
|
|
65
|
+
const isDirty = Object.keys(touched).length > 0
|
|
66
|
+
|
|
67
|
+
const handleChange = useCallback(
|
|
68
|
+
(field: keyof T) =>
|
|
69
|
+
(e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
|
70
|
+
const { value, type, checked } = e.target
|
|
71
|
+
setValues((prev) => ({
|
|
72
|
+
...prev,
|
|
73
|
+
[field]: type === "checkbox" ? checked : value,
|
|
74
|
+
}))
|
|
75
|
+
},
|
|
76
|
+
[]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const handleSubmit = useCallback(
|
|
80
|
+
async (e: FormEvent) => {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
|
|
83
|
+
if (validate) {
|
|
84
|
+
const validationErrors = validate(values)
|
|
85
|
+
setErrors(validationErrors)
|
|
86
|
+
const allTouched = Object.keys(values).reduce(
|
|
87
|
+
(acc, key) => ({ ...acc, [key]: true }),
|
|
88
|
+
{} as Partial<Record<keyof T, boolean>>
|
|
89
|
+
)
|
|
90
|
+
setTouched(allTouched)
|
|
91
|
+
|
|
92
|
+
if (Object.keys(validationErrors).length > 0) {
|
|
93
|
+
const firstErrorField = Object.keys(validationErrors)[0] as keyof T
|
|
94
|
+
document.querySelector<HTMLElement>(`[name="${String(firstErrorField)}"]`)?.focus()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setIsSubmitting(true)
|
|
100
|
+
try {
|
|
101
|
+
await onSubmit(values)
|
|
102
|
+
} finally {
|
|
103
|
+
setIsSubmitting(false)
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[values, validate, onSubmit]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const setFieldValue = useCallback((field: keyof T, value: unknown) => {
|
|
110
|
+
setValues((prev) => ({ ...prev, [field]: value as T[keyof T] }))
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
const setFieldTouched = useCallback((field: keyof T) => {
|
|
114
|
+
setTouched((prev) => ({ ...prev, [field]: true }))
|
|
115
|
+
if (validate) {
|
|
116
|
+
setValues((current) => {
|
|
117
|
+
const validationErrors = validate(current)
|
|
118
|
+
setErrors((prev) => ({ ...prev, ...validationErrors }))
|
|
119
|
+
return current
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}, [validate])
|
|
123
|
+
|
|
124
|
+
const resetForm = useCallback((newValues?: T) => {
|
|
125
|
+
setValues(newValues || initialValues)
|
|
126
|
+
setErrors({})
|
|
127
|
+
setTouched({})
|
|
128
|
+
setIsSubmitting(false)
|
|
129
|
+
}, [initialValues])
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
values,
|
|
133
|
+
errors,
|
|
134
|
+
touched,
|
|
135
|
+
isSubmitting,
|
|
136
|
+
isDirty,
|
|
137
|
+
handleChange,
|
|
138
|
+
handleSubmit,
|
|
139
|
+
setFieldValue,
|
|
140
|
+
setFieldTouched,
|
|
141
|
+
resetForm,
|
|
142
|
+
setErrors,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { useForm }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @template
|
|
2
|
+
// @id: hook-usePagination
|
|
3
|
+
// @category: hook/general
|
|
4
|
+
// @purpose: paginated-list, table-pagination, infinite-scroll
|
|
5
|
+
// @framework: react
|
|
6
|
+
// @style: generic
|
|
7
|
+
// @variants: [client-side, server-side]
|
|
8
|
+
// @sizes: []
|
|
9
|
+
// @darkMode: false
|
|
10
|
+
// @responsive: true
|
|
11
|
+
// @deps: []
|
|
12
|
+
// @props: { totalItems: number, pageSize?: number, initialPage?: number }
|
|
13
|
+
// @states: [first-page, middle-page, last-page, single-page, loading]
|
|
14
|
+
// @example: const { page, setPage, totalPages, pageButtons } = usePagination({ totalItems: 100 })
|
|
15
|
+
// @teachMe
|
|
16
|
+
//
|
|
17
|
+
// ## Anatomy
|
|
18
|
+
// Calculates pagination state given total items and page size. Returns page number, total pages,
|
|
19
|
+
// and a `pageButtons` array for rendering pagination UI (with ellipsis for large page counts).
|
|
20
|
+
// Server-side variant assumes totalItems is known from API response.
|
|
21
|
+
//
|
|
22
|
+
// @goodPractice: Show first + last page always, use ellipsis for gaps (1 ... 4 5 6 ... 20)
|
|
23
|
+
// @goodPractice: Keep pageSize in URL search params for shareable URLs
|
|
24
|
+
// @goodPractice: Disable prev on first page, next on last page
|
|
25
|
+
// @badPractice: Don't render all page buttons for 100+ pages — use ellipsis
|
|
26
|
+
// @badPractice: Don't reset page to 1 on every data change — breaks UX
|
|
27
|
+
// @badPractice: Don't use client-side pagination for > 10k rows — use server-side
|
|
28
|
+
|
|
29
|
+
import { useState, useMemo } from "react"
|
|
30
|
+
|
|
31
|
+
interface UsePaginationOptions {
|
|
32
|
+
totalItems: number
|
|
33
|
+
pageSize?: number
|
|
34
|
+
initialPage?: number
|
|
35
|
+
siblingCount?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface UsePaginationReturn {
|
|
39
|
+
page: number
|
|
40
|
+
setPage: (page: number) => void
|
|
41
|
+
totalPages: number
|
|
42
|
+
pageSize: number
|
|
43
|
+
hasPrev: boolean
|
|
44
|
+
hasNext: boolean
|
|
45
|
+
pageButtons: (number | "ellipsis")[]
|
|
46
|
+
from: number
|
|
47
|
+
to: number
|
|
48
|
+
nextPage: () => void
|
|
49
|
+
prevPage: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function usePagination({
|
|
53
|
+
totalItems,
|
|
54
|
+
pageSize = 20,
|
|
55
|
+
initialPage = 1,
|
|
56
|
+
siblingCount = 1,
|
|
57
|
+
}: UsePaginationOptions): UsePaginationReturn {
|
|
58
|
+
const [page, setPage] = useState(initialPage)
|
|
59
|
+
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
|
|
60
|
+
|
|
61
|
+
const safePage = Math.min(page, totalPages)
|
|
62
|
+
|
|
63
|
+
const pageButtons = useMemo<(number | "ellipsis")[]>(() => {
|
|
64
|
+
const totalPageNumbers = siblingCount + 5
|
|
65
|
+
if (totalPages <= totalPageNumbers) {
|
|
66
|
+
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const leftSiblingIndex = Math.max(safePage - siblingCount, 1)
|
|
70
|
+
const rightSiblingIndex = Math.min(safePage + siblingCount, totalPages)
|
|
71
|
+
const showLeftEllipsis = leftSiblingIndex > 2
|
|
72
|
+
const showRightEllipsis = rightSiblingIndex < totalPages - 1
|
|
73
|
+
|
|
74
|
+
if (!showLeftEllipsis && showRightEllipsis) {
|
|
75
|
+
const leftCount = 3 + 2 * siblingCount
|
|
76
|
+
const leftRange = Array.from({ length: leftCount }, (_, i) => i + 1)
|
|
77
|
+
return [...leftRange, "ellipsis", totalPages]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (showLeftEllipsis && !showRightEllipsis) {
|
|
81
|
+
const rightCount = 3 + 2 * siblingCount
|
|
82
|
+
const rightRange = Array.from({ length: rightCount }, (_, i) => totalPages - rightCount + i + 1)
|
|
83
|
+
return [1, "ellipsis", ...rightRange]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const middleRange = Array.from(
|
|
87
|
+
{ length: rightSiblingIndex - leftSiblingIndex + 1 },
|
|
88
|
+
(_, i) => leftSiblingIndex + i
|
|
89
|
+
)
|
|
90
|
+
return [1, "ellipsis", ...middleRange, "ellipsis", totalPages]
|
|
91
|
+
}, [totalPages, safePage, siblingCount])
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
page: safePage,
|
|
95
|
+
setPage: (p: number) => setPage(Math.max(1, Math.min(p, totalPages))),
|
|
96
|
+
totalPages,
|
|
97
|
+
pageSize,
|
|
98
|
+
hasPrev: safePage > 1,
|
|
99
|
+
hasNext: safePage < totalPages,
|
|
100
|
+
pageButtons,
|
|
101
|
+
from: (safePage - 1) * pageSize + 1,
|
|
102
|
+
to: Math.min(safePage * pageSize, totalItems),
|
|
103
|
+
nextPage: () => setPage((p) => Math.min(p + 1, totalPages)),
|
|
104
|
+
prevPage: () => setPage((p) => Math.max(p - 1, 1)),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { usePagination }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @template
|
|
2
|
+
// @id: service-api
|
|
3
|
+
// @category: service/api
|
|
4
|
+
// @purpose: crud, rest-api, http-client, data-fetching
|
|
5
|
+
// @framework: react
|
|
6
|
+
// @style: generic
|
|
7
|
+
// @variants: [rest, supabase]
|
|
8
|
+
// @sizes: []
|
|
9
|
+
// @darkMode: false
|
|
10
|
+
// @responsive: false
|
|
11
|
+
// @deps: []
|
|
12
|
+
// @props: { baseUrl: string, headers?: Record<string, string> }
|
|
13
|
+
// @states: [idle, loading, success, error]
|
|
14
|
+
// @example: const api = createApiClient('/api/users'); const users = await api.getAll()
|
|
15
|
+
// @teachMe
|
|
16
|
+
//
|
|
17
|
+
// ## Anatomy
|
|
18
|
+
// Generic API client for RESTful services. Supports CRUD operations with typed responses.
|
|
19
|
+
// Built on fetch() with automatic JSON parsing, error handling, and auth header injection.
|
|
20
|
+
// Request interceptor for adding tokens, response interceptor for 401 handling.
|
|
21
|
+
// Use with services/domain files that wrap specific endpoints.
|
|
22
|
+
//
|
|
23
|
+
// @goodPractice: Centralize fetch configuration — base URL, timeout, headers
|
|
24
|
+
// @goodPractice: Handle 401 globally — redirect to login, don't retry
|
|
25
|
+
// @goodPractice: Type all responses — never use `any` for API data
|
|
26
|
+
// @goodPractice: Use AbortController for cancelling requests on unmount
|
|
27
|
+
// @badPractice: Don't catch errors silently — always surface to UI or error boundary
|
|
28
|
+
// @badPractice: Don't store tokens in localStorage without httpOnly cookie fallback
|
|
29
|
+
// @badPractice: Don't retry 4xx errors — only retry 5xx (server) errors
|
|
30
|
+
// @fixHistory: AUTH-002 — removed debug console.log exposing auth tokens
|
|
31
|
+
|
|
32
|
+
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
|
|
33
|
+
|
|
34
|
+
interface ApiResponse<T> {
|
|
35
|
+
data: T | null
|
|
36
|
+
error: ApiError | null
|
|
37
|
+
status: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ApiError {
|
|
41
|
+
message: string
|
|
42
|
+
status: number
|
|
43
|
+
code?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ApiClientConfig {
|
|
47
|
+
baseUrl: string
|
|
48
|
+
headers?: Record<string, string>
|
|
49
|
+
getToken?: () => string | null
|
|
50
|
+
onUnauthorized?: () => void
|
|
51
|
+
timeout?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createApiClient(config: ApiClientConfig) {
|
|
55
|
+
const { baseUrl, headers = {}, getToken, onUnauthorized, timeout = 15000 } = config
|
|
56
|
+
|
|
57
|
+
async function request<T>(method: HttpMethod, path: string, body?: unknown): Promise<ApiResponse<T>> {
|
|
58
|
+
const controller = new AbortController()
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), timeout)
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const token = getToken?.()
|
|
63
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
64
|
+
method,
|
|
65
|
+
headers: {
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
68
|
+
...headers,
|
|
69
|
+
},
|
|
70
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
clearTimeout(timer)
|
|
75
|
+
|
|
76
|
+
if (response.status === 401) {
|
|
77
|
+
onUnauthorized?.()
|
|
78
|
+
return {
|
|
79
|
+
data: null,
|
|
80
|
+
error: { message: "Unauthorized", status: 401, code: "UNAUTHORIZED" },
|
|
81
|
+
status: 401,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const json = await response.json().catch(() => null)
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
return {
|
|
89
|
+
data: null,
|
|
90
|
+
error: {
|
|
91
|
+
message: json?.message || json?.error || `Request failed with status ${response.status}`,
|
|
92
|
+
status: response.status,
|
|
93
|
+
code: json?.code,
|
|
94
|
+
},
|
|
95
|
+
status: response.status,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { data: json as T, error: null, status: response.status }
|
|
100
|
+
} catch (err) {
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
103
|
+
return { data: null, error: { message: "Request timed out", status: 408, code: "TIMEOUT" }, status: 408 }
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
data: null,
|
|
107
|
+
error: { message: err instanceof Error ? err.message : "Network error", status: 0, code: "NETWORK_ERROR" },
|
|
108
|
+
status: 0,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
get: <T>(path: string) => request<T>("GET", path),
|
|
115
|
+
post: <T>(path: string, body: unknown) => request<T>("POST", path, body),
|
|
116
|
+
put: <T>(path: string, body: unknown) => request<T>("PUT", path, body),
|
|
117
|
+
patch: <T>(path: string, body: unknown) => request<T>("PATCH", path, body),
|
|
118
|
+
del: <T>(path: string) => request<T>("DELETE", path),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { createApiClient }
|
|
123
|
+
export type { ApiResponse, ApiError, ApiClientConfig }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @template
|
|
2
|
+
// @id: button-shadcn
|
|
3
|
+
// @category: ui/button
|
|
4
|
+
// @purpose: primary-action, confirmation, danger
|
|
5
|
+
// @framework: react
|
|
6
|
+
// @style: shadcn
|
|
7
|
+
// @variants: [primary, secondary, danger, ghost, outline]
|
|
8
|
+
// @sizes: [sm, default, lg, icon]
|
|
9
|
+
// @darkMode: true
|
|
10
|
+
// @responsive: true
|
|
11
|
+
// @deps: [class-variance-authority, lucide-react]
|
|
12
|
+
// @props: { variant: string, size: string, disabled?: boolean, loading?: boolean, children: ReactNode }
|
|
13
|
+
// @states: [idle, loading, disabled, hover, active, focus-visible]
|
|
14
|
+
// @example: <Button variant="primary" size="default" loading>Save</Button>
|
|
15
|
+
// @teachMe
|
|
16
|
+
//
|
|
17
|
+
// ## Anatomy
|
|
18
|
+
// This Button uses `cva` (class-variance-authority) for type-safe variant management.
|
|
19
|
+
// The loading state renders a spinner icon and disables interaction.
|
|
20
|
+
// Dark mode is handled via Tailwind's `dark:` modifier on the variant classes.
|
|
21
|
+
//
|
|
22
|
+
// @goodPractice: Use cva for variant props — gives autocomplete and runtime safety
|
|
23
|
+
// @goodPractice: Spread remaining props via ...rest for HTML attribute passthrough
|
|
24
|
+
// @goodPractice: Use pointer-events-none during loading to prevent double-clicks
|
|
25
|
+
// @badPractice: Don't omit type="button" — causes unintended form submissions (see BUTTON-001)
|
|
26
|
+
// @badPractice: Don't inline variant styles — breaks dark mode consistency
|
|
27
|
+
// @badPractice: Don't use isLoading boolean alone — use a state machine for complex flows
|
|
28
|
+
// @fixHistory: BUTTON-001 — added type="button" default to prevent form submit
|
|
29
|
+
|
|
30
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
31
|
+
import { Loader2 } from "lucide-react"
|
|
32
|
+
import { memo } from "react"
|
|
33
|
+
|
|
34
|
+
const buttonVariants = cva(
|
|
35
|
+
"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",
|
|
36
|
+
{
|
|
37
|
+
variants: {
|
|
38
|
+
variant: {
|
|
39
|
+
primary:
|
|
40
|
+
"bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600",
|
|
41
|
+
secondary:
|
|
42
|
+
"bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700",
|
|
43
|
+
danger:
|
|
44
|
+
"bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600",
|
|
45
|
+
ghost:
|
|
46
|
+
"hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-100",
|
|
47
|
+
outline:
|
|
48
|
+
"border border-gray-300 hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800",
|
|
49
|
+
},
|
|
50
|
+
size: {
|
|
51
|
+
sm: "h-8 px-3 text-xs",
|
|
52
|
+
default: "h-10 px-4 py-2",
|
|
53
|
+
lg: "h-12 px-6 text-base",
|
|
54
|
+
icon: "h-10 w-10",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
defaultVariants: {
|
|
58
|
+
variant: "primary",
|
|
59
|
+
size: "default",
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
interface ButtonProps
|
|
65
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
66
|
+
VariantProps<typeof buttonVariants> {
|
|
67
|
+
loading?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const Button = memo<ButtonProps>(
|
|
71
|
+
({ className, variant, size, loading = false, disabled, children, ...rest }) => {
|
|
72
|
+
return (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
className={buttonVariants({ variant, size, className })}
|
|
76
|
+
disabled={disabled || loading}
|
|
77
|
+
{...rest}
|
|
78
|
+
>
|
|
79
|
+
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
80
|
+
{children}
|
|
81
|
+
</button>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
Button.displayName = "Button"
|
|
86
|
+
|
|
87
|
+
export { Button, buttonVariants }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @template
|
|
2
|
+
// @id: card-shadcn
|
|
3
|
+
// @category: ui/data-display
|
|
4
|
+
// @purpose: container, summary, feature-card, dashboard-card
|
|
5
|
+
// @framework: react
|
|
6
|
+
// @style: shadcn
|
|
7
|
+
// @variants: [default, bordered, elevated, interactive]
|
|
8
|
+
// @sizes: [sm, default, full]
|
|
9
|
+
// @darkMode: true
|
|
10
|
+
// @responsive: true
|
|
11
|
+
// @deps: []
|
|
12
|
+
// @props: { title?: string, subtitle?: string, footer?: ReactNode, padding?: boolean }
|
|
13
|
+
// @states: [default, hover, loading]
|
|
14
|
+
// @example: <Card title="Revenue" subtitle="Last 30 days"><p>$12,400</p></Card>
|
|
15
|
+
// @teachMe
|
|
16
|
+
//
|
|
17
|
+
// ## Anatomy
|
|
18
|
+
// Card is a container component with optional header (title + subtitle) and footer.
|
|
19
|
+
// `interactive` variant adds hover elevation and cursor pointer for clickable cards.
|
|
20
|
+
// Can show a Skeleton placeholder when loading.
|
|
21
|
+
//
|
|
22
|
+
// @goodPractice: Use semantic padding scale (p-4, p-6) — never fixed widths
|
|
23
|
+
// @goodPractice: Interactive cards need role="button" + onKeyDown for keyboard a11y
|
|
24
|
+
// @goodPractice: Keep card content self-contained — no external margin dependencies
|
|
25
|
+
// @badPractice: Don't nest cards more than 2 levels deep — reduces readability
|
|
26
|
+
// @badPractice: Don't hard-code min-height on cards — let content determine size
|
|
27
|
+
// @badPractice: Don't use cards for simple text — they add visual noise
|
|
28
|
+
|
|
29
|
+
import { type ReactNode } from "react"
|
|
30
|
+
|
|
31
|
+
interface CardProps {
|
|
32
|
+
title?: string
|
|
33
|
+
subtitle?: string
|
|
34
|
+
footer?: ReactNode
|
|
35
|
+
children: ReactNode
|
|
36
|
+
variant?: "default" | "bordered" | "elevated" | "interactive"
|
|
37
|
+
padding?: boolean
|
|
38
|
+
className?: string
|
|
39
|
+
onClick?: () => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const variantStyles: Record<string, string> = {
|
|
43
|
+
default: "bg-white dark:bg-gray-900 shadow-sm",
|
|
44
|
+
bordered: "border border-gray-200 dark:border-gray-700",
|
|
45
|
+
elevated: "bg-white dark:bg-gray-900 shadow-md",
|
|
46
|
+
interactive: "bg-white dark:bg-gray-900 shadow-sm hover:shadow-md cursor-pointer transition-shadow",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Card({
|
|
50
|
+
title,
|
|
51
|
+
subtitle,
|
|
52
|
+
footer,
|
|
53
|
+
children,
|
|
54
|
+
variant = "default",
|
|
55
|
+
padding = true,
|
|
56
|
+
className = "",
|
|
57
|
+
onClick,
|
|
58
|
+
}: CardProps) {
|
|
59
|
+
const Tag = onClick ? "button" : "div"
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Tag
|
|
63
|
+
className={`rounded-xl ${variantStyles[variant]} ${padding ? "p-6" : ""} ${className}`}
|
|
64
|
+
onClick={onClick}
|
|
65
|
+
{...(onClick ? { type: "button" as const } : {})}
|
|
66
|
+
>
|
|
67
|
+
{(title || subtitle) && (
|
|
68
|
+
<div className="mb-4">
|
|
69
|
+
{title && (
|
|
70
|
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h3>
|
|
71
|
+
)}
|
|
72
|
+
{subtitle && (
|
|
73
|
+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{subtitle}</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
<div>{children}</div>
|
|
78
|
+
{footer && (
|
|
79
|
+
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-800">{footer}</div>
|
|
80
|
+
)}
|
|
81
|
+
</Tag>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { Card }
|