@proj-airi/ui 0.9.0-beta.7 → 0.9.0-rc.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@proj-airi/ui",
3
3
  "type": "module",
4
- "version": "0.9.0-beta.7",
4
+ "version": "0.9.0-rc.1",
5
5
  "description": "A collection of UI components that used by Project AIRI",
6
6
  "author": {
7
7
  "name": "Moeru AI Project AIRI Team",
@@ -23,6 +23,7 @@
23
23
  "./*": "./*"
24
24
  },
25
25
  "dependencies": {
26
+ "@moeru/std": "0.1.0-beta.17",
26
27
  "@vueuse/core": "^14.2.1",
27
28
  "floating-vue": "^5.2.2",
28
29
  "reka-ui": "^2.9.2",
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ import { InputFile } from '../input'
3
+
4
+ const props = defineProps<{
5
+ label?: string
6
+ description?: string
7
+ accept?: string
8
+ multiple?: boolean
9
+ placeholder?: string
10
+ }>()
11
+
12
+ const modelValue = defineModel<File[] | undefined>({ required: false })
13
+ </script>
14
+
15
+ <template>
16
+ <div class="max-w-full">
17
+ <label class="flex flex-col gap-4">
18
+ <div>
19
+ <div class="flex items-center gap-1 text-sm font-medium">
20
+ <slot name="label">
21
+ {{ props.label }}
22
+ </slot>
23
+ </div>
24
+ <div class="text-xs text-neutral-500 dark:text-neutral-400">
25
+ <slot name="description">
26
+ {{ props.description }}
27
+ </slot>
28
+ </div>
29
+ </div>
30
+ <InputFile
31
+ v-model="modelValue"
32
+ :accept="props.accept"
33
+ :multiple="props.multiple"
34
+ :placeholder="props.placeholder"
35
+ />
36
+ </label>
37
+ </div>
38
+ </template>
@@ -1,5 +1,6 @@
1
1
  export { default as FieldCheckbox } from './field-checkbox.vue'
2
2
  export { default as FieldCombobox } from './field-combobox-select.vue'
3
+ export { default as FieldInputFile } from './field-input-file.vue'
3
4
  export { default as FieldInput } from './field-input.vue'
4
5
  export { default as FieldKeyValues } from './field-key-values.vue'
5
6
  export { default as FieldRange } from './field-range.vue'
@@ -1,4 +1,5 @@
1
1
  export { default as BasicInputFile } from './basic-input-file.vue'
2
+ export { default as InputFileCard } from './input-file-card.vue'
2
3
  export { default as InputFile } from './input-file.vue'
3
4
  export { default as InputKeyValue } from './input-key-value.vue'
4
5
  export { default as Input } from './input.vue'
@@ -0,0 +1,56 @@
1
+ <script setup lang="ts">
2
+ import { useSlots } from 'vue'
3
+
4
+ import BasicInputFile from './basic-input-file.vue'
5
+
6
+ defineProps<{
7
+ accept?: string
8
+ multiple?: boolean
9
+ }>()
10
+
11
+ const slots = useSlots()
12
+ </script>
13
+
14
+ <template>
15
+ <BasicInputFile
16
+ :class="[
17
+ 'min-h-[120px] flex flex-col cursor-pointer items-center justify-center rounded-xl p-6',
18
+ 'border-dashed border-2',
19
+ 'transition-all duration-300',
20
+ 'opacity-95',
21
+ 'hover:scale-100 hover:opacity-100 hover:shadow-md hover:dark:shadow-lg',
22
+ ]"
23
+ :is-not-dragging-classes="[
24
+ 'border-neutral-200 dark:border-neutral-700 hover:border-primary-300 dark:hover:border-primary-700',
25
+ 'bg-white/60 dark:bg-black/30 hover:bg-white/80 dark:hover:bg-black/40',
26
+ ]"
27
+ :is-dragging-classes="[
28
+ 'border-primary-400 dark:border-primary-600 hover:border-primary-300 dark:hover:border-primary-700',
29
+ 'bg-primary-50/5 dark:bg-primary-900/5',
30
+ ]"
31
+ :accept="accept"
32
+ :multiple="multiple"
33
+ >
34
+ <template #default="{ isDragging }">
35
+ <slot v-if="slots.default" :is-dragging="isDragging" />
36
+ <div
37
+ v-else
38
+ class="flex flex-col items-center"
39
+ :class="[
40
+ isDragging ? 'text-primary-500 dark:text-primary-400' : 'text-neutral-400 dark:text-neutral-500',
41
+ ]"
42
+ >
43
+ <div i-solar:upload-square-line-duotone mb-2 text-5xl />
44
+ <p font-medium text="center lg">
45
+ Upload
46
+ </p>
47
+ <p v-if="isDragging" text="center" text-sm>
48
+ Release to upload
49
+ </p>
50
+ <p v-else text="center" text-sm>
51
+ Click or drag and drop a file here
52
+ </p>
53
+ </div>
54
+ </template>
55
+ </BasicInputFile>
56
+ </template>
@@ -1,56 +1,96 @@
1
1
  <script setup lang="ts">
2
- import { useSlots } from 'vue'
2
+ import { useObjectUrl } from '@vueuse/core'
3
+ import { computed } from 'vue'
3
4
 
4
- import BasicInputFile from './basic-input-file.vue'
5
-
6
- defineProps<{
5
+ const props = withDefaults(defineProps<{
7
6
  accept?: string
8
7
  multiple?: boolean
9
- }>()
8
+ placeholder?: string
9
+ }>(), {
10
+ placeholder: 'Choose file',
11
+ multiple: false,
12
+ })
13
+
14
+ const modelValue = defineModel<File[] | undefined>({ default: undefined })
15
+
16
+ const fileNames = computed(() => {
17
+ const files = modelValue.value ?? []
18
+ if (!files.length)
19
+ return props.placeholder
20
+ return files.map(file => file.name).join(', ')
21
+ })
22
+
23
+ const previewImageFile = computed(() => {
24
+ const files = modelValue.value ?? []
25
+ return files.find(file => file.type.startsWith('image/'))
26
+ })
27
+
28
+ const previewUrl = useObjectUrl(previewImageFile)
29
+
30
+ function onFileChange(event: Event) {
31
+ const input = event.target as HTMLInputElement
32
+ if (!input.files) {
33
+ modelValue.value = undefined
34
+ return
35
+ }
10
36
 
11
- const slots = useSlots()
37
+ const files = Array.from(input.files)
38
+ modelValue.value = files.length ? files : undefined
39
+
40
+ // Allow re-selecting the same file.
41
+ input.value = ''
42
+ }
12
43
  </script>
13
44
 
14
45
  <template>
15
- <BasicInputFile
46
+ <label
16
47
  :class="[
17
- 'min-h-[120px] flex flex-col cursor-pointer items-center justify-center rounded-xl p-6',
18
- 'border-dashed border-2',
19
- 'transition-all duration-300',
20
- 'opacity-95',
21
- 'hover:scale-100 hover:opacity-100 hover:shadow-md hover:dark:shadow-lg',
22
- ]"
23
- :is-not-dragging-classes="[
24
- 'border-neutral-200 dark:border-neutral-700 hover:border-primary-300 dark:hover:border-primary-700',
25
- 'bg-white/60 dark:bg-black/30 hover:bg-white/80 dark:hover:bg-black/40',
26
- ]"
27
- :is-dragging-classes="[
28
- 'border-primary-400 dark:border-primary-600 hover:border-primary-300 dark:hover:border-primary-700',
29
- 'bg-primary-50/5 dark:bg-primary-900/5',
48
+ 'w-full flex cursor-pointer items-center gap-2',
49
+ 'rounded-lg border-2 border-solid border-neutral-100 bg-neutral-50 px-2 py-1 shadow-sm',
50
+ 'transition-all duration-200 ease-in-out',
51
+ 'dark:border-neutral-900 dark:bg-neutral-950',
52
+ 'hover:border-primary-300/70 dark:hover:border-primary-700/70',
30
53
  ]"
31
- :accept="accept"
32
- :multiple="multiple"
33
54
  >
34
- <template #default="{ isDragging }">
35
- <slot v-if="slots.default" :is-dragging="isDragging" />
36
- <div
37
- v-else
38
- class="flex flex-col items-center"
55
+ <input
56
+ type="file"
57
+ :accept="accept"
58
+ :multiple="multiple"
59
+ :class="[
60
+ 'hidden',
61
+ ]"
62
+ @change="onFileChange"
63
+ >
64
+
65
+ <div
66
+ :class="[
67
+ 'i-solar:upload-square-line-duotone h-5 w-5 shrink-0 text-neutral-500 dark:text-neutral-400',
68
+ ]"
69
+ />
70
+
71
+ <div
72
+ :class="[
73
+ 'min-w-0 flex-1 truncate text-sm text-neutral-600 dark:text-neutral-300',
74
+ ]"
75
+ :title="fileNames"
76
+ >
77
+ {{ fileNames }}
78
+ </div>
79
+
80
+ <div
81
+ v-if="previewUrl"
82
+ :class="[
83
+ 'h-8 w-8 shrink-0 overflow-hidden rounded-md border border-neutral-200 bg-white',
84
+ 'dark:border-neutral-700 dark:bg-neutral-900',
85
+ ]"
86
+ >
87
+ <img
88
+ :src="previewUrl"
89
+ alt="Preview"
39
90
  :class="[
40
- isDragging ? 'text-primary-500 dark:text-primary-400' : 'text-neutral-400 dark:text-neutral-500',
91
+ 'h-full w-full object-cover',
41
92
  ]"
42
93
  >
43
- <div i-solar:upload-square-line-duotone mb-2 text-5xl />
44
- <p font-medium text="center lg">
45
- Upload
46
- </p>
47
- <p v-if="isDragging" text="center" text-sm>
48
- Release to upload
49
- </p>
50
- <p v-else text="center" text-sm>
51
- Click or drag and drop a file here
52
- </p>
53
- </div>
54
- </template>
55
- </BasicInputFile>
94
+ </div>
95
+ </label>
56
96
  </template>
@@ -19,6 +19,7 @@ interface ButtonProps {
19
19
  loading?: boolean // Loading state
20
20
  variant?: ButtonVariant // Button style variant
21
21
  size?: ButtonSize // Button size variant
22
+ shape?: 'rounded' | 'pill' | 'square' // Button shape
22
23
  theme?: ButtonTheme // Button theme
23
24
  block?: boolean // Full width button
24
25
  }
@@ -127,9 +128,21 @@ const variantClasses: Record<ButtonVariant, Record<ButtonTheme, {
127
128
 
128
129
  // Extract size styles for better organization
129
130
  const sizeClasses: Record<ButtonSize, string> = {
130
- sm: 'px-3 py-1.5 text-xs',
131
- md: 'px-4 py-2 text-sm',
132
- lg: 'px-6 py-3 text-base',
131
+ sm: props.shape === 'pill'
132
+ ? 'px-3 py-1.5 text-xs'
133
+ : props.shape === 'square'
134
+ ? 'p-2 text-xs'
135
+ : 'px-4 py-2 text-sm',
136
+ md: props.shape === 'pill'
137
+ ? 'px-4 py-2 text-sm'
138
+ : props.shape === 'square'
139
+ ? 'p-3 text-sm'
140
+ : 'px-5 py-3 text-base',
141
+ lg: props.shape === 'pill'
142
+ ? 'px-6 py-3 text-base'
143
+ : props.shape === 'square'
144
+ ? 'p-4 text-base'
145
+ : 'px-6 py-3 text-base',
133
146
  }
134
147
 
135
148
  // Base classes that are always applied
@@ -0,0 +1,219 @@
1
+ <script setup lang="ts">
2
+ import { errorCauseFrom, errorMessageFrom, errorNameFrom, errorStackFrom } from '@moeru/std'
3
+ import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
4
+ import { computed, shallowRef } from 'vue'
5
+
6
+ import Button from './button.vue'
7
+
8
+ type HeightPreset = 'sm' | 'md' | 'lg' | 'xl' | 'auto'
9
+
10
+ interface ContainerErrorProps {
11
+ error?: unknown
12
+ message?: string
13
+ stack?: string
14
+ includeStack?: boolean
15
+ showCopyButton?: boolean
16
+ showFeedbackButton?: boolean
17
+ copyButtonLabel?: string
18
+ copiedButtonLabel?: string
19
+ feedbackButtonLabel?: string
20
+ heightPreset?: HeightPreset
21
+ }
22
+
23
+ const props = withDefaults(defineProps<ContainerErrorProps>(), {
24
+ includeStack: true,
25
+ showCopyButton: true,
26
+ showFeedbackButton: true,
27
+ copyButtonLabel: 'Copy',
28
+ copiedButtonLabel: 'Copied',
29
+ feedbackButtonLabel: 'Feedback',
30
+ heightPreset: 'md',
31
+ })
32
+
33
+ const emit = defineEmits<{
34
+ (e: 'copy', content: string): void
35
+ (e: 'feedback'): void
36
+ }>()
37
+
38
+ const copied = shallowRef(false)
39
+
40
+ const heightPresetClasses: Record<HeightPreset, string[]> = {
41
+ sm: ['h-32'],
42
+ md: ['h-36'],
43
+ lg: ['h-42'],
44
+ xl: ['h-48'],
45
+ auto: [],
46
+ }
47
+
48
+ function indentLines(content: string, indent = 2): string {
49
+ const spaces = ' '.repeat(indent)
50
+ return content
51
+ .split('\n')
52
+ .map(line => `${spaces}${line}`)
53
+ .join('\n')
54
+ }
55
+
56
+ const resolvedErrorName = computed(() => {
57
+ return (errorNameFrom(props.error) ?? '').trim()
58
+ })
59
+
60
+ const resolvedMessage = computed(() => {
61
+ const fromProp = props.message?.trim()
62
+ if (fromProp)
63
+ return fromProp
64
+
65
+ const normalizedMessage = errorMessageFrom(props.error)
66
+ if (normalizedMessage)
67
+ return normalizedMessage.trim()
68
+
69
+ return props.error == null ? '' : String(props.error).trim()
70
+ })
71
+
72
+ const resolvedStack = computed(() => {
73
+ const fromProp = props.stack?.trim()
74
+ if (fromProp) {
75
+ const index = fromProp.indexOf(resolvedMessage.value)
76
+ if (index >= 0)
77
+ return fromProp.slice(index + resolvedMessage.value.length).trim()
78
+ }
79
+
80
+ if (!props.includeStack)
81
+ return ''
82
+
83
+ const resolved = (errorStackFrom(props.error) ?? '').trim()
84
+ const index = resolved.indexOf(resolvedMessage.value)
85
+ if (index >= 0)
86
+ return resolved.slice(index + resolvedMessage.value.length).trim()
87
+
88
+ return resolved
89
+ })
90
+
91
+ const resolvedCause = computed(() => {
92
+ if (props.error == null)
93
+ return ''
94
+
95
+ const cause = errorCauseFrom(props.error)
96
+ if (cause == null)
97
+ return ''
98
+
99
+ if (cause instanceof Error) {
100
+ const name = errorNameFrom(cause) ?? cause.name ?? 'Error'
101
+ const message = (errorMessageFrom(cause) ?? cause.message ?? '').trim()
102
+ const stack = (errorStackFrom(cause) ?? cause.stack ?? '').trim()
103
+
104
+ const header = message ? `${name}: ${message}` : name
105
+ if (!stack)
106
+ return header
107
+
108
+ return `${header}\n${indentLines(stack, 2)}`
109
+ }
110
+
111
+ return String(cause).trim()
112
+ })
113
+
114
+ const panelContent = computed(() => {
115
+ const sections: string[] = []
116
+
117
+ if (resolvedErrorName.value || resolvedMessage.value) {
118
+ const header = resolvedErrorName.value
119
+ ? (resolvedMessage.value ? `${resolvedErrorName.value}: ${resolvedMessage.value}` : resolvedErrorName.value)
120
+ : resolvedMessage.value
121
+ if (header)
122
+ sections.push(header)
123
+ }
124
+
125
+ if (resolvedStack.value)
126
+ sections.push(`Stack:\n${resolvedStack.value}`)
127
+
128
+ if (resolvedCause.value)
129
+ sections.push(`Cause:\n${resolvedCause.value}`)
130
+
131
+ return sections.join('\n\n')
132
+ })
133
+
134
+ async function copyContent() {
135
+ if (!panelContent.value)
136
+ return
137
+
138
+ emit('copy', panelContent.value)
139
+
140
+ try {
141
+ const clipboard = globalThis.navigator?.clipboard
142
+ if (!clipboard)
143
+ return
144
+
145
+ await clipboard.writeText(panelContent.value)
146
+ copied.value = true
147
+ globalThis.setTimeout(() => {
148
+ copied.value = false
149
+ }, 1200)
150
+ }
151
+ catch {
152
+ // Ignore clipboard failures and still keep emitted copy payload.
153
+ }
154
+ }
155
+ </script>
156
+
157
+ <template>
158
+ <div
159
+ :class="[
160
+ 'relative w-full rounded-xl border-2 border-red-200/70 bg-red-50/60 backdrop-blur-md p-1',
161
+ 'dark:border-red-900/50 dark:bg-red-950/25',
162
+ ]"
163
+ >
164
+ <div :class="['absolute right-4 -translate-x-full top-2 z-10']">
165
+ <Button
166
+ v-if="showCopyButton"
167
+ size="sm"
168
+ variant="secondary"
169
+ shape="square"
170
+ icon="i-solar:copy-line-duotone"
171
+ :title="copied ? copiedButtonLabel : copyButtonLabel"
172
+ :aria-label="copied ? copiedButtonLabel : copyButtonLabel"
173
+ @click="copyContent"
174
+ />
175
+ </div>
176
+
177
+ <div :class="['absolute right-2 top-2 z-10']">
178
+ <Button
179
+ v-if="showFeedbackButton"
180
+ size="sm"
181
+ variant="secondary"
182
+ shape="square"
183
+ icon="i-solar:square-share-line-line-duotone"
184
+ :title="feedbackButtonLabel"
185
+ :aria-label="feedbackButtonLabel"
186
+ @click="emit('feedback')"
187
+ />
188
+ </div>
189
+
190
+ <ScrollAreaRoot
191
+ type="auto"
192
+ :class="[
193
+ 'relative w-full overflow-hidden rounded-xl',
194
+ 'bg-neutral-50/80 dark:bg-neutral-950/80',
195
+ ...heightPresetClasses[heightPreset],
196
+ ]"
197
+ >
198
+ <ScrollAreaViewport :class="['h-full w-full']">
199
+ <div :class="['flex flex-col gap-2 p-3 text-xs']">
200
+ <div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono font-semibold text-red-700 leading-relaxed dark:text-red-300']">
201
+ {{ resolvedErrorName || 'Error' }}
202
+ <span v-if="resolvedMessage">
203
+ : {{ resolvedMessage }}
204
+ </span>
205
+ </div>
206
+ <pre v-if="resolvedStack" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']"> {{ resolvedStack }}</pre>
207
+ <pre v-if="resolvedCause" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']">{{ `Cause:\n${resolvedCause}` }}</pre>
208
+ <div v-if="!panelContent" :class="['text-neutral-600 dark:text-neutral-300']">
209
+ No error details available.
210
+ </div>
211
+ </div>
212
+ </ScrollAreaViewport>
213
+ <ScrollAreaScrollbar orientation="vertical" :class="['w-2 p-0.5']">
214
+ <ScrollAreaThumb :class="['rounded-full bg-neutral-300/80 dark:bg-neutral-700/80']" />
215
+ </ScrollAreaScrollbar>
216
+ <ScrollAreaCorner />
217
+ </ScrollAreaRoot>
218
+ </div>
219
+ </template>
@@ -1,4 +1,5 @@
1
1
  export { default as Button } from './button.vue'
2
2
  export { default as Callout } from './callout.vue'
3
+ export { default as ContainerError } from './container-error.vue'
3
4
  export { default as DoubleCheckButton } from './double-check-button.vue'
4
5
  export { default as Progress } from './progress.vue'