sprintify-ui 0.11.28 → 0.11.30

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.
@@ -1,10 +1,26 @@
1
1
  import { PropType } from 'vue';
2
2
  import { User } from '@/types/User';
3
3
  import { RouteLocationRaw } from 'vue-router';
4
+ type AvatarUserInput = Partial<User> & {
5
+ name?: string;
6
+ label?: string;
7
+ };
4
8
  declare const _default: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
5
9
  user: {
6
- required: true;
7
- type: PropType<User>;
10
+ default: undefined;
11
+ type: PropType<AvatarUserInput>;
12
+ };
13
+ name: {
14
+ default: undefined;
15
+ type: StringConstructor;
16
+ };
17
+ email: {
18
+ default: undefined;
19
+ type: StringConstructor;
20
+ };
21
+ src: {
22
+ default: undefined;
23
+ type: StringConstructor;
8
24
  };
9
25
  size: {
10
26
  default: string;
@@ -32,8 +48,20 @@ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropT
32
48
  };
33
49
  }>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
34
50
  user: {
35
- required: true;
36
- type: PropType<User>;
51
+ default: undefined;
52
+ type: PropType<AvatarUserInput>;
53
+ };
54
+ name: {
55
+ default: undefined;
56
+ type: StringConstructor;
57
+ };
58
+ email: {
59
+ default: undefined;
60
+ type: StringConstructor;
61
+ };
62
+ src: {
63
+ default: undefined;
64
+ type: StringConstructor;
37
65
  };
38
66
  size: {
39
67
  default: string;
@@ -61,9 +89,13 @@ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropT
61
89
  };
62
90
  }>> & Readonly<{}>, {
63
91
  class: string;
92
+ email: string;
64
93
  to: string | import("vue-router").RouteLocationAsRelativeGeneric | import("vue-router").RouteLocationAsPathGeneric;
65
94
  size: string;
95
+ name: string;
66
96
  tooltip: boolean;
97
+ user: AvatarUserInput;
98
+ src: string;
67
99
  showDetails: boolean;
68
100
  detailsPosition: "left" | "right";
69
101
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
@@ -1,8 +1,9 @@
1
1
  import toHumanList from './toHumanList';
2
2
  import fileSizeFormat from './fileSizeFormat';
3
3
  import resizeImageFromURI from './resizeImageFromURI';
4
+ import resizeImageForUpload from './resizeImageForUpload';
4
5
  import { disableScroll, enableScroll } from './scrollPreventer';
5
6
  import { blobToBase64, validateBase64, base64ToBlob } from './blob';
6
7
  import { getColorConfig } from './colors';
7
8
  import { getInitials, getAvatarColor } from './avatar';
8
- export { toHumanList, fileSizeFormat, disableScroll, enableScroll, resizeImageFromURI, blobToBase64, validateBase64, base64ToBlob, getColorConfig, getInitials, getAvatarColor, };
9
+ export { toHumanList, fileSizeFormat, disableScroll, enableScroll, resizeImageFromURI, resizeImageForUpload, blobToBase64, validateBase64, base64ToBlob, getColorConfig, getInitials, getAvatarColor, };
@@ -0,0 +1,7 @@
1
+ type ResizeImageForUploadOptions = {
2
+ maxBytes: number;
3
+ outputMimeType?: string;
4
+ quality?: number;
5
+ };
6
+ export default function resizeImageForUpload(file: Blob, options: ResizeImageForUploadOptions): Promise<Blob>;
7
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprintify-ui",
3
- "version": "0.11.28",
3
+ "version": "0.11.30",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "generate-llm-txt": "node scripts/generate-llm-txt.js",
@@ -1,6 +1,5 @@
1
1
  <template>
2
2
  <BaseTooltip
3
- v-if="user"
4
3
  :visible="tooltip"
5
4
  :text="tooltipText"
6
5
  :class="classInternal"
@@ -11,8 +10,8 @@
11
10
  class="flex items-center"
12
11
  >
13
12
  <img
14
- v-if="user.avatar_url"
15
- :src="user.avatar_url"
13
+ v-if="resolvedUrl"
14
+ :src="resolvedUrl"
16
15
  :class="[sizeClass, detailsPosition == 'left' ? 'order-2' : 'order-1']"
17
16
  class="shrink-0 block overflow-hidden rounded-full object-cover object-center"
18
17
  >
@@ -54,10 +53,10 @@
54
53
  }"
55
54
  >
56
55
  <div class="truncate">
57
- {{ user.full_name }}
56
+ {{ resolvedName }}
58
57
  </div>
59
58
  <div class="truncate opacity-50">
60
- {{ user.email }}
59
+ {{ resolvedEmail }}
61
60
  </div>
62
61
  </div>
63
62
  </component>
@@ -76,10 +75,27 @@ defineOptions({
76
75
  inheritAttrs: false,
77
76
  })
78
77
 
78
+ type AvatarUserInput = Partial<User> & {
79
+ name?: string;
80
+ label?: string;
81
+ };
82
+
79
83
  const props = defineProps({
80
84
  user: {
81
- required: true,
82
- type: Object as PropType<User>,
85
+ default: undefined,
86
+ type: Object as PropType<AvatarUserInput>,
87
+ },
88
+ name: {
89
+ default: undefined,
90
+ type: String,
91
+ },
92
+ email: {
93
+ default: undefined,
94
+ type: String,
95
+ },
96
+ src: {
97
+ default: undefined,
98
+ type: String,
83
99
  },
84
100
  size: {
85
101
  default: 'base',
@@ -107,8 +123,23 @@ const props = defineProps({
107
123
  },
108
124
  });
109
125
 
110
- const initials = computed(() => getInitials(props.user?.full_name || ''));
111
- const avatarColor = computed(() => getAvatarColor(props.user?.full_name || '', props.user?.email));
126
+ const resolvedName = computed(() => {
127
+ return (
128
+ [props.name, props.user?.name, props.user?.full_name, props.user?.label]
129
+ .find((value) => Boolean(value && value.trim()))?.trim() || ''
130
+ );
131
+ });
132
+
133
+ const resolvedEmail = computed(() => {
134
+ return (props.email || props.user?.email || '').trim();
135
+ });
136
+
137
+ const resolvedUrl = computed(() => {
138
+ return (props.src || props.user?.avatar_url || '').trim();
139
+ });
140
+
141
+ const initials = computed(() => getInitials(resolvedName.value));
142
+ const avatarColor = computed(() => getAvatarColor(resolvedName.value, resolvedEmail.value));
112
143
 
113
144
  const classInternal = computed(() => {
114
145
  return twMerge(
@@ -125,11 +156,15 @@ const tooltipText = computed(() => {
125
156
  return null;
126
157
  }
127
158
 
159
+ if (!resolvedName.value && !resolvedEmail.value) {
160
+ return null;
161
+ }
162
+
128
163
  return `<p class="font-medium leading-tight">
129
- ${props.user.full_name}
164
+ ${resolvedName.value}
130
165
  </p>
131
166
  <p class="text-slate-500 leading-tight">
132
- ${props.user.email}
167
+ ${resolvedEmail.value}
133
168
  </p>`;
134
169
  })
135
170
 
@@ -23,6 +23,7 @@
23
23
  />
24
24
 
25
25
  <slot />
26
+
26
27
  <BaseIcon
27
28
  v-if="icon && iconPosition == 'end'"
28
29
  :icon="icon"
@@ -35,7 +35,7 @@ import { UploadedFile } from '@/types/UploadedFile';
35
35
  import { useSnackbarsStore } from '@/stores/snackbars';
36
36
  import BaseLoadingCover from '@/components/BaseLoadingCover.vue';
37
37
  import { BaseCropperConfig } from '@/types';
38
- import { base64ToBlob, blobToBase64, resizeImageFromURI } from '@/utils';
38
+ import { resizeImageForUpload } from '@/utils';
39
39
  import BaseFilePicker from './BaseFilePicker.vue';
40
40
  import BaseFilePickerCrop from './BaseFilePickerCrop.vue';
41
41
  import { t } from '@/i18n';
@@ -220,49 +220,21 @@ async function autoResizeImage(file: File): Promise<File> {
220
220
  }
221
221
 
222
222
  try {
223
- const source = await blobToBase64(file);
224
- const dimensions = await getImageDimensions(source);
225
-
226
- const resizeRatio = Math.sqrt(props.maxImageSizeBeforeResize / file.size);
227
-
228
- let targetWidth = Math.max(1, Math.floor(dimensions.width * resizeRatio));
229
- let targetHeight = Math.max(1, Math.floor(dimensions.height * resizeRatio));
230
-
231
- let resizedBlob = await createResizedBlob(
232
- source,
233
- targetHeight,
234
- targetWidth,
235
- file.type
236
- );
237
-
238
- let attempts = 0;
239
-
240
- while (
241
- resizedBlob.size > props.maxImageSizeBeforeResize &&
242
- attempts < 5 &&
243
- targetWidth > 1 &&
244
- targetHeight > 1
245
- ) {
246
- targetWidth = Math.max(1, Math.floor(targetWidth * 0.8));
247
- targetHeight = Math.max(1, Math.floor(targetHeight * 0.8));
248
- resizedBlob = await createResizedBlob(
249
- source,
250
- targetHeight,
251
- targetWidth,
252
- file.type
253
- );
254
- attempts++;
255
- }
223
+ const resizedBlob = await resizeImageForUpload(file, {
224
+ maxBytes: props.maxImageSizeBeforeResize,
225
+ });
256
226
 
257
227
  if (resizedBlob.size >= file.size) {
258
228
  return file;
259
229
  }
260
230
 
231
+ const mimeType = resizedBlob.type || file.type;
232
+
261
233
  return new File(
262
234
  [resizedBlob],
263
- getResizedFileName(file.name, resizedBlob.type),
235
+ getResizedFileName(file.name, mimeType),
264
236
  {
265
- type: resizedBlob.type || file.type,
237
+ type: mimeType,
266
238
  lastModified: file.lastModified,
267
239
  }
268
240
  );
@@ -272,34 +244,6 @@ async function autoResizeImage(file: File): Promise<File> {
272
244
  }
273
245
  }
274
246
 
275
- async function createResizedBlob(
276
- source: string,
277
- height: number,
278
- width: number,
279
- mimeType: string
280
- ): Promise<Blob> {
281
- const resizedSource = await resizeImageFromURI(source, height, width);
282
- return await base64ToBlob(resizedSource, `data:${mimeType}`);
283
- }
284
-
285
- async function getImageDimensions(
286
- source: string
287
- ): Promise<{ width: number; height: number }> {
288
- return await new Promise((resolve, reject) => {
289
- const image = new Image();
290
- image.onload = () => {
291
- resolve({
292
- width: image.width,
293
- height: image.height,
294
- });
295
- };
296
- image.onerror = () => {
297
- reject(new Error('Failed to load image dimensions'));
298
- };
299
- image.src = source;
300
- });
301
- }
302
-
303
247
  function getResizedFileName(filename: string, mimeType: string): string {
304
248
  if (mimeType != 'image/jpeg') {
305
249
  return filename;
@@ -1,6 +1,7 @@
1
1
  import toHumanList from './toHumanList';
2
2
  import fileSizeFormat from './fileSizeFormat';
3
3
  import resizeImageFromURI from './resizeImageFromURI';
4
+ import resizeImageForUpload from './resizeImageForUpload';
4
5
  import { disableScroll, enableScroll } from './scrollPreventer';
5
6
  import { blobToBase64, validateBase64, base64ToBlob } from './blob';
6
7
  import { getColorConfig } from './colors';
@@ -12,6 +13,7 @@ export {
12
13
  disableScroll,
13
14
  enableScroll,
14
15
  resizeImageFromURI,
16
+ resizeImageForUpload,
15
17
  blobToBase64,
16
18
  validateBase64,
17
19
  base64ToBlob,
@@ -0,0 +1,181 @@
1
+ type ResizeImageForUploadOptions = {
2
+ maxBytes: number;
3
+ outputMimeType?: string;
4
+ quality?: number;
5
+ };
6
+
7
+ type DecodedImage = {
8
+ source: CanvasImageSource;
9
+ width: number;
10
+ height: number;
11
+ cleanup: () => void;
12
+ };
13
+
14
+ const DEFAULT_OUTPUT_QUALITY = 0.9;
15
+
16
+ export default async function resizeImageForUpload(
17
+ file: Blob,
18
+ options: ResizeImageForUploadOptions
19
+ ): Promise<Blob> {
20
+ if (options.maxBytes <= 0 || file.size <= options.maxBytes) {
21
+ return file;
22
+ }
23
+
24
+ const decodedImage = await decodeImage(file);
25
+ const outputMimeType =
26
+ options.outputMimeType ?? getOutputMimeType((file as File).type);
27
+ const quality = clamp(options.quality ?? DEFAULT_OUTPUT_QUALITY, 0.1, 1);
28
+
29
+ let canvas: HTMLCanvasElement | null = null;
30
+
31
+ try {
32
+ const resizeRatio = Math.min(1, Math.sqrt(options.maxBytes / file.size));
33
+ const targetWidth = Math.max(1, Math.floor(decodedImage.width * resizeRatio));
34
+ const targetHeight = Math.max(
35
+ 1,
36
+ Math.floor(decodedImage.height * resizeRatio)
37
+ );
38
+
39
+ canvas = createScaledCanvas(
40
+ decodedImage.source,
41
+ decodedImage.width,
42
+ decodedImage.height,
43
+ targetWidth,
44
+ targetHeight
45
+ );
46
+
47
+ if (!supportsQualityEncoding(outputMimeType)) {
48
+ return await canvasToBlob(canvas, outputMimeType);
49
+ }
50
+
51
+ return await canvasToBlob(canvas, outputMimeType, quality);
52
+ } finally {
53
+ decodedImage.cleanup();
54
+
55
+ if (canvas) {
56
+ releaseCanvas(canvas);
57
+ }
58
+ }
59
+ }
60
+
61
+ function getOutputMimeType(inputMimeType: string): string {
62
+ if (inputMimeType === 'image/webp') {
63
+ return 'image/webp';
64
+ }
65
+
66
+ return 'image/jpeg';
67
+ }
68
+
69
+ async function decodeImage(file: Blob): Promise<DecodedImage> {
70
+ if (typeof createImageBitmap === 'function') {
71
+ try {
72
+ const bitmap = await createImageBitmap(file);
73
+
74
+ return {
75
+ source: bitmap,
76
+ width: bitmap.width,
77
+ height: bitmap.height,
78
+ cleanup: () => bitmap.close(),
79
+ };
80
+ } catch {
81
+ // Fallback to Image decode when createImageBitmap is not supported.
82
+ }
83
+ }
84
+
85
+ const objectUrl = URL.createObjectURL(file);
86
+
87
+ try {
88
+ const image = await loadImage(objectUrl);
89
+
90
+ return {
91
+ source: image,
92
+ width: image.naturalWidth || image.width,
93
+ height: image.naturalHeight || image.height,
94
+ cleanup: () => URL.revokeObjectURL(objectUrl),
95
+ };
96
+ } catch (error) {
97
+ URL.revokeObjectURL(objectUrl);
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ async function loadImage(src: string): Promise<HTMLImageElement> {
103
+ const image = new Image();
104
+ image.decoding = 'async';
105
+ image.src = src;
106
+
107
+ if (typeof image.decode === 'function') {
108
+ try {
109
+ await image.decode();
110
+ return image;
111
+ } catch {
112
+ // decode() may reject if called before enough data is available.
113
+ }
114
+ }
115
+
116
+ return await new Promise((resolve, reject) => {
117
+ image.onload = () => resolve(image);
118
+ image.onerror = () => reject(new Error('Failed to decode image'));
119
+ });
120
+ }
121
+
122
+ function createScaledCanvas(
123
+ source: CanvasImageSource,
124
+ sourceWidth: number,
125
+ sourceHeight: number,
126
+ width: number,
127
+ height: number
128
+ ): HTMLCanvasElement {
129
+ const canvas = document.createElement('canvas');
130
+ canvas.width = width;
131
+ canvas.height = height;
132
+
133
+ const context = canvas.getContext('2d', {
134
+ alpha: false,
135
+ desynchronized: true,
136
+ });
137
+
138
+ if (!context) {
139
+ throw new Error('Could not acquire context 2d');
140
+ }
141
+
142
+ context.imageSmoothingEnabled = true;
143
+ context.imageSmoothingQuality = 'high';
144
+ context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height);
145
+
146
+ return canvas;
147
+ }
148
+
149
+ function supportsQualityEncoding(mimeType: string): boolean {
150
+ return mimeType === 'image/jpeg' || mimeType === 'image/webp';
151
+ }
152
+
153
+ async function canvasToBlob(
154
+ canvas: HTMLCanvasElement,
155
+ mimeType: string,
156
+ quality?: number
157
+ ): Promise<Blob> {
158
+ return await new Promise((resolve, reject) => {
159
+ canvas.toBlob(
160
+ (blob) => {
161
+ if (blob) {
162
+ resolve(blob);
163
+ return;
164
+ }
165
+
166
+ reject(new Error('Could not create resized image blob'));
167
+ },
168
+ mimeType,
169
+ quality
170
+ );
171
+ });
172
+ }
173
+
174
+ function releaseCanvas(canvas: HTMLCanvasElement) {
175
+ canvas.width = 0;
176
+ canvas.height = 0;
177
+ }
178
+
179
+ function clamp(value: number, min: number, max: number): number {
180
+ return Math.min(max, Math.max(min, value));
181
+ }