@saasmakers/ui 0.1.50 → 0.1.51

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.
@@ -0,0 +1,177 @@
1
+ <script lang="ts" setup>
2
+ import { NuxtLinkLocale } from '#components'
3
+ import { numbro } from '../../composables/useUtils'
4
+
5
+ const props = defineProps({
6
+ bordered: {
7
+ default: true,
8
+ type: Boolean,
9
+ },
10
+ borderWidth: {
11
+ default: 3,
12
+ type: Number,
13
+ },
14
+ circular: {
15
+ default: true,
16
+ type: Boolean,
17
+ },
18
+ editable: {
19
+ default: false,
20
+ type: Boolean,
21
+ },
22
+ habitsCompleted: {
23
+ default: undefined,
24
+ type: Number,
25
+ },
26
+ maxSizeMb: {
27
+ default: undefined,
28
+ type: Number,
29
+ },
30
+ shadow: {
31
+ default: true,
32
+ type: Boolean,
33
+ },
34
+ src: {
35
+ default: undefined,
36
+ type: String,
37
+ },
38
+ to: {
39
+ default: undefined,
40
+ type: Object as PropType<RouteLocationNamedI18n>,
41
+ },
42
+ })
43
+
44
+ const emit = defineEmits<{
45
+ avatarSelected: [event: Event, file: File]
46
+ click: [event: MouseEvent]
47
+ }>()
48
+
49
+ const { createToast } = useToasts()
50
+
51
+ const hovered = ref(false)
52
+ const error = ref(false)
53
+ const loaded = ref(false)
54
+ const fileInput = ref<HTMLInputElement>()
55
+
56
+ const { t } = useI18n()
57
+
58
+ function onAvatarSelected(event: Event) {
59
+ const file = (event.target as HTMLInputElement).files?.[0]
60
+
61
+ if (file) {
62
+ if (props.maxSizeMb && file.size > props.maxSizeMb * 1024 * 1024) {
63
+ return createToast('fileSizeTooLarge', 'error', { maxSizeMb: props.maxSizeMb })
64
+ }
65
+
66
+ emit('avatarSelected', event, file)
67
+ }
68
+ }
69
+
70
+ function onClick(event: MouseEvent) {
71
+ emit('click', event)
72
+
73
+ if (props.editable) {
74
+ fileInput.value?.click()
75
+ }
76
+ }
77
+
78
+ function onError() {
79
+ error.value = true
80
+ loaded.value = true
81
+ }
82
+
83
+ function onMouseEnter() {
84
+ hovered.value = true
85
+ }
86
+
87
+ function onMouseLeave() {
88
+ hovered.value = false
89
+ }
90
+
91
+ function onLoad() {
92
+ loaded.value = true
93
+ }
94
+ </script>
95
+
96
+ <template>
97
+ <component
98
+ :is="to ? NuxtLinkLocale : 'div'"
99
+ class="relative flex"
100
+ :to="to"
101
+ @mouseenter="onMouseEnter"
102
+ @mouseleave="onMouseLeave"
103
+ >
104
+ <div
105
+ :aria-label="editable ? t('globals.edit') : undefined"
106
+ class="relative h-full w-full overflow-hidden"
107
+ :class="{
108
+ 'rounded-full': circular,
109
+ 'shadow': shadow,
110
+ 'cursor-pointer': to || editable,
111
+ }"
112
+ :role="editable ? 'button' : undefined"
113
+ @click="onClick"
114
+ >
115
+ <img
116
+ class="h-full w-full object-cover drag-none"
117
+ :class="{
118
+ 'rounded-full': circular,
119
+ 'border-white dark:border-white': bordered,
120
+ 'border-2': borderWidth === 2,
121
+ 'border-3': borderWidth === 3,
122
+ 'border-4': borderWidth === 4,
123
+ }"
124
+ loading="lazy"
125
+ :src="!error && src ? src : '/images/bases/BaseAvatar/default.svg'"
126
+ @error="onError"
127
+ @load="onLoad"
128
+ >
129
+
130
+ <BaseText
131
+ v-if="editable && hovered"
132
+ class="absolute bottom-0 left-0 right-0 w-full bg-gray-900 text-center text-white dark:bg-gray-900 dark:text-white"
133
+ size="3xs"
134
+ :text="t('globals.edit')"
135
+ />
136
+
137
+ <input
138
+ v-if="editable"
139
+ ref="fileInput"
140
+ accept="image/*"
141
+ class="hidden"
142
+ type="file"
143
+ @change="onAvatarSelected"
144
+ >
145
+ </div>
146
+
147
+ <BaseText
148
+ v-if="habitsCompleted && (!editable || (editable && !hovered))"
149
+ v-tooltip="t('habitsCompleted')"
150
+ class="absolute left-1/2 transform select-none rounded bg-indigo-800 text-center text-white opacity-95 shadow -bottom-0.5 -translate-x-1/2 dark:bg-indigo-200 dark:text-black"
151
+ :class="{
152
+ 'w-6': habitsCompleted < 100,
153
+ 'w-7': habitsCompleted >= 100 && habitsCompleted < 1000,
154
+ 'w-9': habitsCompleted >= 1000 && habitsCompleted < 10000,
155
+ 'w-10': habitsCompleted >= 10000 && habitsCompleted < 100000,
156
+ 'w-11': habitsCompleted >= 100000 && habitsCompleted < 1000000,
157
+ 'w-13': habitsCompleted >= 1000000,
158
+ }"
159
+ size="2xs"
160
+ :text="numbro(habitsCompleted)"
161
+ />
162
+ </component>
163
+ </template>
164
+
165
+ <i18n lang="json">
166
+ {
167
+ "en": {
168
+ "habitsCompleted": "Habits completed"
169
+ },
170
+ "fr": {
171
+ "habitsCompleted": "Habitudes accomplies"
172
+ },
173
+ "ja": {
174
+ "habitsCompleted": "習慣を完了しました"
175
+ }
176
+ }
177
+ </i18n>
@@ -0,0 +1,50 @@
1
+ <script lang="ts" setup>
2
+ import type { BaseToast } from '../../types/bases'
3
+ import { getIcon } from '../../composables/useIcons'
4
+
5
+ const props = withDefaults(defineProps<BaseToast>(), {
6
+ hasClose: true,
7
+ id: '',
8
+ status: 'info',
9
+ text: '',
10
+ })
11
+
12
+ const emit = defineEmits<{
13
+ close: [event: MouseEvent, toast: BaseToast]
14
+ }>()
15
+
16
+ function onClose(event: MouseEvent) {
17
+ if (props.hasClose) {
18
+ emit('close', event, props)
19
+ }
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <div
25
+ class="group flex select-none items-center rounded-full bg-gray-900 px-3 py-2 text-base text-white font-normal dark:bg-gray-100 dark:text-black"
26
+ :class="{ 'cursor-pointer': hasClose }"
27
+ @click="onClose"
28
+ >
29
+ <BaseIcon
30
+ class="flex-initial"
31
+ :status="status"
32
+ :text="text"
33
+ />
34
+
35
+ <template v-if="hasClose">
36
+ <div class="ml-2 mr-1.5 h-5 border-l border-gray-700 flex-initial dark:border-gray-300" />
37
+
38
+ <BaseIcon
39
+ class="text-gray-500 flex-initial dark:text-gray-500"
40
+ :class="{
41
+ 'group-hover:text-red-600 dark:group-hover:text-red-400': status === 'error',
42
+ 'group-hover:text-indigo-600 dark:group-hover:text-indigo-400': status === 'info',
43
+ 'group-hover:text-orange-600 dark:group-hover:text-orange-400': status === 'warning',
44
+ 'group-hover:text-teal-600 dark:group-hover:text-teal-400': status === 'success',
45
+ }"
46
+ :icon="getIcon('close')"
47
+ />
48
+ </template>
49
+ </div>
50
+ </template>
@@ -0,0 +1,30 @@
1
+ <script lang="ts" setup>
2
+ const { fadeInUp } = useMotion()
3
+ const toasts = useToasts()
4
+
5
+ function onToastClose(_event: MouseEvent, toast: BaseToast) {
6
+ toasts.closeToast(toast.id)
7
+ }
8
+ </script>
9
+
10
+ <template>
11
+ <div class="fixed bottom-0 left-0 right-0 z-10 flex flex-col items-center overflow-hidden safe-bottom">
12
+ <div class="mb-20 flex flex-col items-center">
13
+ <Motion
14
+ v-for="toast in toasts.toasts.value"
15
+ :key="toast.id"
16
+ :animate="fadeInUp.animate"
17
+ class="mb-2 flex-initial last:mb-0"
18
+ :initial="fadeInUp.initial"
19
+ >
20
+ <BaseToast
21
+ :id="toast.id"
22
+ :key="toast.id"
23
+ :status="toast.status"
24
+ :text="toast.text"
25
+ @close="onToastClose"
26
+ />
27
+ </Motion>
28
+ </div>
29
+ </div>
30
+ </template>
@@ -0,0 +1,41 @@
1
+ const toasts = ref<BaseToast[]>([])
2
+
3
+ export default function useToast() {
4
+ const nuxtApp = useNuxtApp()
5
+
6
+ const clearToasts = () => {
7
+ if (import.meta.client) {
8
+ toasts.value = []
9
+ }
10
+ }
11
+
12
+ const closeToast = (toastId: string) => {
13
+ if (import.meta.client) {
14
+ toasts.value = toasts.value.filter((item) => {
15
+ return item.id !== toastId
16
+ })
17
+ }
18
+ }
19
+
20
+ const createToast = (message: string, status: BaseStatus = 'success', i18nParams?: { [key: string]: number | string }) => {
21
+ if (import.meta.client) {
22
+ const toast: BaseToast = {
23
+ id: Math.floor((1 + Math.random()) * 0x10000).toString(16),
24
+ status: status || 'success',
25
+ text: message.includes(' ') ? message : nuxtApp.$i18n.t(`toasts.${message}`, i18nParams || {}),
26
+ }
27
+
28
+ toasts.value.push(toast)
29
+
30
+ // Remove toast after 5 seconds
31
+ setTimeout(() => closeToast(toast.id), 5 * 1000)
32
+ }
33
+ }
34
+
35
+ return {
36
+ clearToasts,
37
+ closeToast,
38
+ createToast,
39
+ toasts,
40
+ }
41
+ }
@@ -0,0 +1,12 @@
1
+ import numbroLib from 'numbro'
2
+
3
+ export function numbro(number: '∞' | number | undefined, format?: string) {
4
+ if (!number && number !== 0) {
5
+ return ''
6
+ }
7
+ else if (number === '∞' || number % 1 !== 0) {
8
+ return `${number}`
9
+ }
10
+
11
+ return numbroLib(number).format(format || '0,0')
12
+ }
@@ -35,6 +35,16 @@ export interface BaseAlert {
35
35
  text?: BaseTextText
36
36
  }
37
37
 
38
+ interface BaseAvatar {
39
+ bordered?: boolean
40
+ borderWidth?: number
41
+ circular?: boolean
42
+ habitsCompleted?: number
43
+ shadow?: boolean
44
+ src?: string
45
+ to?: RouteLocationNamedI18n
46
+ }
47
+
38
48
  export interface BaseBordered {
39
49
  background?: BaseBackground
40
50
  borderColor?: BaseBorderedColor
@@ -182,3 +192,10 @@ export interface BaseText {
182
192
  }
183
193
 
184
194
  export type BaseTextText = string | { base: string, sm: string }
195
+
196
+ export interface BaseToast {
197
+ hasClose?: boolean
198
+ id: string
199
+ status?: BaseStatus
200
+ text: BaseTextText
201
+ }
@@ -6,6 +6,7 @@ declare global {
6
6
 
7
7
  // Bases
8
8
  type BaseAlert = Bases.BaseAlert
9
+ type BaseAvatar = Bases.BaseAvatar
9
10
  type BaseBackground = Bases.BaseBackground
10
11
  type BaseBordered = Bases.BaseBordered
11
12
  type BaseBorderedColor = Bases.BaseBorderedColor
@@ -38,6 +39,9 @@ declare global {
38
39
  type BaseStatus = Bases.BaseStatus
39
40
  type BaseText = Bases.BaseText
40
41
  type BaseTextText = Bases.BaseTextText
42
+ type BaseToast = Bases.BaseToast
43
+ type BaseToasts = Bases.BaseToasts
44
+ type BaseToastsAlignment = Bases.BaseToastsAlignment
41
45
  }
42
46
 
43
47
  export * from './bases'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saasmakers/ui",
3
3
  "type": "module",
4
- "version": "0.1.50",
4
+ "version": "0.1.51",
5
5
  "private": false,
6
6
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
7
7
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "app",
20
20
  "nuxt.config.ts",
21
+ "public",
21
22
  "uno.config.ts"
22
23
  ],
23
24
  "scripts": {
@@ -42,6 +43,7 @@
42
43
  "@unocss/reset": "66.5.10",
43
44
  "floating-vue": "5.2.2",
44
45
  "motion-v": "1.7.4",
46
+ "numbro": "2.5.0",
45
47
  "unocss": "66.5.4",
46
48
  "vue": "3.5.22",
47
49
  "vue-router": "4.6.3"
@@ -0,0 +1,22 @@
1
+ <svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <rect width="200" height="200" fill="#F8FAFC"/>
3
+ <g clip-path="url(#clip0)">
4
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M171.236 99.9916C171.236 73.1755 149.464 51.4038 122.648 51.4038H77.4786C50.6624 51.4038 28.891 73.1755 28.891 99.9916C28.891 126.808 50.6624 148.579 77.4786 148.579H122.648C149.464 148.579 171.236 126.808 171.236 99.9916Z" fill="url(#paint0_radial)"/>
5
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M158.977 107.28C158.977 80.0811 132.65 71.9116 100.695 71.9116C64.5672 71.9116 41.159 79.4983 41.159 107.28C41.159 132.536 67.2716 141.093 100.258 141.093C138.413 141.093 158.977 135.644 158.977 107.28Z" fill="url(#paint1_radial)"/>
6
+ <path d="M75.8 124.527C81.5881 124.527 86.2802 118.069 86.2802 110.103C86.2802 102.137 81.5881 95.6787 75.8 95.6787C70.012 95.6787 65.3198 102.137 65.3198 110.103C65.3198 118.069 70.012 124.527 75.8 124.527Z" fill="#F9F9F9"/>
7
+ <path d="M126.74 124.747C132.528 124.747 137.22 118.289 137.22 110.323C137.22 102.356 132.528 95.8984 126.74 95.8984C120.952 95.8984 116.26 102.356 116.26 110.323C116.26 118.289 120.952 124.747 126.74 124.747Z" fill="#F9F9F9"/>
8
+ </g>
9
+ <defs>
10
+ <radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(93.6026 101.03) rotate(-0.40466) scale(149.159 170.801)">
11
+ <stop stop-color="#FCFCFC"/>
12
+ <stop offset="1" stop-color="#D4D4D4"/>
13
+ </radialGradient>
14
+ <radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(101.232 106.114) rotate(-0.246549) scale(90.3212 53.0581)">
15
+ <stop stop-color="#2E3748"/>
16
+ <stop offset="1" stop-color="#030812"/>
17
+ </radialGradient>
18
+ <clipPath id="clip0">
19
+ <rect width="150" height="150" fill="white" transform="translate(25 25)"/>
20
+ </clipPath>
21
+ </defs>
22
+ </svg>