create-nexwire 4.2.4 → 4.2.5

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.
Files changed (35) hide show
  1. package/express/.vscode/extensions.json +6 -0
  2. package/express/.vscode/settings.json +37 -26
  3. package/express/biome.json +99 -66
  4. package/express/package.json +30 -28
  5. package/express/src/database/schema.ts +2 -2
  6. package/express/src/resources/src/components/Checkbox.vue +32 -29
  7. package/express/src/resources/src/components/Input.vue +243 -223
  8. package/express/src/resources/src/components/InputGroup.vue +147 -145
  9. package/express/src/resources/src/components/InputPasswordToggle.vue +229 -213
  10. package/express/src/resources/src/components/Modal.vue +71 -55
  11. package/express/src/resources/src/components/Refresh.vue +19 -0
  12. package/express/src/resources/src/helpers/assets.ts +51 -0
  13. package/express/src/resources/src/main.ts +3 -1
  14. package/express/src/resources/src/plugins/gum.ts +1 -1
  15. package/express/src/resources/src/stores/auth.ts +184 -164
  16. package/express/src/resources/vite.config.ts +9 -2
  17. package/express/src/storage/database/nexwire.sqlite +0 -0
  18. package/hono/.vscode/extensions.json +6 -0
  19. package/hono/.vscode/settings.json +37 -26
  20. package/hono/biome.json +25 -2
  21. package/hono/package.json +24 -22
  22. package/hono/src/database/schema.ts +2 -2
  23. package/hono/src/resources/src/components/Checkbox.vue +32 -29
  24. package/hono/src/resources/src/components/Input.vue +243 -234
  25. package/hono/src/resources/src/components/InputGroup.vue +147 -149
  26. package/hono/src/resources/src/components/InputPasswordToggle.vue +229 -222
  27. package/hono/src/resources/src/components/Modal.vue +71 -55
  28. package/hono/src/resources/src/components/Refresh.vue +19 -0
  29. package/hono/src/resources/src/helpers/assets.ts +51 -0
  30. package/hono/src/resources/src/main.ts +3 -1
  31. package/hono/src/resources/src/plugins/gum.ts +1 -1
  32. package/hono/src/resources/src/stores/auth.ts +184 -164
  33. package/hono/src/resources/vite.config.ts +9 -2
  34. package/hono/src/storage/database/nexwire.sqlite +0 -0
  35. package/package.json +1 -1
@@ -1,213 +1,229 @@
1
- <template>
2
- <div :class="topclass">
3
- <div class="position-relative">
4
- <label :for="inputId" class="d-flex align-items-center" :class="{ 'd-none': !inputLabel }">
5
- <div class="position-relative">
6
- <span class="text-capitalize mb-1" v-html="inputLabel"></span>
7
- <span
8
- class="position-absolute text-danger rounded-circle bg-danger must"
9
- :class="{ 'd-none': !props.must }"></span>
10
- </div>
11
- <div
12
- class="text-end ms-auto text-primary align-self-center"
13
- :class="{ 'd-none': !props.hood }"
14
- style="font-size: 12px; margin-top: 0.15rem"
15
- v-html="hoodHtml"></div>
16
- </label>
17
-
18
- <div class="input-group mb-3">
19
- <input
20
- ref="inputRef"
21
- class="form-control border-0"
22
- :type="showPassword ? 'text' : 'password'"
23
- :value="props.modelValue"
24
- :maxlength="maxLengthAttr"
25
- :readonly="props.readonly"
26
- v-bind="$attrs"
27
- @input="updateModel" />
28
- <button
29
- class="input-group-text p-0 border-0 border-start border-1 position-relative z-1"
30
- type="button"
31
- @click="togglePassword">
32
- <i
33
- class="py-2 px-3"
34
- :class="showPassword ? 'bi bi-eye' : 'bi bi-eye-slash'"
35
- style="font-size: 15px"></i>
36
- </button>
37
- </div>
38
- <!-- <input class="form-control" v-model="model" :maxlength="max" v-bind="$attrs"> vue3.3 (2nd way)/3.4 -->
39
- <div
40
- class="position-absolute bottom-0 end-0 pe-1 text-secondary max"
41
- :class="{ 'd-none': !max }">
42
- {{ maxCounter }}
43
- </div>
44
- </div>
45
- <div
46
- :id="`${inputId}Help`"
47
- class="form-text mt-1 text-danger"
48
- :class="{ 'd-none': !props.err }">
49
- {{ props.err }}
50
- </div>
51
- </div>
52
- </template>
53
-
54
- <script setup lang="ts">
55
- import { computed, nextTick, onMounted, ref, useAttrs } from "vue";
56
- import { browserDetect } from "@/plugins/browserDetect";
57
-
58
- defineOptions({ name: "InputPasswordToggle", inheritAttrs: false });
59
- const { isFirefox } = browserDetect;
60
-
61
- const $attrs = useAttrs();
62
- const inputRef = ref<HTMLInputElement | null>(null);
63
- type InputPasswordCategory = "number" | "decimal" | "mobile" | "tax-period" | "bin" | "challan-number" | string;
64
-
65
- interface InputPasswordProps {
66
- focus?: boolean;
67
- must?: boolean;
68
- err?: string | boolean;
69
- modelValue?: string | number;
70
- category?: InputPasswordCategory;
71
- hood?: string | boolean | number;
72
- readonly?: boolean;
73
- }
74
-
75
- const props = withDefaults(defineProps<InputPasswordProps>(), {
76
- err: false,
77
- modelValue: ""
78
- });
79
-
80
- const topclass = computed(() => ($attrs.topclass as string | undefined) || "mb-3");
81
- const inputId = computed(() => ($attrs.id as string | undefined) || "");
82
- const inputLabel = computed(() => ($attrs.label as string | undefined) || "");
83
- const inputType = computed(() => ($attrs.type as string | undefined) || "text");
84
-
85
- const max = computed<number | null>(() => {
86
- const raw = $attrs.maxlength as string | number | undefined;
87
- return raw != null ? Number(raw) : null;
88
- });
89
- const maxLengthAttr = computed<number | undefined>(() => max.value ?? undefined);
90
-
91
- const hoodHtml = computed(() => (props.hood === false || props.hood == null ? "" : String(props.hood)));
92
-
93
- const maxCounter = computed(() => {
94
- if (max.value == null) {
95
- return "";
96
- }
97
-
98
- if (!isFirefox && inputType.value === "month") {
99
- return "";
100
- }
101
-
102
- return max.value - String(props.modelValue).length;
103
- });
104
-
105
- const emit = defineEmits<(event: "update:modelValue", value: string | number) => void>();
106
- const updateModel = (e: Event) => {
107
- const target = e.target as HTMLInputElement;
108
-
109
- if (props.category === "number") {
110
- emit("update:modelValue", (target.value = target.value.replace(/[^\d]/gi, "")));
111
- } else if (props.category === "decimal") {
112
- let val = target.value.replace(/[^\d.]/g, "");
113
- const parts = val.split(".");
114
- val = parts[0];
115
- if (parts.length > 1) {
116
- val += `.${parts.slice(1).join("")}`;
117
- }
118
- emit("update:modelValue", (target.value = val));
119
- } else if (props.category === "mobile") {
120
- let digits = target.value.replace(/[^\d]/g, ""); // only digits
121
- let val = digits;
122
- if (target.value.includes("+")) {
123
- val = `+${digits}`;
124
- }
125
- emit("update:modelValue", (target.value = val));
126
- } else if (props.category === "tax-period") {
127
- let val = target.value.replace(/[^\d-]/g, ""); // Only numbers and hyphen
128
- val = val.replace(/-/g, ""); // Remove all hyphens
129
- val = val.slice(0, 6); // Only 6 digits (YYYYMM)
130
-
131
- if (val.length > 4) {
132
- let year = val.slice(0, 4);
133
- let month = val.slice(4, 6);
134
-
135
- // Only correct if both month digits entered
136
- if (month.length === 2) {
137
- let m = parseInt(month, 10);
138
- // Clamp month between 1 and 12
139
- if (m < 1) m = 1;
140
- if (m > 12) m = 12;
141
- // Always pad to 2 digits
142
- month = m.toString().padStart(2, "0");
143
- }
144
-
145
- val = `${year}-${month}`;
146
- }
147
-
148
- emit("update:modelValue", (target.value = val));
149
- } else if (props.category === "bin") {
150
- let digits = target.value.replace(/[^\d]/g, "");
151
- digits = digits.slice(0, 14);
152
- let formatted = digits;
153
- if (digits.length > 9) {
154
- formatted = `${digits.slice(0, 9)}-${digits.slice(9, 14)}`;
155
- }
156
- emit("update:modelValue", (target.value = formatted));
157
- } else if (props.category === "challan-number") {
158
- let digits = target.value.replace(/[^\d]/g, "");
159
- digits = digits.slice(0, 16);
160
- let formatted = digits;
161
- if (digits.length > 4) {
162
- formatted = `${digits.slice(0, 4)}-${digits.slice(4, 16)}`;
163
- }
164
- emit("update:modelValue", (target.value = formatted));
165
- } else {
166
- emit("update:modelValue", target.value);
167
- }
168
- };
169
-
170
- const showPassword = ref(false);
171
- const togglePassword = () => (showPassword.value = !showPassword.value);
172
-
173
- onMounted(() => {
174
- const shouldFocus = props.focus !== false && props.focus !== undefined;
175
- if (!shouldFocus) return;
176
-
177
- nextTick(() => inputRef.value?.focus());
178
- });
179
- </script>
180
-
181
- <style lang="scss" scoped>
182
- input {
183
- border-color: #cccccc;
184
-
185
- &:read-only {
186
- background-color: #f8f8f8;
187
- }
188
-
189
- &::placeholder {
190
- color: #bfbfbf;
191
- }
192
- }
193
-
194
- button:focus-visible {
195
- border: none;
196
- box-shadow: none;
197
- }
198
-
199
- .form-text {
200
- font-size: 0.75rem;
201
- }
202
-
203
- .must {
204
- width: 4px;
205
- height: 4px;
206
- margin-left: 2px;
207
- margin-top: 5px;
208
- }
209
-
210
- .max {
211
- font-size: 0.6rem;
212
- }
213
- </style>
1
+ <template>
2
+ <div :class="topclass">
3
+ <div class="position-relative">
4
+ <label :for="inputId" class="d-flex align-items-center" :class="{ 'd-none': !inputLabel }">
5
+ <div class="position-relative">
6
+ <span class="text-capitalize mb-1" v-html="inputLabel"></span>
7
+ <span
8
+ class="position-absolute text-danger rounded-circle bg-danger must"
9
+ :class="{ 'd-none': !props.must }"
10
+ ></span>
11
+ </div>
12
+ <div
13
+ class="text-end ms-auto text-primary align-self-center"
14
+ :class="{ 'd-none': !props.hood }"
15
+ style="font-size: 12px; margin-top: 0.15rem"
16
+ v-html="hoodHtml"
17
+ ></div>
18
+ </label>
19
+
20
+ <div class="input-group border-0 mb-3">
21
+ <input
22
+ ref="inputRef"
23
+ class="form-control shadow-none"
24
+ :type="showPassword ? 'text' : 'password'"
25
+ :value="props.modelValue"
26
+ :maxlength="maxLengthAttr"
27
+ :readonly="props.readonly"
28
+ v-bind="$attrs"
29
+ @input="updateModel"
30
+ />
31
+ <button
32
+ class="input-group-text p-0 position-relative z-1"
33
+ type="button"
34
+ @click="togglePassword"
35
+ >
36
+ <i
37
+ class="py-2 px-3"
38
+ :class="showPassword ? 'bi bi-eye' : 'bi bi-eye-slash'"
39
+ style="font-size: 15px"
40
+ ></i>
41
+ </button>
42
+ </div>
43
+ <!-- <input class="form-control" v-model="model" :maxlength="max" v-bind="$attrs"> vue3.3 (2nd way)/3.4 -->
44
+ <div
45
+ class="position-absolute bottom-0 end-0 pe-1 text-secondary max"
46
+ :class="{ 'd-none': !max }"
47
+ >
48
+ {{ maxCounter }}
49
+ </div>
50
+ </div>
51
+ <div
52
+ :id="`${inputId}Help`"
53
+ class="form-text mt-1 text-danger"
54
+ :class="{ 'd-none': !props.err }"
55
+ >
56
+ {{ props.err }}
57
+ </div>
58
+ </div>
59
+ </template>
60
+
61
+ <script setup lang="ts">
62
+ import { computed, nextTick, onMounted, ref, useAttrs } from "vue";
63
+ import { browserDetect } from "@/plugins/browserDetect";
64
+
65
+ defineOptions({ name: "InputPasswordToggle", inheritAttrs: false });
66
+ const { isFirefox } = browserDetect;
67
+
68
+ const $attrs = useAttrs();
69
+ const inputRef = ref<HTMLInputElement | null>(null);
70
+ type InputPasswordCategory =
71
+ | "number"
72
+ | "decimal"
73
+ | "mobile"
74
+ | "tax-period"
75
+ | "bin"
76
+ | "challan-number"
77
+ | string;
78
+
79
+ interface InputPasswordProps {
80
+ focus?: boolean;
81
+ must?: boolean;
82
+ err?: string | boolean;
83
+ modelValue?: string | number;
84
+ category?: InputPasswordCategory;
85
+ hood?: string | boolean | number;
86
+ readonly?: boolean;
87
+ }
88
+
89
+ const props = withDefaults(defineProps<InputPasswordProps>(), {
90
+ err: false,
91
+ modelValue: "",
92
+ });
93
+
94
+ const topclass = computed(() => ($attrs.topclass as string | undefined) || "mb-3");
95
+ const inputId = computed(() => ($attrs.id as string | undefined) || "");
96
+ const inputLabel = computed(() => ($attrs.label as string | undefined) || "");
97
+ const inputType = computed(() => ($attrs.type as string | undefined) || "text");
98
+
99
+ const max = computed<number | null>(() => {
100
+ const raw = $attrs.maxlength as string | number | undefined;
101
+ return raw != null ? Number(raw) : null;
102
+ });
103
+ const maxLengthAttr = computed<number | undefined>(() => max.value ?? undefined);
104
+
105
+ const hoodHtml = computed(() =>
106
+ props.hood === false || props.hood == null ? "" : String(props.hood),
107
+ );
108
+
109
+ const maxCounter = computed(() => {
110
+ if (max.value == null) {
111
+ return "";
112
+ }
113
+
114
+ if (!isFirefox && inputType.value === "month") {
115
+ return "";
116
+ }
117
+
118
+ return max.value - String(props.modelValue).length;
119
+ });
120
+
121
+ const emit = defineEmits<(event: "update:modelValue", value: string | number) => void>();
122
+ const updateModel = (e: Event) => {
123
+ const target = e.target as HTMLInputElement;
124
+
125
+ if (props.category === "number") {
126
+ emit("update:modelValue", (target.value = target.value.replace(/[^\d]/gi, "")));
127
+ } else if (props.category === "decimal") {
128
+ let val = target.value.replace(/[^\d.]/g, "");
129
+ const parts = val.split(".");
130
+ val = parts[0];
131
+ if (parts.length > 1) {
132
+ val += `.${parts.slice(1).join("")}`;
133
+ }
134
+ emit("update:modelValue", (target.value = val));
135
+ } else if (props.category === "mobile") {
136
+ let digits = target.value.replace(/[^\d]/g, ""); // only digits
137
+ let val = digits;
138
+ if (target.value.includes("+")) {
139
+ val = `+${digits}`;
140
+ }
141
+ emit("update:modelValue", (target.value = val));
142
+ } else if (props.category === "tax-period") {
143
+ let val = target.value.replace(/[^\d-]/g, ""); // Only numbers and hyphen
144
+ val = val.replace(/-/g, ""); // Remove all hyphens
145
+ val = val.slice(0, 6); // Only 6 digits (YYYYMM)
146
+
147
+ if (val.length > 4) {
148
+ let year = val.slice(0, 4);
149
+ let month = val.slice(4, 6);
150
+
151
+ // Only correct if both month digits entered
152
+ if (month.length === 2) {
153
+ let m = parseInt(month, 10);
154
+ // Clamp month between 1 and 12
155
+ if (m < 1) m = 1;
156
+ if (m > 12) m = 12;
157
+ // Always pad to 2 digits
158
+ month = m.toString().padStart(2, "0");
159
+ }
160
+
161
+ val = `${year}-${month}`;
162
+ }
163
+
164
+ emit("update:modelValue", (target.value = val));
165
+ } else if (props.category === "bin") {
166
+ let digits = target.value.replace(/[^\d]/g, "");
167
+ digits = digits.slice(0, 14);
168
+ let formatted = digits;
169
+ if (digits.length > 9) {
170
+ formatted = `${digits.slice(0, 9)}-${digits.slice(9, 14)}`;
171
+ }
172
+ emit("update:modelValue", (target.value = formatted));
173
+ } else if (props.category === "challan-number") {
174
+ let digits = target.value.replace(/[^\d]/g, "");
175
+ digits = digits.slice(0, 16);
176
+ let formatted = digits;
177
+ if (digits.length > 4) {
178
+ formatted = `${digits.slice(0, 4)}-${digits.slice(4, 16)}`;
179
+ }
180
+ emit("update:modelValue", (target.value = formatted));
181
+ } else {
182
+ emit("update:modelValue", target.value);
183
+ }
184
+ };
185
+
186
+ const showPassword = ref(false);
187
+ const togglePassword = () => (showPassword.value = !showPassword.value);
188
+
189
+ onMounted(async () => {
190
+ const shouldFocus = props.focus !== false && props.focus !== undefined;
191
+ if (!shouldFocus) return;
192
+
193
+ await nextTick();
194
+ // preventScroll: focusing inside a modal must not scroll the modal/page
195
+ inputRef.value?.focus({ preventScroll: true });
196
+ });
197
+ </script>
198
+
199
+ <style lang="scss" scoped>
200
+ input {
201
+ &:read-only {
202
+ background-color: #f8f8f8;
203
+ }
204
+
205
+ &::placeholder {
206
+ color: #bfbfbf;
207
+ }
208
+ }
209
+
210
+ button:focus-visible {
211
+ border: none;
212
+ box-shadow: none;
213
+ }
214
+
215
+ .form-text {
216
+ font-size: 0.75rem;
217
+ }
218
+
219
+ .must {
220
+ width: 4px;
221
+ height: 4px;
222
+ margin-left: 2px;
223
+ margin-top: 5px;
224
+ }
225
+
226
+ .max {
227
+ font-size: 0.6rem;
228
+ }
229
+ </style>
@@ -1,55 +1,71 @@
1
- <template>
2
- <Teleport to="#modal-show">
3
- <div :id="props.id" ref="modal" class="modal fade" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1"
4
- :aria-labelledby="`${props.id}Label`">
5
- <div :class="`modal-dialog modal-dialog-centered modal-dialog-scrollable modal-${props.size}`">
6
- <div class="modal-content border">
7
- <div class="modal-header">
8
- <div class="d-flex w-100 justify-content-between align-items-center">
9
- <h5 id="modalScrollableTitle" class="modal-title me-3">{{ props.title }}</h5>
10
- <button ref="closeButton" type="button" class="btn-close shadow-none" data-bs-dismiss="modal"
11
- aria-label="Close"></button>
12
- <slot name="modal-header-extra"></slot>
13
- </div>
14
- </div>
15
- <div class="modal-body">
16
- <slot name="modalbody"></slot>
17
- </div>
18
- <div class="modal-footer py-2">
19
- <slot name="modalfooter"></slot>
20
- </div>
21
- </div>
22
- </div>
23
- </div>
24
- </Teleport>
25
- </template>
26
-
27
- <script setup lang="ts">
28
- import { ref } from "vue";
29
-
30
- type ModalSize = "sm" | "md" | "lg" | "xl" | string;
31
-
32
- interface ModalProps {
33
- id: string;
34
- title?: string;
35
- size?: ModalSize;
36
- }
37
-
38
- defineOptions({ name: "Modal", inheritAttrs: false });
39
-
40
- const props = withDefaults(defineProps<ModalProps>(), {
41
- title: "",
42
- size: "md"
43
- });
44
-
45
- const modal = ref<HTMLElement | null>(null);
46
- const closeButton = ref<HTMLButtonElement | null>(null);
47
-
48
- const close = () => {
49
- closeButton.value?.click();
50
- };
51
-
52
- defineExpose({ close });
53
- </script>
54
-
55
- <style lang="scss" scoped></style>
1
+ <template>
2
+ <Teleport to="#modal-show">
3
+ <div
4
+ :id="props.id"
5
+ ref="modal"
6
+ class="modal fade"
7
+ data-bs-backdrop="static"
8
+ data-bs-keyboard="false"
9
+ tabindex="-1"
10
+ :aria-labelledby="`${props.id}Label`"
11
+ >
12
+ <div
13
+ :class="`modal-dialog modal-dialog-centered modal-dialog-scrollable modal-${props.size}`"
14
+ >
15
+ <div class="modal-content border">
16
+ <div class="modal-header">
17
+ <div class="d-flex w-100 justify-content-between align-items-center">
18
+ <h5 id="modalScrollableTitle" class="modal-title me-3">{{ props.title }}</h5>
19
+ <button
20
+ ref="closeButton"
21
+ type="button"
22
+ class="btn-close shadow-none"
23
+ data-bs-dismiss="modal"
24
+ aria-label="Close"
25
+ ></button>
26
+ <slot name="modal-header-extra"></slot>
27
+ </div>
28
+ </div>
29
+ <div class="modal-body">
30
+ <slot name="modalbody"></slot>
31
+ </div>
32
+ <div class="modal-footer py-2">
33
+ <slot name="modalfooter"></slot>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ </Teleport>
39
+ </template>
40
+
41
+ <script setup lang="ts">
42
+ import { Modal } from "bootstrap";
43
+ import { onMounted, ref } from "vue";
44
+
45
+ interface ModalProps {
46
+ id: string;
47
+ title?: string;
48
+ size?: string;
49
+ }
50
+
51
+ const props = withDefaults(defineProps<ModalProps>(), {
52
+ title: "",
53
+ size: "md"
54
+ });
55
+
56
+ const modal = ref<HTMLElement | null>(null);
57
+ let bsModal: Modal | null = null;
58
+
59
+ onMounted(() => {
60
+ if (modal.value) {
61
+ bsModal = new Modal(modal.value);
62
+ }
63
+ });
64
+
65
+ const open = () => bsModal?.show();
66
+ const close = () => bsModal?.hide();
67
+
68
+ defineExpose({ open, close });
69
+ </script>
70
+
71
+ <style lang="scss" scoped></style>
@@ -0,0 +1,19 @@
1
+ <template>
2
+ <Teleport v-if="target" :to="target">
3
+ <slot />
4
+ </Teleport>
5
+ </template>
6
+
7
+ <script setup>
8
+ import { onMounted, ref } from "vue";
9
+
10
+ defineOptions({ name: "Refresh", inheritAttrs: false });
11
+
12
+ const target = ref(null);
13
+ onMounted(() => {
14
+ // grab the header container once it's in the DOM
15
+ target.value = document.getElementById("refresh");
16
+ });
17
+ </script>
18
+
19
+ <style lang="scss" scoped></style>
@@ -0,0 +1,51 @@
1
+ // src/helpers/assets.ts
2
+
3
+ // 1. Eagerly load ALL images in the entire src folder.
4
+ // This tells Vite: "Find every image, hash it, and give me the final URL."
5
+ const allImages = import.meta.glob("/src/**/*.{png,jpg,jpeg,svg,webp,gif}", {
6
+ eager: true,
7
+ import: "default"
8
+ });
9
+
10
+ /**
11
+ * Resolves ANY image path (using @, relative, or absolute) for both dev and prod.
12
+ */
13
+ export default function asset(path: string): string {
14
+ // 1. If it's already a full URL or data URI, return it as is.
15
+ if (path.startsWith("http") || path.startsWith("data:")) {
16
+ return path;
17
+ }
18
+
19
+ // 2. Normalize the path.
20
+ // Vite's glob keys always start from the root, like "/src/assets/..."
21
+ let cleanPath = path;
22
+
23
+ // Convert alias `@/` to `/src/`
24
+ if (cleanPath.startsWith("@/")) {
25
+ cleanPath = cleanPath.replace("@/", "/src/");
26
+ }
27
+ // Convert absolute `/` to `/src/` (assuming you mean inside src)
28
+ else if (cleanPath.startsWith("/") && !cleanPath.startsWith("/src/")) {
29
+ cleanPath = `/src${cleanPath}`;
30
+ }
31
+ // Convert relative `../../` (This is the tricky part)
32
+ else if (cleanPath.startsWith("../")) {
33
+ // We can't easily resolve `../../` from a utility file, so we just
34
+ // try to find a matching suffix in the glob keys.
35
+ // (e.g., if you pass "../../assets/foo.png", we look for "assets/foo.png" inside /src)
36
+ const match = Object.keys(allImages).find((key) => key.endsWith(cleanPath.replace(/^(\.\.\/)+/, "")));
37
+ if (match) {
38
+ return allImages[match] as string;
39
+ }
40
+ }
41
+
42
+ // 3. Look up the normalized path in the glob object
43
+ const resolved = allImages[cleanPath] as string | undefined;
44
+
45
+ if (!resolved) {
46
+ console.warn(`[getAssetUrl] Image not found: ${path}. Returning original path.`);
47
+ return path;
48
+ }
49
+
50
+ return resolved;
51
+ }