urpanels-ui-pack 0.0.10 → 0.0.11
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/dist/DataTableLite/DataTableLite.svelte +56 -0
- package/dist/DataTableLite/DataTableLite.svelte.d.ts +15 -0
- package/dist/DataTableLite/index.d.ts +1 -0
- package/dist/DataTableLite/index.js +1 -0
- package/dist/FilterBar/FilterBar.svelte +124 -0
- package/dist/FilterBar/FilterBar.svelte.d.ts +14 -0
- package/dist/FilterBar/index.d.ts +2 -0
- package/dist/FilterBar/index.js +1 -0
- package/dist/FilterBar/types.d.ts +35 -0
- package/dist/FilterBar/types.js +1 -0
- package/dist/ImageEditorModal/ImageEditorModal.svelte +330 -0
- package/dist/ImageEditorModal/ImageEditorModal.svelte.d.ts +23 -0
- package/dist/ImageEditorModal/index.d.ts +1 -0
- package/dist/ImageEditorModal/index.js +1 -0
- package/dist/InputMultiSelectModal/InputMultiSelectModal.svelte +285 -0
- package/dist/InputMultiSelectModal/InputMultiSelectModal.svelte.d.ts +25 -0
- package/dist/InputMultiSelectModal/index.d.ts +1 -0
- package/dist/InputMultiSelectModal/index.js +1 -0
- package/dist/InputQuantityModal/InputQuantityModal.svelte +431 -0
- package/dist/InputQuantityModal/InputQuantityModal.svelte.d.ts +29 -0
- package/dist/InputQuantityModal/index.d.ts +1 -0
- package/dist/InputQuantityModal/index.js +1 -0
- package/dist/InputSelectModalV2/InputSelectModalV2.svelte +340 -0
- package/dist/InputSelectModalV2/InputSelectModalV2.svelte.d.ts +48 -0
- package/dist/InputSelectModalV2/index.d.ts +1 -0
- package/dist/InputSelectModalV2/index.js +1 -0
- package/dist/KpiStatsGrid/KpiStatsGrid.svelte +50 -0
- package/dist/KpiStatsGrid/KpiStatsGrid.svelte.d.ts +14 -0
- package/dist/KpiStatsGrid/index.d.ts +1 -0
- package/dist/KpiStatsGrid/index.js +1 -0
- package/dist/Modal/Modal.svelte +35 -18
- package/dist/Modal/Modal.svelte.d.ts +2 -0
- package/dist/PageLayout/PageHeader.svelte +16 -1
- package/dist/RecentActivityList/RecentActivityList.svelte +64 -0
- package/dist/RecentActivityList/RecentActivityList.svelte.d.ts +18 -0
- package/dist/RecentActivityList/index.d.ts +1 -0
- package/dist/RecentActivityList/index.js +1 -0
- package/dist/RichTextEditor/RichTextEditor.svelte +181 -1
- package/dist/RichTextEditor/RichTextEditor.svelte.d.ts +3 -0
- package/dist/RichTextRenderer/RichTextRenderer.svelte +169 -0
- package/dist/RichTextRenderer/RichTextRenderer.svelte.d.ts +11 -0
- package/dist/RichTextRenderer/index.d.ts +1 -0
- package/dist/RichTextRenderer/index.js +1 -0
- package/dist/StatusPill/StatusPill.svelte +26 -0
- package/dist/StatusPill/StatusPill.svelte.d.ts +10 -0
- package/dist/StatusPill/index.d.ts +1 -0
- package/dist/StatusPill/index.js +1 -0
- package/dist/TimelineList/TimelineList.svelte +207 -0
- package/dist/TimelineList/TimelineList.svelte.d.ts +4 -0
- package/dist/TimelineList/TimelineList.types.d.ts +70 -0
- package/dist/TimelineList/TimelineList.types.js +1 -0
- package/dist/TimelineList/index.d.ts +2 -0
- package/dist/TimelineList/index.js +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +11 -0
- package/dist/sections/index.d.ts +0 -5
- package/dist/sections/index.js +5 -3
- package/package.json +4 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export type TableColumn = {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
align?: 'left' | 'center' | 'right';
|
|
6
|
+
width?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
columns: TableColumn[];
|
|
11
|
+
rows: Record<string, any>[];
|
|
12
|
+
rowKey?: string;
|
|
13
|
+
emptyText?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { columns = [], rows = [], rowKey = 'id', emptyText = '-' }: Props = $props();
|
|
17
|
+
|
|
18
|
+
const alignClass = (align?: string) => {
|
|
19
|
+
if (align === 'center') return 'text-center';
|
|
20
|
+
if (align === 'right') return 'text-right';
|
|
21
|
+
return 'text-left';
|
|
22
|
+
};
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div class="overflow-x-auto" data-component-name="DataTableLite">
|
|
26
|
+
<table class="min-w-full border-collapse">
|
|
27
|
+
<thead>
|
|
28
|
+
<tr>
|
|
29
|
+
{#each columns as col}
|
|
30
|
+
<th class="px-3 py-2.5 text-xs font-semibold text-gray-700 bg-gray-50 border-b border-gray-200 {alignClass(col.align)}" style:width={col.width || undefined}>
|
|
31
|
+
{col.label}
|
|
32
|
+
</th>
|
|
33
|
+
{/each}
|
|
34
|
+
</tr>
|
|
35
|
+
</thead>
|
|
36
|
+
<tbody>
|
|
37
|
+
{#if rows.length === 0}
|
|
38
|
+
<tr>
|
|
39
|
+
<td colspan={columns.length} class="px-3 py-6 text-center text-sm text-gray-400">
|
|
40
|
+
{emptyText}
|
|
41
|
+
</td>
|
|
42
|
+
</tr>
|
|
43
|
+
{:else}
|
|
44
|
+
{#each rows as row (row[rowKey] || Math.random())}
|
|
45
|
+
<tr class="hover:bg-gray-50">
|
|
46
|
+
{#each columns as col}
|
|
47
|
+
<td class="px-3 py-2.5 text-sm text-gray-800 border-b border-gray-100 {alignClass(col.align)}">
|
|
48
|
+
{row[col.key] ?? '-'}
|
|
49
|
+
</td>
|
|
50
|
+
{/each}
|
|
51
|
+
</tr>
|
|
52
|
+
{/each}
|
|
53
|
+
{/if}
|
|
54
|
+
</tbody>
|
|
55
|
+
</table>
|
|
56
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type TableColumn = {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
align?: 'left' | 'center' | 'right';
|
|
5
|
+
width?: string;
|
|
6
|
+
};
|
|
7
|
+
interface Props {
|
|
8
|
+
columns: TableColumn[];
|
|
9
|
+
rows: Record<string, any>[];
|
|
10
|
+
rowKey?: string;
|
|
11
|
+
emptyText?: string;
|
|
12
|
+
}
|
|
13
|
+
declare const DataTableLite: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type DataTableLite = ReturnType<typeof DataTableLite>;
|
|
15
|
+
export default DataTableLite;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as DataTableLite } from './DataTableLite.svelte';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as DataTableLite } from './DataTableLite.svelte';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FilterGroup, ActiveFilters } from './types';
|
|
3
|
+
import { t } from '../utils/translations.svelte';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Array of filter group definitions */
|
|
7
|
+
groups: FilterGroup[];
|
|
8
|
+
/** Current active filter values (bindable) */
|
|
9
|
+
activeFilters?: ActiveFilters;
|
|
10
|
+
/** Called when any filter changes */
|
|
11
|
+
onFilterChange?: (filters: ActiveFilters) => void;
|
|
12
|
+
/** Compact mode - smaller chips */
|
|
13
|
+
compact?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
groups,
|
|
18
|
+
activeFilters = $bindable({}),
|
|
19
|
+
onFilterChange,
|
|
20
|
+
compact = false
|
|
21
|
+
}: Props = $props();
|
|
22
|
+
|
|
23
|
+
function isActive(groupKey: string, optionValue: string): boolean {
|
|
24
|
+
const current = activeFilters[groupKey];
|
|
25
|
+
if (current === null || current === undefined) return false;
|
|
26
|
+
if (Array.isArray(current)) return current.includes(optionValue);
|
|
27
|
+
return current === optionValue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isAllActive(groupKey: string): boolean {
|
|
31
|
+
const current = activeFilters[groupKey];
|
|
32
|
+
return current === null || current === undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function handleSelect(group: FilterGroup, optionValue: string) {
|
|
36
|
+
if (group.type === 'single') {
|
|
37
|
+
// Single select: toggle between this option and "all"
|
|
38
|
+
if (activeFilters[group.key] === optionValue) {
|
|
39
|
+
activeFilters[group.key] = null;
|
|
40
|
+
} else {
|
|
41
|
+
activeFilters[group.key] = optionValue;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
// Multi select: toggle option in array
|
|
45
|
+
const current = activeFilters[group.key];
|
|
46
|
+
let arr: string[] = Array.isArray(current) ? [...current] : [];
|
|
47
|
+
|
|
48
|
+
if (arr.includes(optionValue)) {
|
|
49
|
+
arr = arr.filter(v => v !== optionValue);
|
|
50
|
+
} else {
|
|
51
|
+
arr.push(optionValue);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
activeFilters[group.key] = arr.length > 0 ? arr : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onFilterChange?.(activeFilters);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleSelectAll(groupKey: string) {
|
|
61
|
+
activeFilters[groupKey] = null;
|
|
62
|
+
onFilterChange?.(activeFilters);
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<div class="flex flex-wrap gap-3" data-component-name="FilterBar">
|
|
67
|
+
{#each groups as group}
|
|
68
|
+
<div class="flex items-center gap-1.5">
|
|
69
|
+
{#if group.label}
|
|
70
|
+
<span class="text-xs font-medium text-gray-500 mr-1 hidden sm:inline">{group.label}:</span>
|
|
71
|
+
{/if}
|
|
72
|
+
|
|
73
|
+
<div class="flex flex-wrap items-center bg-gray-100 rounded-xl p-1 {compact ? 'gap-0.5' : 'gap-1'}">
|
|
74
|
+
<!-- "All" option -->
|
|
75
|
+
{#if group.allLabel !== null}
|
|
76
|
+
<button
|
|
77
|
+
onclick={() => handleSelectAll(group.key)}
|
|
78
|
+
class="
|
|
79
|
+
{compact ? 'px-2.5 py-1.5 text-xs' : 'px-3 py-1.5 text-sm'}
|
|
80
|
+
rounded-lg font-medium transition-all cursor-pointer
|
|
81
|
+
{isAllActive(group.key)
|
|
82
|
+
? 'bg-white text-blue-600 shadow-sm'
|
|
83
|
+
: 'text-gray-600 hover:text-gray-900'}
|
|
84
|
+
"
|
|
85
|
+
>
|
|
86
|
+
{group.allLabel || t('common.all') || 'Tümü'}
|
|
87
|
+
</button>
|
|
88
|
+
{/if}
|
|
89
|
+
|
|
90
|
+
<!-- Filter options -->
|
|
91
|
+
{#each group.options as option}
|
|
92
|
+
<button
|
|
93
|
+
onclick={() => handleSelect(group, option.value)}
|
|
94
|
+
class="
|
|
95
|
+
flex items-start gap-1.5 text-left
|
|
96
|
+
{compact ? 'px-2.5 py-1.5 text-xs' : 'px-3 py-1.5 text-sm'}
|
|
97
|
+
max-w-[10rem] sm:max-w-[14rem]
|
|
98
|
+
rounded-lg font-medium transition-all cursor-pointer
|
|
99
|
+
{isActive(group.key, option.value)
|
|
100
|
+
? 'bg-white text-blue-600 shadow-sm'
|
|
101
|
+
: 'text-gray-600 hover:text-gray-900'}
|
|
102
|
+
"
|
|
103
|
+
>
|
|
104
|
+
{#if option.icon}
|
|
105
|
+
<span class="text-sm">{option.icon}</span>
|
|
106
|
+
{/if}
|
|
107
|
+
<span class="whitespace-normal break-words leading-tight">{option.label}</span>
|
|
108
|
+
{#if option.count !== undefined}
|
|
109
|
+
<span class="
|
|
110
|
+
{compact ? 'text-[10px] px-1' : 'text-xs px-1.5'}
|
|
111
|
+
py-0.5 rounded-full
|
|
112
|
+
{isActive(group.key, option.value)
|
|
113
|
+
? 'bg-blue-100 text-blue-700'
|
|
114
|
+
: 'bg-gray-200 text-gray-600'}
|
|
115
|
+
">
|
|
116
|
+
{option.count}
|
|
117
|
+
</span>
|
|
118
|
+
{/if}
|
|
119
|
+
</button>
|
|
120
|
+
{/each}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
{/each}
|
|
124
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FilterGroup, ActiveFilters } from './types';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Array of filter group definitions */
|
|
4
|
+
groups: FilterGroup[];
|
|
5
|
+
/** Current active filter values (bindable) */
|
|
6
|
+
activeFilters?: ActiveFilters;
|
|
7
|
+
/** Called when any filter changes */
|
|
8
|
+
onFilterChange?: (filters: ActiveFilters) => void;
|
|
9
|
+
/** Compact mode - smaller chips */
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
}
|
|
12
|
+
declare const FilterBar: import("svelte").Component<Props, {}, "activeFilters">;
|
|
13
|
+
type FilterBar = ReturnType<typeof FilterBar>;
|
|
14
|
+
export default FilterBar;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as FilterBar } from './FilterBar.svelte';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single filter option within a filter group
|
|
3
|
+
*/
|
|
4
|
+
export interface FilterOption {
|
|
5
|
+
/** Unique value for this option */
|
|
6
|
+
value: string;
|
|
7
|
+
/** Display label */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Optional icon (emoji or SVG path) */
|
|
10
|
+
icon?: string;
|
|
11
|
+
/** Optional count badge */
|
|
12
|
+
count?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A group of related filter options
|
|
16
|
+
* Each group represents a filter dimension (e.g., type, category, status)
|
|
17
|
+
*/
|
|
18
|
+
export interface FilterGroup {
|
|
19
|
+
/** Unique key for this filter group (used in activeFilters map) */
|
|
20
|
+
key: string;
|
|
21
|
+
/** Display label for the group */
|
|
22
|
+
label?: string;
|
|
23
|
+
/** Available options */
|
|
24
|
+
options: FilterOption[];
|
|
25
|
+
/** Selection mode: single = radio-like, multi = checkbox-like */
|
|
26
|
+
type: 'single' | 'multi';
|
|
27
|
+
/** Custom label for the "all" option. Set to null to hide "all" option (only for multi) */
|
|
28
|
+
allLabel?: string | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Map of active filter values
|
|
32
|
+
* key = FilterGroup.key
|
|
33
|
+
* value = null (all), string (single selection), string[] (multi selection)
|
|
34
|
+
*/
|
|
35
|
+
export type ActiveFilters = Record<string, string | string[] | null>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import Modal from '../Modal/Modal.svelte';
|
|
4
|
+
import { t } from '../utils/translations.svelte';
|
|
5
|
+
|
|
6
|
+
type ModalColor = 'blue' | 'green' | 'amber' | 'red' | 'purple' | 'gray' | 'emerald' | 'teal';
|
|
7
|
+
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
|
|
8
|
+
|
|
9
|
+
export interface ImageEditorSavePayload {
|
|
10
|
+
dataUrl: string;
|
|
11
|
+
imageData: any;
|
|
12
|
+
designState: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
show?: boolean;
|
|
17
|
+
onClose?: () => void;
|
|
18
|
+
onReady?: () => void;
|
|
19
|
+
onSave?: (payload: ImageEditorSavePayload) => Promise<void> | void;
|
|
20
|
+
initialImageUrl?: string;
|
|
21
|
+
color?: ModalColor;
|
|
22
|
+
size?: ModalSize;
|
|
23
|
+
zIndex?: number;
|
|
24
|
+
title?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
filerobotOptions?: Record<string, any>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
show = $bindable(false),
|
|
31
|
+
onClose,
|
|
32
|
+
onReady,
|
|
33
|
+
onSave,
|
|
34
|
+
initialImageUrl = '',
|
|
35
|
+
color = 'blue',
|
|
36
|
+
size = '3xl',
|
|
37
|
+
zIndex = 70,
|
|
38
|
+
title = t('imageEditor.title', 'Image Editor'),
|
|
39
|
+
description = t('imageEditor.description', 'Crop and adjust your image before saving'),
|
|
40
|
+
filerobotOptions = {}
|
|
41
|
+
}: Props = $props();
|
|
42
|
+
|
|
43
|
+
let editorHost: HTMLDivElement | null = $state(null);
|
|
44
|
+
let editorInstance: any = $state(null);
|
|
45
|
+
let isInitializing = $state(false);
|
|
46
|
+
let isSaving = $state(false);
|
|
47
|
+
let editorError = $state('');
|
|
48
|
+
|
|
49
|
+
const FILEROBOT_CDN_URL = 'https://scaleflex.cloudimg.io/v7/plugins/filerobot-image-editor/latest/filerobot-image-editor.min.js';
|
|
50
|
+
const FILEROBOT_LOAD_TIMEOUT_MS = 12000;
|
|
51
|
+
|
|
52
|
+
function getFilerobotGlobal(): any {
|
|
53
|
+
return typeof window !== 'undefined' ? (window as any).FilerobotImageEditor : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function waitForFilerobotGlobal(timeoutMs: number): Promise<any> {
|
|
57
|
+
const start = Date.now();
|
|
58
|
+
|
|
59
|
+
return new Promise<any>((resolve, reject) => {
|
|
60
|
+
const tick = () => {
|
|
61
|
+
const globalRef = getFilerobotGlobal();
|
|
62
|
+
if (globalRef) {
|
|
63
|
+
resolve(globalRef);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (Date.now() - start >= timeoutMs) {
|
|
68
|
+
reject(new Error('Image editor script load timed out. Please check internet/CSP and retry.'));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setTimeout(tick, 120);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
tick();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function injectFilerobotScript(): Promise<any> {
|
|
80
|
+
await new Promise<void>((resolve, reject) => {
|
|
81
|
+
const script = document.createElement('script');
|
|
82
|
+
const timeout = window.setTimeout(() => {
|
|
83
|
+
script.remove();
|
|
84
|
+
reject(new Error('Image editor script load timed out.'));
|
|
85
|
+
}, FILEROBOT_LOAD_TIMEOUT_MS);
|
|
86
|
+
|
|
87
|
+
script.src = FILEROBOT_CDN_URL;
|
|
88
|
+
script.async = true;
|
|
89
|
+
script.defer = true;
|
|
90
|
+
script.dataset.filerobot = '1';
|
|
91
|
+
script.onload = () => {
|
|
92
|
+
window.clearTimeout(timeout);
|
|
93
|
+
resolve();
|
|
94
|
+
};
|
|
95
|
+
script.onerror = () => {
|
|
96
|
+
window.clearTimeout(timeout);
|
|
97
|
+
script.remove();
|
|
98
|
+
reject(new Error('Image editor script failed to load.'));
|
|
99
|
+
};
|
|
100
|
+
document.head.appendChild(script);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return waitForFilerobotGlobal(FILEROBOT_LOAD_TIMEOUT_MS);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function ensureFilerobotLoaded(): Promise<any> {
|
|
107
|
+
const existing = getFilerobotGlobal();
|
|
108
|
+
if (existing) return existing;
|
|
109
|
+
|
|
110
|
+
if (typeof document === 'undefined') {
|
|
111
|
+
throw new Error('Filerobot can only be initialized in browser');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const currentScript = document.querySelector(`script[data-filerobot="1"]`) as HTMLScriptElement | null;
|
|
115
|
+
if (currentScript) {
|
|
116
|
+
try {
|
|
117
|
+
return await waitForFilerobotGlobal(FILEROBOT_LOAD_TIMEOUT_MS);
|
|
118
|
+
} catch {
|
|
119
|
+
// Recover from stale/broken script tag by re-injecting.
|
|
120
|
+
currentScript.remove();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return injectFilerobotScript();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function cleanupEditor(): void {
|
|
128
|
+
if (!editorInstance) return;
|
|
129
|
+
try {
|
|
130
|
+
editorInstance.terminate?.();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.warn('[ImageEditorModal] terminate failed', error);
|
|
133
|
+
} finally {
|
|
134
|
+
editorInstance = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function closeModal(): void {
|
|
139
|
+
cleanupEditor();
|
|
140
|
+
editorError = '';
|
|
141
|
+
show = false;
|
|
142
|
+
onClose?.();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function handleSave(): Promise<void> {
|
|
146
|
+
const inst = editorInstance;
|
|
147
|
+
if (!inst || isSaving || isInitializing || !onSave) return;
|
|
148
|
+
|
|
149
|
+
isSaving = true;
|
|
150
|
+
editorError = '';
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
// Detect format from original image URL
|
|
154
|
+
const urlExt = initialImageUrl.split('.').pop()?.toLowerCase()?.split('?')[0] || 'png';
|
|
155
|
+
const extension = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'].includes(urlExt) ? urlExt : 'png';
|
|
156
|
+
const mimeType = extension === 'jpg' || extension === 'jpeg' ? 'image/jpeg' : `image/${extension}`;
|
|
157
|
+
const quality = (extension === 'jpg' || extension === 'jpeg' || extension === 'webp') ? 0.92 : undefined;
|
|
158
|
+
|
|
159
|
+
let dataUrl = '';
|
|
160
|
+
let imageData: any = null;
|
|
161
|
+
let designState: any = {};
|
|
162
|
+
|
|
163
|
+
// Use VanillaJS bridge getCurrentImgData — applies all operations (crop, rotate, etc.)
|
|
164
|
+
if (typeof inst.getCurrentImgData === 'function') {
|
|
165
|
+
const result = inst.getCurrentImgData(
|
|
166
|
+
{ extension, quality: quality ?? 0.92 },
|
|
167
|
+
4, // pixelRatio
|
|
168
|
+
false // keepLoadingSpinnerShown
|
|
169
|
+
);
|
|
170
|
+
imageData = result?.imageData || result;
|
|
171
|
+
designState = result?.designState || {};
|
|
172
|
+
dataUrl = extractDataUrl(imageData, mimeType, quality);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Fallback: grab the processed canvas from editor DOM
|
|
176
|
+
if (!dataUrl && editorHost) {
|
|
177
|
+
const canvases = editorHost.querySelectorAll('canvas');
|
|
178
|
+
// Take the last canvas which is typically the output
|
|
179
|
+
const canvas = canvases[canvases.length - 1] as HTMLCanvasElement | null;
|
|
180
|
+
if (canvas) {
|
|
181
|
+
dataUrl = canvas.toDataURL(mimeType, quality);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!dataUrl) {
|
|
186
|
+
editorError = t('imageEditor.saveError', 'Edited image data is empty');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await onSave({ dataUrl, imageData, designState });
|
|
191
|
+
closeModal();
|
|
192
|
+
} catch (error) {
|
|
193
|
+
editorError = error instanceof Error ? error.message : t('common.error', 'An error occurred');
|
|
194
|
+
} finally {
|
|
195
|
+
isSaving = false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function extractDataUrl(imageData: any, mimeType?: string, quality?: number): string {
|
|
200
|
+
if (typeof imageData?.imageBase64 === 'string' && imageData.imageBase64.startsWith('data:image/')) {
|
|
201
|
+
return imageData.imageBase64;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (typeof imageData?.base64 === 'string' && imageData.base64.startsWith('data:image/')) {
|
|
205
|
+
return imageData.base64;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (imageData?.imageCanvas?.toDataURL) {
|
|
209
|
+
return imageData.imageCanvas.toDataURL(mimeType || 'image/png', quality);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (imageData?.canvas?.toDataURL) {
|
|
213
|
+
return imageData.canvas.toDataURL(mimeType || 'image/png', quality);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return '';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
$effect(() => {
|
|
220
|
+
if (!show) {
|
|
221
|
+
untrack(() => cleanupEditor());
|
|
222
|
+
editorError = '';
|
|
223
|
+
isInitializing = false;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!editorHost || !initialImageUrl) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let cancelled = false;
|
|
232
|
+
|
|
233
|
+
const initializeEditor = async () => {
|
|
234
|
+
untrack(() => cleanupEditor());
|
|
235
|
+
isInitializing = true;
|
|
236
|
+
editorError = '';
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const moduleRef = await ensureFilerobotLoaded();
|
|
240
|
+
if (cancelled) return;
|
|
241
|
+
|
|
242
|
+
const EditorCtor: any = moduleRef;
|
|
243
|
+
const tabs = (moduleRef as any).TABS || {};
|
|
244
|
+
const tools = (moduleRef as any).TOOLS || {};
|
|
245
|
+
|
|
246
|
+
const config = {
|
|
247
|
+
source: initialImageUrl,
|
|
248
|
+
useBackendTranslations: false,
|
|
249
|
+
closeAfterSave: false,
|
|
250
|
+
disableSaveIfNoChanges: false,
|
|
251
|
+
tabsIds: [tabs.ADJUST, tabs.RESIZE],
|
|
252
|
+
defaultTabId: tabs.ADJUST,
|
|
253
|
+
defaultToolId: tools.CROP,
|
|
254
|
+
savingPixelRatio: 4,
|
|
255
|
+
removeSaveButton: true,
|
|
256
|
+
// Prevent Filerobot's own save dialog if triggered internally
|
|
257
|
+
onBeforeSave: () => false,
|
|
258
|
+
onClose: () => {
|
|
259
|
+
closeModal();
|
|
260
|
+
},
|
|
261
|
+
...filerobotOptions
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
editorInstance = new EditorCtor(editorHost, config);
|
|
265
|
+
editorInstance.render?.();
|
|
266
|
+
onReady?.();
|
|
267
|
+
} catch (error) {
|
|
268
|
+
editorError = error instanceof Error ? error.message : t('common.error', 'An error occurred');
|
|
269
|
+
} finally {
|
|
270
|
+
isInitializing = false;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
initializeEditor();
|
|
275
|
+
|
|
276
|
+
return () => {
|
|
277
|
+
cancelled = true;
|
|
278
|
+
untrack(() => cleanupEditor());
|
|
279
|
+
isInitializing = false;
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
</script>
|
|
283
|
+
|
|
284
|
+
<Modal
|
|
285
|
+
open={show}
|
|
286
|
+
{title}
|
|
287
|
+
{description}
|
|
288
|
+
icon="🖼️"
|
|
289
|
+
{color}
|
|
290
|
+
{size}
|
|
291
|
+
{zIndex}
|
|
292
|
+
onClose={closeModal}
|
|
293
|
+
cancelText={t('common.cancel', 'Cancel')}
|
|
294
|
+
confirmText={isInitializing || isSaving ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
|
|
295
|
+
confirmDisabled={isInitializing || isSaving || !editorInstance}
|
|
296
|
+
confirmLoading={isSaving}
|
|
297
|
+
onConfirm={handleSave}
|
|
298
|
+
>
|
|
299
|
+
{#snippet children()}
|
|
300
|
+
<div class="space-y-3" data-component-name="ImageEditorModal">
|
|
301
|
+
{#if editorError}
|
|
302
|
+
<div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
303
|
+
{editorError}
|
|
304
|
+
</div>
|
|
305
|
+
{/if}
|
|
306
|
+
|
|
307
|
+
{#if isInitializing}
|
|
308
|
+
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700">
|
|
309
|
+
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
310
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
311
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
|
|
312
|
+
</svg>
|
|
313
|
+
{t('common.loading', 'Loading...')}
|
|
314
|
+
</div>
|
|
315
|
+
{/if}
|
|
316
|
+
|
|
317
|
+
<div class="h-[65vh] min-h-[480px] w-full overflow-hidden rounded-xl border border-gray-200 bg-gray-100">
|
|
318
|
+
<div bind:this={editorHost} class="h-full w-full"></div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
</div>
|
|
322
|
+
{/snippet}
|
|
323
|
+
</Modal>
|
|
324
|
+
|
|
325
|
+
<style>
|
|
326
|
+
/* Hide Filerobot's internal save button — we use our own modal footer button */
|
|
327
|
+
:global(.FIE_topbar-save-button) {
|
|
328
|
+
display: none !important;
|
|
329
|
+
}
|
|
330
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type ModalColor = 'blue' | 'green' | 'amber' | 'red' | 'purple' | 'gray' | 'emerald' | 'teal';
|
|
2
|
+
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
|
|
3
|
+
export interface ImageEditorSavePayload {
|
|
4
|
+
dataUrl: string;
|
|
5
|
+
imageData: any;
|
|
6
|
+
designState: any;
|
|
7
|
+
}
|
|
8
|
+
interface Props {
|
|
9
|
+
show?: boolean;
|
|
10
|
+
onClose?: () => void;
|
|
11
|
+
onReady?: () => void;
|
|
12
|
+
onSave?: (payload: ImageEditorSavePayload) => Promise<void> | void;
|
|
13
|
+
initialImageUrl?: string;
|
|
14
|
+
color?: ModalColor;
|
|
15
|
+
size?: ModalSize;
|
|
16
|
+
zIndex?: number;
|
|
17
|
+
title?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
filerobotOptions?: Record<string, any>;
|
|
20
|
+
}
|
|
21
|
+
declare const ImageEditorModal: import("svelte").Component<Props, {}, "show">;
|
|
22
|
+
type ImageEditorModal = ReturnType<typeof ImageEditorModal>;
|
|
23
|
+
export default ImageEditorModal;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ImageEditorModal } from './ImageEditorModal.svelte';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ImageEditorModal } from './ImageEditorModal.svelte';
|