@smartnet360/svelte-components 0.0.38 → 0.0.40
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/apps/site-check/SiteCheck.svelte +5 -3
- package/dist/apps/site-check/SiteCheck.svelte.d.ts +2 -1
- package/dist/apps/site-check/helper.js +4 -3
- package/dist/apps/site-check/index.d.ts +1 -1
- package/dist/apps/site-check/index.js +1 -1
- package/dist/apps/site-check/transforms.d.ts +19 -0
- package/dist/apps/site-check/transforms.js +61 -3
- package/dist/core/Charts/ChartCard.svelte +1 -1
- package/dist/core/Charts/data-utils.js +29 -2
- package/dist/core/Settings/FieldRenderer.svelte +234 -0
- package/dist/core/Settings/FieldRenderer.svelte.d.ts +30 -0
- package/dist/core/Settings/Settings.svelte +199 -0
- package/dist/core/Settings/Settings.svelte.d.ts +24 -0
- package/dist/core/Settings/index.d.ts +9 -0
- package/dist/core/Settings/index.js +8 -0
- package/dist/core/Settings/store.d.ts +56 -0
- package/dist/core/Settings/store.js +184 -0
- package/dist/core/Settings/types.d.ts +162 -0
- package/dist/core/Settings/types.js +7 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +2 -0
- package/package.json +1 -1
|
@@ -2,22 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
<script lang="ts">
|
|
4
4
|
import { TreeView, createTreeStore } from '../../core/TreeView';
|
|
5
|
-
import { ChartComponent,
|
|
5
|
+
import { ChartComponent, type Layout, type CellStylingConfig } from '../../core/Charts';
|
|
6
6
|
import { buildTreeNodes, filterChartData, transformChartData, type CellTrafficRecord, defaultCellStyling } from './index';
|
|
7
7
|
import { expandLayoutForCells } from './helper';
|
|
8
8
|
import { log } from '../../core/logger';
|
|
9
9
|
import { onMount } from 'svelte';
|
|
10
|
-
import type { Mode } from '../../index.js';
|
|
10
|
+
import type {ChartMarker, Mode } from '../../index.js';
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
13
13
|
rawData: CellTrafficRecord[];
|
|
14
14
|
baseLayout: Layout;
|
|
15
15
|
baseMetrics: string[];
|
|
16
16
|
mode: Mode;
|
|
17
|
+
markers?: ChartMarker[];
|
|
17
18
|
cellStyling?: CellStylingConfig; // Optional cell styling config (defaults to defaultCellStyling)
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
let { rawData, baseLayout, baseMetrics, mode = "scrollspy", cellStyling = defaultCellStyling }: Props = $props();
|
|
21
|
+
let { rawData, baseLayout, baseMetrics, mode = "scrollspy", markers = [], cellStyling = defaultCellStyling }: Props = $props();
|
|
21
22
|
|
|
22
23
|
let treeStore = $state<ReturnType<typeof createTreeStore> | null>(null);
|
|
23
24
|
|
|
@@ -125,6 +126,7 @@
|
|
|
125
126
|
layout={chartLayout}
|
|
126
127
|
data={chartData}
|
|
127
128
|
mode={mode}
|
|
129
|
+
markers={markers}
|
|
128
130
|
showGlobalControls={true}
|
|
129
131
|
enableAdaptation={true}
|
|
130
132
|
/>
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { type Layout, type CellStylingConfig } from '../../core/Charts';
|
|
2
2
|
import { type CellTrafficRecord } from './index';
|
|
3
|
-
import type { Mode } from '../../index.js';
|
|
3
|
+
import type { ChartMarker, Mode } from '../../index.js';
|
|
4
4
|
interface Props {
|
|
5
5
|
rawData: CellTrafficRecord[];
|
|
6
6
|
baseLayout: Layout;
|
|
7
7
|
baseMetrics: string[];
|
|
8
8
|
mode: Mode;
|
|
9
|
+
markers?: ChartMarker[];
|
|
9
10
|
cellStyling?: CellStylingConfig;
|
|
10
11
|
}
|
|
11
12
|
declare const SiteCheck: import("svelte").Component<Props, {}, "">;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createStyledKPI } from './transforms.js';
|
|
1
|
+
import { createStyledKPI, sortCellsByBandFrequency } from './transforms.js';
|
|
2
2
|
/**
|
|
3
3
|
* Expand base layout configuration with dynamic KPIs based on selected cells
|
|
4
4
|
* Takes a base layout (with one KPI per metric) and expands it to include one KPI per cell
|
|
@@ -9,14 +9,15 @@ import { createStyledKPI } from './transforms.js';
|
|
|
9
9
|
* @returns Expanded layout with cell-specific KPIs
|
|
10
10
|
*/
|
|
11
11
|
export function expandLayoutForCells(baseLayout, data, stylingConfig) {
|
|
12
|
-
// Get unique cells and their metadata
|
|
12
|
+
// Get unique cells and their metadata, sorted by band frequency
|
|
13
13
|
const cellMap = new Map();
|
|
14
14
|
data.forEach((record) => {
|
|
15
15
|
if (!cellMap.has(record.cellName)) {
|
|
16
16
|
cellMap.set(record.cellName, record);
|
|
17
17
|
}
|
|
18
18
|
});
|
|
19
|
-
|
|
19
|
+
// Sort cells by band frequency instead of alphabetically
|
|
20
|
+
const cells = sortCellsByBandFrequency(Array.from(cellMap.entries()));
|
|
20
21
|
// Deep clone the layout structure and expand KPIs
|
|
21
22
|
const expandedLayout = {
|
|
22
23
|
layoutName: baseLayout.layoutName,
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { default as SiteCheck } from './SiteCheck.svelte';
|
|
6
6
|
export { loadCellTrafficData, getUniqueSites, getUniqueCells, groupDataByCell, type CellTrafficRecord } from './data-loader.js';
|
|
7
|
-
export { buildTreeNodes, filterChartData, transformChartData, createStyledKPI } from './transforms.js';
|
|
7
|
+
export { buildTreeNodes, filterChartData, transformChartData, createStyledKPI, extractBandFromCell, getBandFrequency, sortCellsByBandFrequency } from './transforms.js';
|
|
8
8
|
export { expandLayoutForCells, extractBaseMetrics } from './helper.js';
|
|
9
9
|
export { defaultCellStyling } from './default-cell-styling.js';
|
|
@@ -7,7 +7,7 @@ export { default as SiteCheck } from './SiteCheck.svelte';
|
|
|
7
7
|
// Data loading
|
|
8
8
|
export { loadCellTrafficData, getUniqueSites, getUniqueCells, groupDataByCell } from './data-loader.js';
|
|
9
9
|
// Data transforms
|
|
10
|
-
export { buildTreeNodes, filterChartData, transformChartData, createStyledKPI } from './transforms.js';
|
|
10
|
+
export { buildTreeNodes, filterChartData, transformChartData, createStyledKPI, extractBandFromCell, getBandFrequency, sortCellsByBandFrequency } from './transforms.js';
|
|
11
11
|
// Helper functions
|
|
12
12
|
export { expandLayoutForCells, extractBaseMetrics } from './helper.js';
|
|
13
13
|
// Default cell styling configuration
|
|
@@ -5,6 +5,25 @@
|
|
|
5
5
|
import type { TreeNode } from '../../core/TreeView';
|
|
6
6
|
import type { KPI, CellStylingConfig } from '../../core/Charts';
|
|
7
7
|
import type { CellTrafficRecord } from './data-loader';
|
|
8
|
+
/**
|
|
9
|
+
* Extract band from cell name using regex pattern matching
|
|
10
|
+
* @param cellName - Cell name like "LTE700_1", "NR3500_2", etc.
|
|
11
|
+
* @returns Band string like "LTE700", "NR3500" or null if not found
|
|
12
|
+
* @deprecated Use the band field from CellTrafficRecord instead
|
|
13
|
+
*/
|
|
14
|
+
export declare function extractBandFromCell(cellName: string): string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Get frequency order for a band (for sorting)
|
|
17
|
+
* @param band - Band string like "LTE700", "NR3500"
|
|
18
|
+
* @returns Frequency number or high value for unknown bands
|
|
19
|
+
*/
|
|
20
|
+
export declare function getBandFrequency(band: string | null): number;
|
|
21
|
+
/**
|
|
22
|
+
* Sort items by band frequency using actual band data from records
|
|
23
|
+
* @param items - Array of [cellName, record] tuples
|
|
24
|
+
* @returns Sorted array (ascending frequency order)
|
|
25
|
+
*/
|
|
26
|
+
export declare function sortCellsByBandFrequency(items: [string, CellTrafficRecord][]): [string, CellTrafficRecord][];
|
|
8
27
|
/**
|
|
9
28
|
* Build hierarchical tree structure: Site → Sector (Azimuth) → Cell (Band)
|
|
10
29
|
*/
|
|
@@ -3,6 +3,64 @@
|
|
|
3
3
|
* Converts raw CSV data to TreeView nodes and Chart configurations
|
|
4
4
|
*/
|
|
5
5
|
import { log } from '../../core/logger';
|
|
6
|
+
/**
|
|
7
|
+
* Band frequency mapping for consistent ordering
|
|
8
|
+
* Maps band strings to their actual frequencies in MHz
|
|
9
|
+
*/
|
|
10
|
+
const BAND_FREQUENCY_ORDER = {
|
|
11
|
+
// LTE Bands (by frequency)
|
|
12
|
+
'LTE700': 700,
|
|
13
|
+
'LTE800': 800,
|
|
14
|
+
'LTE900': 900,
|
|
15
|
+
'LTE1800': 1800,
|
|
16
|
+
'LTE2100': 2100,
|
|
17
|
+
'LTE2600': 2600,
|
|
18
|
+
// NR/5G Bands (by frequency)
|
|
19
|
+
'NR700': 700.1, // Slightly higher to sort after LTE700
|
|
20
|
+
'NR2100': 2100.1,
|
|
21
|
+
'NR3500': 3500,
|
|
22
|
+
'NR26000': 26000 // mmWave
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Extract band from cell name using regex pattern matching
|
|
26
|
+
* @param cellName - Cell name like "LTE700_1", "NR3500_2", etc.
|
|
27
|
+
* @returns Band string like "LTE700", "NR3500" or null if not found
|
|
28
|
+
* @deprecated Use the band field from CellTrafficRecord instead
|
|
29
|
+
*/
|
|
30
|
+
export function extractBandFromCell(cellName) {
|
|
31
|
+
// Match patterns like "LTE700", "NR3500", etc.
|
|
32
|
+
const match = cellName.match(/(LTE|NR)(\d+)/i);
|
|
33
|
+
return match ? `${match[1].toUpperCase()}${match[2]}` : null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Get frequency order for a band (for sorting)
|
|
37
|
+
* @param band - Band string like "LTE700", "NR3500"
|
|
38
|
+
* @returns Frequency number or high value for unknown bands
|
|
39
|
+
*/
|
|
40
|
+
export function getBandFrequency(band) {
|
|
41
|
+
if (!band)
|
|
42
|
+
return 999999; // Unknown bands go to end
|
|
43
|
+
return BAND_FREQUENCY_ORDER[band] || 999999;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Sort items by band frequency using actual band data from records
|
|
47
|
+
* @param items - Array of [cellName, record] tuples
|
|
48
|
+
* @returns Sorted array (ascending frequency order)
|
|
49
|
+
*/
|
|
50
|
+
export function sortCellsByBandFrequency(items) {
|
|
51
|
+
return items.sort((a, b) => {
|
|
52
|
+
const [cellNameA, recordA] = a;
|
|
53
|
+
const [cellNameB, recordB] = b;
|
|
54
|
+
const freqA = getBandFrequency(recordA.band);
|
|
55
|
+
const freqB = getBandFrequency(recordB.band);
|
|
56
|
+
// Primary sort: by frequency
|
|
57
|
+
if (freqA !== freqB) {
|
|
58
|
+
return freqA - freqB;
|
|
59
|
+
}
|
|
60
|
+
// Secondary sort: by cell name for same frequency
|
|
61
|
+
return cellNameA.localeCompare(cellNameB);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
6
64
|
/**
|
|
7
65
|
* Build hierarchical tree structure: Site → Sector (Azimuth) → Cell (Band)
|
|
8
66
|
*/
|
|
@@ -50,9 +108,9 @@ export function buildTreeNodes(data) {
|
|
|
50
108
|
defaultChecked: false, // Don't check parent nodes
|
|
51
109
|
children: []
|
|
52
110
|
};
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
111
|
+
// Sort cells by band frequency (LTE700, LTE800, etc.)
|
|
112
|
+
const sortedCells = sortCellsByBandFrequency(Array.from(cellMap.entries()));
|
|
113
|
+
sortedCells.forEach(([cellName, record]) => {
|
|
56
114
|
const cellNode = {
|
|
57
115
|
id: cellName, // Simple ID (just cell name)
|
|
58
116
|
label: `${cellName} (${record.band})`,
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
// Create default modern layout using the centralized function
|
|
181
|
-
const defaultLayout: any = createDefaultPlotlyLayout(chart.title, layoutHoverMode);
|
|
181
|
+
const defaultLayout: any = createDefaultPlotlyLayout(chart.title, layoutHoverMode, layoutColoredHover);
|
|
182
182
|
|
|
183
183
|
// Override specific properties for this chart
|
|
184
184
|
defaultLayout.yaxis.title = {
|
|
@@ -11,6 +11,27 @@ const modernColors = [
|
|
|
11
11
|
'#EC4899', // Pink
|
|
12
12
|
'#6B7280' // Gray
|
|
13
13
|
];
|
|
14
|
+
/**
|
|
15
|
+
* Darken a hex color by a given amount
|
|
16
|
+
* @param color - Hex color string (e.g., '#3B82F6')
|
|
17
|
+
* @param amount - Amount to darken (0-1, where 0.2 = 20% darker)
|
|
18
|
+
* @returns Darkened hex color
|
|
19
|
+
*/
|
|
20
|
+
function darkenColor(color, amount) {
|
|
21
|
+
// Remove # if present
|
|
22
|
+
const hex = color.replace('#', '');
|
|
23
|
+
// Parse RGB components
|
|
24
|
+
const r = parseInt(hex.substr(0, 2), 16);
|
|
25
|
+
const g = parseInt(hex.substr(2, 2), 16);
|
|
26
|
+
const b = parseInt(hex.substr(4, 2), 16);
|
|
27
|
+
// Darken each component
|
|
28
|
+
const newR = Math.round(r * (1 - amount));
|
|
29
|
+
const newG = Math.round(g * (1 - amount));
|
|
30
|
+
const newB = Math.round(b * (1 - amount));
|
|
31
|
+
// Convert back to hex
|
|
32
|
+
const toHex = (n) => n.toString(16).padStart(2, '0');
|
|
33
|
+
return `#${toHex(newR)}${toHex(newG)}${toHex(newB)}`;
|
|
34
|
+
}
|
|
14
35
|
// Cache for processed KPI data to avoid reprocessing on every render
|
|
15
36
|
const dataCache = new WeakMap();
|
|
16
37
|
export function processKPIData(data, kpi) {
|
|
@@ -106,7 +127,10 @@ export function createTimeSeriesTrace(values, timestamps, kpi, yaxis = 'y1', col
|
|
|
106
127
|
mode: 'lines',
|
|
107
128
|
fill: 'tonexty',
|
|
108
129
|
stackgroup: stackGroup || 'one',
|
|
109
|
-
line: {
|
|
130
|
+
line: {
|
|
131
|
+
width: 1.5, // Visible border width
|
|
132
|
+
color: darkenColor(traceColor, 0.25) // 25% darker border for better separation
|
|
133
|
+
},
|
|
110
134
|
fillcolor: traceColor
|
|
111
135
|
};
|
|
112
136
|
case 'stacked-percentage':
|
|
@@ -117,7 +141,10 @@ export function createTimeSeriesTrace(values, timestamps, kpi, yaxis = 'y1', col
|
|
|
117
141
|
fill: 'tonexty',
|
|
118
142
|
stackgroup: stackGroup || 'one',
|
|
119
143
|
groupnorm: 'percent',
|
|
120
|
-
line: {
|
|
144
|
+
line: {
|
|
145
|
+
width: 1.5, // Visible border width
|
|
146
|
+
color: darkenColor(traceColor, 0.25) // 25% darker border for better separation
|
|
147
|
+
},
|
|
121
148
|
fillcolor: traceColor,
|
|
122
149
|
hovertemplate: `<b>${kpi.name}</b><br>` +
|
|
123
150
|
`Percentage: %{y:.1f}%<br>` +
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FieldDefinition } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic Field Renderer
|
|
6
|
+
*
|
|
7
|
+
* Renders a form field based on its type definition.
|
|
8
|
+
* Uses Bootstrap form components for styling.
|
|
9
|
+
* Works with hierarchical store structure.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export let field: FieldDefinition;
|
|
13
|
+
export let segmentId: string;
|
|
14
|
+
export let store: any; // The hierarchical settings store
|
|
15
|
+
export let value: any;
|
|
16
|
+
|
|
17
|
+
// Generate unique ID for form elements
|
|
18
|
+
const inputId = `field-${segmentId}-${field.id}`;
|
|
19
|
+
|
|
20
|
+
// Handle value changes - update the hierarchical store
|
|
21
|
+
function handleChange(newValue: any) {
|
|
22
|
+
store.update(segmentId, field.id, newValue);
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<div class="field-wrapper mb-3">
|
|
27
|
+
{#if field.type === 'boolean'}
|
|
28
|
+
<div class="form-check form-switch">
|
|
29
|
+
<input
|
|
30
|
+
id={inputId}
|
|
31
|
+
type="checkbox"
|
|
32
|
+
class="form-check-input"
|
|
33
|
+
checked={value}
|
|
34
|
+
disabled={field.disabled}
|
|
35
|
+
on:change={(e) => handleChange(e.currentTarget.checked)}
|
|
36
|
+
/>
|
|
37
|
+
<label class="form-check-label" for={inputId}>
|
|
38
|
+
{field.label}
|
|
39
|
+
</label>
|
|
40
|
+
{#if field.description}
|
|
41
|
+
<div class="form-text">{field.description}</div>
|
|
42
|
+
{/if}
|
|
43
|
+
</div>
|
|
44
|
+
{:else if field.type === 'text'}
|
|
45
|
+
<label for={inputId} class="form-label">
|
|
46
|
+
{field.label}
|
|
47
|
+
{#if field.tooltip}
|
|
48
|
+
<span class="text-muted" title={field.tooltip}>ⓘ</span>
|
|
49
|
+
{/if}
|
|
50
|
+
</label>
|
|
51
|
+
<input
|
|
52
|
+
id={inputId}
|
|
53
|
+
type="text"
|
|
54
|
+
class="form-control"
|
|
55
|
+
value={value}
|
|
56
|
+
placeholder={field.placeholder}
|
|
57
|
+
maxlength={field.maxLength}
|
|
58
|
+
pattern={field.pattern}
|
|
59
|
+
disabled={field.disabled}
|
|
60
|
+
on:input={(e) => handleChange(e.currentTarget.value)}
|
|
61
|
+
/>
|
|
62
|
+
{#if field.description}
|
|
63
|
+
<div class="form-text">{field.description}</div>
|
|
64
|
+
{/if}
|
|
65
|
+
{:else if field.type === 'number'}
|
|
66
|
+
<label for={inputId} class="form-label">
|
|
67
|
+
{field.label}
|
|
68
|
+
{#if field.tooltip}
|
|
69
|
+
<span class="text-muted" title={field.tooltip}>ⓘ</span>
|
|
70
|
+
{/if}
|
|
71
|
+
</label>
|
|
72
|
+
<div class="input-group">
|
|
73
|
+
<input
|
|
74
|
+
id={inputId}
|
|
75
|
+
type="number"
|
|
76
|
+
class="form-control"
|
|
77
|
+
value={value}
|
|
78
|
+
min={field.min}
|
|
79
|
+
max={field.max}
|
|
80
|
+
step={field.step}
|
|
81
|
+
disabled={field.disabled}
|
|
82
|
+
on:input={(e) => handleChange(parseFloat(e.currentTarget.value))}
|
|
83
|
+
/>
|
|
84
|
+
{#if field.unit}
|
|
85
|
+
<span class="input-group-text">{field.unit}</span>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
{#if field.description}
|
|
89
|
+
<div class="form-text">{field.description}</div>
|
|
90
|
+
{/if}
|
|
91
|
+
{:else if field.type === 'range'}
|
|
92
|
+
<label for={inputId} class="form-label">
|
|
93
|
+
{field.label}
|
|
94
|
+
{#if field.showValue !== false}
|
|
95
|
+
<span class="badge bg-secondary ms-2">{value}{field.unit || ''}</span>
|
|
96
|
+
{/if}
|
|
97
|
+
{#if field.tooltip}
|
|
98
|
+
<span class="text-muted" title={field.tooltip}>ⓘ</span>
|
|
99
|
+
{/if}
|
|
100
|
+
</label>
|
|
101
|
+
<input
|
|
102
|
+
id={inputId}
|
|
103
|
+
type="range"
|
|
104
|
+
class="form-range"
|
|
105
|
+
value={value}
|
|
106
|
+
min={field.min}
|
|
107
|
+
max={field.max}
|
|
108
|
+
step={field.step || 1}
|
|
109
|
+
disabled={field.disabled}
|
|
110
|
+
on:input={(e) => handleChange(parseFloat(e.currentTarget.value))}
|
|
111
|
+
/>
|
|
112
|
+
{#if field.description}
|
|
113
|
+
<div class="form-text">{field.description}</div>
|
|
114
|
+
{/if}
|
|
115
|
+
{:else if field.type === 'color'}
|
|
116
|
+
<label for={inputId} class="form-label">
|
|
117
|
+
{field.label}
|
|
118
|
+
{#if field.tooltip}
|
|
119
|
+
<span class="text-muted" title={field.tooltip}>ⓘ</span>
|
|
120
|
+
{/if}
|
|
121
|
+
</label>
|
|
122
|
+
<div class="input-group" style="max-width: 200px;">
|
|
123
|
+
<input
|
|
124
|
+
id={inputId}
|
|
125
|
+
type="color"
|
|
126
|
+
class="form-control form-control-color"
|
|
127
|
+
value={value}
|
|
128
|
+
disabled={field.disabled}
|
|
129
|
+
on:input={(e) => handleChange(e.currentTarget.value)}
|
|
130
|
+
/>
|
|
131
|
+
<input
|
|
132
|
+
type="text"
|
|
133
|
+
class="form-control"
|
|
134
|
+
value={value}
|
|
135
|
+
disabled={field.disabled}
|
|
136
|
+
on:input={(e) => handleChange(e.currentTarget.value)}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
{#if field.description}
|
|
140
|
+
<div class="form-text">{field.description}</div>
|
|
141
|
+
{/if}
|
|
142
|
+
{:else if field.type === 'select'}
|
|
143
|
+
<label for={inputId} class="form-label">
|
|
144
|
+
{field.label}
|
|
145
|
+
{#if field.tooltip}
|
|
146
|
+
<span class="text-muted" title={field.tooltip}>ⓘ</span>
|
|
147
|
+
{/if}
|
|
148
|
+
</label>
|
|
149
|
+
<select
|
|
150
|
+
id={inputId}
|
|
151
|
+
class="form-select"
|
|
152
|
+
value={value}
|
|
153
|
+
disabled={field.disabled}
|
|
154
|
+
on:change={(e) => handleChange(e.currentTarget.value)}
|
|
155
|
+
>
|
|
156
|
+
{#each field.options as option}
|
|
157
|
+
<option value={option.value}>
|
|
158
|
+
{option.label}
|
|
159
|
+
</option>
|
|
160
|
+
{/each}
|
|
161
|
+
</select>
|
|
162
|
+
{#if field.description}
|
|
163
|
+
<div class="form-text">{field.description}</div>
|
|
164
|
+
{/if}
|
|
165
|
+
{:else if field.type === 'radio'}
|
|
166
|
+
<label class="form-label">
|
|
167
|
+
{field.label}
|
|
168
|
+
{#if field.tooltip}
|
|
169
|
+
<span class="text-muted" title={field.tooltip}>ⓘ</span>
|
|
170
|
+
{/if}
|
|
171
|
+
</label>
|
|
172
|
+
{#each field.options as option, index}
|
|
173
|
+
<div class="form-check">
|
|
174
|
+
<input
|
|
175
|
+
id={`${inputId}-${index}`}
|
|
176
|
+
type="radio"
|
|
177
|
+
class="form-check-input"
|
|
178
|
+
name={field.id}
|
|
179
|
+
value={option.value}
|
|
180
|
+
checked={value === option.value}
|
|
181
|
+
disabled={field.disabled}
|
|
182
|
+
on:change={() => handleChange(option.value)}
|
|
183
|
+
/>
|
|
184
|
+
<label class="form-check-label" for={`${inputId}-${index}`}>
|
|
185
|
+
{option.label}
|
|
186
|
+
{#if option.description}
|
|
187
|
+
<small class="text-muted d-block">{option.description}</small>
|
|
188
|
+
{/if}
|
|
189
|
+
</label>
|
|
190
|
+
</div>
|
|
191
|
+
{/each}
|
|
192
|
+
{#if field.description}
|
|
193
|
+
<div class="form-text mt-2">{field.description}</div>
|
|
194
|
+
{/if}
|
|
195
|
+
{:else if field.type === 'custom'}
|
|
196
|
+
<label class="form-label">
|
|
197
|
+
{field.label}
|
|
198
|
+
{#if field.tooltip}
|
|
199
|
+
<span class="text-muted" title={field.tooltip}>ⓘ</span>
|
|
200
|
+
{/if}
|
|
201
|
+
</label>
|
|
202
|
+
<svelte:component
|
|
203
|
+
this={field.component}
|
|
204
|
+
{value}
|
|
205
|
+
disabled={field.disabled}
|
|
206
|
+
on:change={(e: CustomEvent) => handleChange(e.detail)}
|
|
207
|
+
{...field.componentProps}
|
|
208
|
+
/>
|
|
209
|
+
{#if field.description}
|
|
210
|
+
<div class="form-text">{field.description}</div>
|
|
211
|
+
{/if}
|
|
212
|
+
{/if}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<style>
|
|
216
|
+
.field-wrapper {
|
|
217
|
+
animation: fadeIn 0.2s ease-in;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@keyframes fadeIn {
|
|
221
|
+
from {
|
|
222
|
+
opacity: 0;
|
|
223
|
+
transform: translateY(-5px);
|
|
224
|
+
}
|
|
225
|
+
to {
|
|
226
|
+
opacity: 1;
|
|
227
|
+
transform: translateY(0);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.text-muted {
|
|
232
|
+
cursor: help;
|
|
233
|
+
}
|
|
234
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FieldDefinition } from './types';
|
|
2
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
3
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
4
|
+
$$bindings?: Bindings;
|
|
5
|
+
} & Exports;
|
|
6
|
+
(internal: unknown, props: Props & {
|
|
7
|
+
$$events?: Events;
|
|
8
|
+
$$slots?: Slots;
|
|
9
|
+
}): Exports & {
|
|
10
|
+
$set?: any;
|
|
11
|
+
$on?: any;
|
|
12
|
+
};
|
|
13
|
+
z_$$bindings?: Bindings;
|
|
14
|
+
}
|
|
15
|
+
declare const FieldRenderer: $$__sveltets_2_IsomorphicComponent<{
|
|
16
|
+
/**
|
|
17
|
+
* Generic Field Renderer
|
|
18
|
+
*
|
|
19
|
+
* Renders a form field based on its type definition.
|
|
20
|
+
* Uses Bootstrap form components for styling.
|
|
21
|
+
* Works with hierarchical store structure.
|
|
22
|
+
*/ field: FieldDefinition;
|
|
23
|
+
segmentId: string;
|
|
24
|
+
store: any;
|
|
25
|
+
value: any;
|
|
26
|
+
}, {
|
|
27
|
+
[evt: string]: CustomEvent<any>;
|
|
28
|
+
}, {}, {}, string>;
|
|
29
|
+
type FieldRenderer = InstanceType<typeof FieldRenderer>;
|
|
30
|
+
export default FieldRenderer;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import type { SettingsSchema } from './types';
|
|
4
|
+
import { createSettingsStore } from './store';
|
|
5
|
+
import FieldRenderer from './FieldRenderer.svelte';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Settings Component
|
|
9
|
+
*
|
|
10
|
+
* Reusable settings panel that accepts a schema and renders
|
|
11
|
+
* a data-driven UI with automatic persistence.
|
|
12
|
+
* Provides hierarchical access: $settings.segment.field
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```svelte
|
|
16
|
+
* const settings = createSettingsStore(mySchema, 'myApp');
|
|
17
|
+
* <Settings schema={mySchema} {settings} />
|
|
18
|
+
*
|
|
19
|
+
* // Access settings in parent:
|
|
20
|
+
* $settings.appearance.theme
|
|
21
|
+
* $settings.editor.fontSize
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The settings schema defining all segments and fields */
|
|
26
|
+
export let schema: SettingsSchema;
|
|
27
|
+
|
|
28
|
+
/** The settings store instance (create externally for access in parent) */
|
|
29
|
+
export let settings: ReturnType<typeof createSettingsStore>;
|
|
30
|
+
|
|
31
|
+
/** Optional: Callback when settings change */
|
|
32
|
+
export let onChange: ((values: any) => void) | undefined = undefined;
|
|
33
|
+
|
|
34
|
+
// Current values (hierarchical structure: { segment: { field: value } })
|
|
35
|
+
let currentValues: Record<string, Record<string, any>> = {};
|
|
36
|
+
|
|
37
|
+
// Track collapsed state of segments
|
|
38
|
+
let collapsedSegments = new Map<string, boolean>();
|
|
39
|
+
|
|
40
|
+
onMount(() => {
|
|
41
|
+
// Initialize collapsed states from schema
|
|
42
|
+
schema.segments.forEach((segment) => {
|
|
43
|
+
collapsedSegments.set(segment.id, segment.collapsed || false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Subscribe to store changes
|
|
47
|
+
const unsubscribe = settings.subscribe((values) => {
|
|
48
|
+
currentValues = values;
|
|
49
|
+
if (onChange) {
|
|
50
|
+
onChange(values);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return unsubscribe;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function toggleSegment(segmentId: string) {
|
|
58
|
+
const current = collapsedSegments.get(segmentId) || false;
|
|
59
|
+
collapsedSegments.set(segmentId, !current);
|
|
60
|
+
collapsedSegments = collapsedSegments; // Trigger reactivity
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resetSegmentHandler(segmentId: string) {
|
|
64
|
+
if (confirm(`Reset "${segmentId}" settings to defaults?`)) {
|
|
65
|
+
settings.resetSegment(segmentId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resetAll() {
|
|
70
|
+
if (confirm('Reset all settings to defaults?')) {
|
|
71
|
+
settings.resetAll();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check if a field should be visible based on visibleIf condition
|
|
76
|
+
function isFieldVisible(field: any, segmentId: string): boolean {
|
|
77
|
+
if (!field.visibleIf) return true;
|
|
78
|
+
// Pass the entire hierarchical structure to visibleIf
|
|
79
|
+
return field.visibleIf(currentValues);
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<div class="settings-container">
|
|
84
|
+
<!-- Header -->
|
|
85
|
+
<div class="settings-header mb-4">
|
|
86
|
+
<div class="d-flex justify-content-between align-items-center">
|
|
87
|
+
<div>
|
|
88
|
+
<h2 class="mb-1">Settings</h2>
|
|
89
|
+
{#if schema.version}
|
|
90
|
+
<small class="text-muted">Schema v{schema.version}</small>
|
|
91
|
+
{/if}
|
|
92
|
+
</div>
|
|
93
|
+
<button class="btn btn-outline-secondary btn-sm" on:click={resetAll}>
|
|
94
|
+
Reset All
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<!-- Settings Cards -->
|
|
100
|
+
<div class="settings-grid">
|
|
101
|
+
{#each schema.segments as segment (segment.id)}
|
|
102
|
+
<div class="card settings-card">
|
|
103
|
+
<!-- Card Header -->
|
|
104
|
+
<div class="card-header">
|
|
105
|
+
<div class="d-flex justify-content-between align-items-center">
|
|
106
|
+
<div class="d-flex align-items-center gap-2">
|
|
107
|
+
{#if segment.icon}
|
|
108
|
+
<span class="segment-icon">{segment.icon}</span>
|
|
109
|
+
{/if}
|
|
110
|
+
<div>
|
|
111
|
+
<h5 class="mb-0">{segment.title}</h5>
|
|
112
|
+
{#if segment.description}
|
|
113
|
+
<small class="text-muted">{segment.description}</small>
|
|
114
|
+
{/if}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="d-flex gap-2">
|
|
118
|
+
<button
|
|
119
|
+
class="btn btn-sm btn-link text-decoration-none p-0"
|
|
120
|
+
on:click={() => resetSegmentHandler(segment.id)}
|
|
121
|
+
title="Reset to defaults"
|
|
122
|
+
>
|
|
123
|
+
↺
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
class="btn btn-sm btn-link text-decoration-none p-0"
|
|
127
|
+
on:click={() => toggleSegment(segment.id)}
|
|
128
|
+
title={collapsedSegments.get(segment.id) ? 'Expand' : 'Collapse'}
|
|
129
|
+
>
|
|
130
|
+
{collapsedSegments.get(segment.id) ? '▶' : '▼'}
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<!-- Card Body -->
|
|
137
|
+
{#if !collapsedSegments.get(segment.id)}
|
|
138
|
+
<div class="card-body">
|
|
139
|
+
{#each segment.fields as field (field.id)}
|
|
140
|
+
{#if isFieldVisible(field, segment.id)}
|
|
141
|
+
<FieldRenderer
|
|
142
|
+
{field}
|
|
143
|
+
segmentId={segment.id}
|
|
144
|
+
store={settings}
|
|
145
|
+
value={currentValues[segment.id]?.[field.id]}
|
|
146
|
+
/>
|
|
147
|
+
{/if}
|
|
148
|
+
{/each}
|
|
149
|
+
</div>
|
|
150
|
+
{/if}
|
|
151
|
+
</div>
|
|
152
|
+
{/each}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<style>
|
|
157
|
+
.settings-container {
|
|
158
|
+
max-width: 1200px;
|
|
159
|
+
margin: 0 auto;
|
|
160
|
+
padding: 1rem;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.settings-grid {
|
|
164
|
+
display: grid;
|
|
165
|
+
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
|
166
|
+
gap: 1.5rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.settings-card {
|
|
170
|
+
border: 1px solid var(--bs-border-color);
|
|
171
|
+
border-radius: 0.5rem;
|
|
172
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
173
|
+
transition: box-shadow 0.2s ease;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.settings-card:hover {
|
|
177
|
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.card-header {
|
|
181
|
+
background-color: var(--bs-light);
|
|
182
|
+
border-bottom: 1px solid var(--bs-border-color);
|
|
183
|
+
padding: 1rem;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.segment-icon {
|
|
187
|
+
font-size: 1.5rem;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.card-body {
|
|
191
|
+
padding: 1.5rem;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@media (max-width: 768px) {
|
|
195
|
+
.settings-grid {
|
|
196
|
+
grid-template-columns: 1fr;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SettingsSchema } from './types';
|
|
2
|
+
import { createSettingsStore } from './store';
|
|
3
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
4
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
5
|
+
$$bindings?: Bindings;
|
|
6
|
+
} & Exports;
|
|
7
|
+
(internal: unknown, props: Props & {
|
|
8
|
+
$$events?: Events;
|
|
9
|
+
$$slots?: Slots;
|
|
10
|
+
}): Exports & {
|
|
11
|
+
$set?: any;
|
|
12
|
+
$on?: any;
|
|
13
|
+
};
|
|
14
|
+
z_$$bindings?: Bindings;
|
|
15
|
+
}
|
|
16
|
+
declare const Settings: $$__sveltets_2_IsomorphicComponent<{
|
|
17
|
+
/** The settings schema defining all segments and fields */ schema: SettingsSchema;
|
|
18
|
+
/** The settings store instance (create externally for access in parent) */ settings: ReturnType<typeof createSettingsStore>;
|
|
19
|
+
/** Optional: Callback when settings change */ onChange?: ((values: any) => void) | undefined;
|
|
20
|
+
}, {
|
|
21
|
+
[evt: string]: CustomEvent<any>;
|
|
22
|
+
}, {}, {}, string>;
|
|
23
|
+
type Settings = InstanceType<typeof Settings>;
|
|
24
|
+
export default Settings;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings System - Public API
|
|
3
|
+
*
|
|
4
|
+
* A data-driven, reusable settings component for SvelteKit applications.
|
|
5
|
+
* Supports hierarchical access with TypeScript autocomplete.
|
|
6
|
+
*/
|
|
7
|
+
export { default as Settings } from './Settings.svelte';
|
|
8
|
+
export { createSettingsStore, createFieldMap, type InferSettingsType } from './store';
|
|
9
|
+
export type { SettingsSchema, SettingsStore, SettingsValues, SegmentDefinition, FieldDefinition, BaseFieldDefinition, BooleanFieldDefinition, TextFieldDefinition, NumberFieldDefinition, RangeFieldDefinition, ColorFieldDefinition, SelectFieldDefinition, RadioFieldDefinition, CustomFieldDefinition } from './types';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings System - Public API
|
|
3
|
+
*
|
|
4
|
+
* A data-driven, reusable settings component for SvelteKit applications.
|
|
5
|
+
* Supports hierarchical access with TypeScript autocomplete.
|
|
6
|
+
*/
|
|
7
|
+
export { default as Settings } from './Settings.svelte';
|
|
8
|
+
export { createSettingsStore, createFieldMap } from './store';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Store Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates reactive stores for settings management with automatic persistence.
|
|
5
|
+
* Each consuming app creates its own store instance with its schema and namespace.
|
|
6
|
+
*
|
|
7
|
+
* Supports hierarchical access with TypeScript autocomplete:
|
|
8
|
+
* $settings.appearance.theme
|
|
9
|
+
* $settings.editor.fontSize
|
|
10
|
+
*/
|
|
11
|
+
import type { SettingsSchema } from './types';
|
|
12
|
+
/**
|
|
13
|
+
* Type helper to infer settings structure from schema
|
|
14
|
+
* Converts schema into nested type: { segment: { field: value } }
|
|
15
|
+
*/
|
|
16
|
+
export type InferSettingsType<T extends SettingsSchema> = {
|
|
17
|
+
[K in T['segments'][number]['id']]: {
|
|
18
|
+
[F in Extract<T['segments'][number], {
|
|
19
|
+
id: K;
|
|
20
|
+
}>['fields'][number] as F['id']]: F['defaultValue'];
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Create a settings store instance with hierarchical access
|
|
25
|
+
*
|
|
26
|
+
* @param schema - The settings schema defining all fields
|
|
27
|
+
* @param namespace - Unique namespace for localStorage isolation (e.g., 'myApp')
|
|
28
|
+
* @returns A reactive store with hierarchical access: $settings.segment.field
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* const settings = createSettingsStore(mySchema, 'myApp');
|
|
33
|
+
*
|
|
34
|
+
* // In Svelte components with full autocomplete:
|
|
35
|
+
* $settings.appearance.theme // ✅ TypeScript autocomplete works!
|
|
36
|
+
* $settings.editor.fontSize // ✅ Nested access
|
|
37
|
+
*
|
|
38
|
+
* // Update values:
|
|
39
|
+
* settings.update('appearance', 'theme', 'dark')
|
|
40
|
+
* settings.updateSegment('appearance', { theme: 'dark', ... })
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare function createSettingsStore<T extends SettingsSchema>(schema: T, namespace: string): {
|
|
44
|
+
subscribe: (this: void, run: import("svelte/store").Subscriber<InferSettingsType<T>>, invalidate?: () => void) => import("svelte/store").Unsubscriber;
|
|
45
|
+
update: (segmentId: string, fieldId: string, value: any) => void;
|
|
46
|
+
updateSegment: (segmentId: string, values: Record<string, any>) => void;
|
|
47
|
+
reset: (segmentId: string, fieldId: string) => void;
|
|
48
|
+
resetSegment: (segmentId: string) => void;
|
|
49
|
+
resetAll: () => void;
|
|
50
|
+
getValues: () => InferSettingsType<T>;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Helper to create a flat map of field ID -> field definition
|
|
54
|
+
* Useful for quick lookups when rendering or validating
|
|
55
|
+
*/
|
|
56
|
+
export declare function createFieldMap(schema: SettingsSchema): Map<any, any>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Store Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates reactive stores for settings management with automatic persistence.
|
|
5
|
+
* Each consuming app creates its own store instance with its schema and namespace.
|
|
6
|
+
*
|
|
7
|
+
* Supports hierarchical access with TypeScript autocomplete:
|
|
8
|
+
* $settings.appearance.theme
|
|
9
|
+
* $settings.editor.fontSize
|
|
10
|
+
*/
|
|
11
|
+
import { writable, get } from 'svelte/store';
|
|
12
|
+
/**
|
|
13
|
+
* Extract default values from schema in hierarchical structure
|
|
14
|
+
* Returns: { segment: { field: value } }
|
|
15
|
+
*/
|
|
16
|
+
function extractDefaults(schema) {
|
|
17
|
+
const defaults = {};
|
|
18
|
+
for (const segment of schema.segments) {
|
|
19
|
+
defaults[segment.id] = {};
|
|
20
|
+
for (const field of segment.fields) {
|
|
21
|
+
defaults[segment.id][field.id] = field.defaultValue;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return defaults;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Load saved settings from localStorage (SSR-safe)
|
|
28
|
+
* Supports hierarchical structure
|
|
29
|
+
*/
|
|
30
|
+
function loadFromStorage(namespace, defaults) {
|
|
31
|
+
if (typeof window === 'undefined')
|
|
32
|
+
return defaults;
|
|
33
|
+
try {
|
|
34
|
+
const storageKey = `${namespace}:settings`;
|
|
35
|
+
const saved = localStorage.getItem(storageKey);
|
|
36
|
+
if (saved) {
|
|
37
|
+
const parsed = JSON.parse(saved);
|
|
38
|
+
// Deep merge with defaults to ensure new fields appear
|
|
39
|
+
const merged = {};
|
|
40
|
+
for (const segmentId in defaults) {
|
|
41
|
+
merged[segmentId] = {
|
|
42
|
+
...defaults[segmentId],
|
|
43
|
+
...(parsed[segmentId] || {})
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return merged;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
console.error('Failed to load settings from localStorage:', error);
|
|
51
|
+
}
|
|
52
|
+
return defaults;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Save settings to localStorage (SSR-safe)
|
|
56
|
+
*/
|
|
57
|
+
function saveToStorage(namespace, values) {
|
|
58
|
+
if (typeof window === 'undefined')
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
const storageKey = `${namespace}:settings`;
|
|
62
|
+
localStorage.setItem(storageKey, JSON.stringify(values));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.error('Failed to save settings to localStorage:', error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Create a settings store instance with hierarchical access
|
|
70
|
+
*
|
|
71
|
+
* @param schema - The settings schema defining all fields
|
|
72
|
+
* @param namespace - Unique namespace for localStorage isolation (e.g., 'myApp')
|
|
73
|
+
* @returns A reactive store with hierarchical access: $settings.segment.field
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* const settings = createSettingsStore(mySchema, 'myApp');
|
|
78
|
+
*
|
|
79
|
+
* // In Svelte components with full autocomplete:
|
|
80
|
+
* $settings.appearance.theme // ✅ TypeScript autocomplete works!
|
|
81
|
+
* $settings.editor.fontSize // ✅ Nested access
|
|
82
|
+
*
|
|
83
|
+
* // Update values:
|
|
84
|
+
* settings.update('appearance', 'theme', 'dark')
|
|
85
|
+
* settings.updateSegment('appearance', { theme: 'dark', ... })
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export function createSettingsStore(schema, namespace) {
|
|
89
|
+
// Extract defaults from schema (hierarchical structure)
|
|
90
|
+
const defaults = extractDefaults(schema);
|
|
91
|
+
// Load initial values (defaults + any saved values)
|
|
92
|
+
const initialValues = loadFromStorage(namespace, defaults);
|
|
93
|
+
const { subscribe, set: setStore, update: updateStore } = writable(initialValues);
|
|
94
|
+
// Subscribe to changes and persist to localStorage
|
|
95
|
+
subscribe((values) => {
|
|
96
|
+
saveToStorage(namespace, values);
|
|
97
|
+
});
|
|
98
|
+
/**
|
|
99
|
+
* Update a single field value
|
|
100
|
+
* @param segmentId - The segment ID (e.g., 'appearance')
|
|
101
|
+
* @param fieldId - The field ID (e.g., 'theme')
|
|
102
|
+
* @param value - The new value
|
|
103
|
+
*/
|
|
104
|
+
function update(segmentId, fieldId, value) {
|
|
105
|
+
updateStore((current) => ({
|
|
106
|
+
...current,
|
|
107
|
+
[segmentId]: {
|
|
108
|
+
...current[segmentId],
|
|
109
|
+
[fieldId]: value
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Update multiple fields in a segment at once
|
|
115
|
+
* @param segmentId - The segment ID
|
|
116
|
+
* @param values - Partial segment values
|
|
117
|
+
*/
|
|
118
|
+
function updateSegment(segmentId, values) {
|
|
119
|
+
updateStore((current) => ({
|
|
120
|
+
...current,
|
|
121
|
+
[segmentId]: {
|
|
122
|
+
...current[segmentId],
|
|
123
|
+
...values
|
|
124
|
+
}
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Reset a single field to its default value
|
|
129
|
+
* @param segmentId - The segment ID
|
|
130
|
+
* @param fieldId - The field ID
|
|
131
|
+
*/
|
|
132
|
+
function reset(segmentId, fieldId) {
|
|
133
|
+
const defaultValue = defaults[segmentId]?.[fieldId];
|
|
134
|
+
if (defaultValue !== undefined) {
|
|
135
|
+
update(segmentId, fieldId, defaultValue);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Reset an entire segment to defaults
|
|
140
|
+
* @param segmentId - The segment ID
|
|
141
|
+
*/
|
|
142
|
+
function resetSegment(segmentId) {
|
|
143
|
+
if (defaults[segmentId]) {
|
|
144
|
+
updateStore((current) => ({
|
|
145
|
+
...current,
|
|
146
|
+
[segmentId]: { ...defaults[segmentId] }
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Reset all settings to their default values
|
|
152
|
+
*/
|
|
153
|
+
function resetAll() {
|
|
154
|
+
setStore({ ...defaults });
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get a snapshot of current values (non-reactive)
|
|
158
|
+
*/
|
|
159
|
+
function getValues() {
|
|
160
|
+
return get({ subscribe });
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
subscribe,
|
|
164
|
+
update,
|
|
165
|
+
updateSegment,
|
|
166
|
+
reset,
|
|
167
|
+
resetSegment,
|
|
168
|
+
resetAll,
|
|
169
|
+
getValues
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Helper to create a flat map of field ID -> field definition
|
|
174
|
+
* Useful for quick lookups when rendering or validating
|
|
175
|
+
*/
|
|
176
|
+
export function createFieldMap(schema) {
|
|
177
|
+
const map = new Map();
|
|
178
|
+
for (const segment of schema.segments) {
|
|
179
|
+
for (const field of segment.fields) {
|
|
180
|
+
map.set(field.id, field);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return map;
|
|
184
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings System Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* Defines the schema structure for the data-driven settings component.
|
|
5
|
+
* Schemas are passed in from consuming applications.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Base field definition shared by all field types
|
|
9
|
+
*/
|
|
10
|
+
export interface BaseFieldDefinition {
|
|
11
|
+
/** Unique identifier for the field */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Display label for the field */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Optional description/help text */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Optional tooltip shown on hover */
|
|
18
|
+
tooltip?: string;
|
|
19
|
+
/** Whether the field is disabled */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
/** Condition for field visibility (function that receives all settings) */
|
|
22
|
+
visibleIf?: (settings: Record<string, any>) => boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Boolean toggle field (checkbox/switch)
|
|
26
|
+
*/
|
|
27
|
+
export interface BooleanFieldDefinition extends BaseFieldDefinition {
|
|
28
|
+
type: 'boolean';
|
|
29
|
+
defaultValue: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Text input field
|
|
33
|
+
*/
|
|
34
|
+
export interface TextFieldDefinition extends BaseFieldDefinition {
|
|
35
|
+
type: 'text';
|
|
36
|
+
defaultValue: string;
|
|
37
|
+
/** Input placeholder text */
|
|
38
|
+
placeholder?: string;
|
|
39
|
+
/** Maximum character length */
|
|
40
|
+
maxLength?: number;
|
|
41
|
+
/** Validation pattern (regex) */
|
|
42
|
+
pattern?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Number input field
|
|
46
|
+
*/
|
|
47
|
+
export interface NumberFieldDefinition extends BaseFieldDefinition {
|
|
48
|
+
type: 'number';
|
|
49
|
+
defaultValue: number;
|
|
50
|
+
min?: number;
|
|
51
|
+
max?: number;
|
|
52
|
+
step?: number;
|
|
53
|
+
/** Unit label (e.g., "px", "ms", "%") */
|
|
54
|
+
unit?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Range slider field
|
|
58
|
+
*/
|
|
59
|
+
export interface RangeFieldDefinition extends BaseFieldDefinition {
|
|
60
|
+
type: 'range';
|
|
61
|
+
defaultValue: number;
|
|
62
|
+
min: number;
|
|
63
|
+
max: number;
|
|
64
|
+
step?: number;
|
|
65
|
+
/** Whether to show the current value */
|
|
66
|
+
showValue?: boolean;
|
|
67
|
+
/** Unit label (e.g., "px", "ms", "%") */
|
|
68
|
+
unit?: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Color picker field
|
|
72
|
+
*/
|
|
73
|
+
export interface ColorFieldDefinition extends BaseFieldDefinition {
|
|
74
|
+
type: 'color';
|
|
75
|
+
defaultValue: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Select dropdown field
|
|
79
|
+
*/
|
|
80
|
+
export interface SelectFieldDefinition extends BaseFieldDefinition {
|
|
81
|
+
type: 'select';
|
|
82
|
+
defaultValue: string | number;
|
|
83
|
+
options: Array<{
|
|
84
|
+
value: string | number;
|
|
85
|
+
label: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
}>;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Radio button group field
|
|
91
|
+
*/
|
|
92
|
+
export interface RadioFieldDefinition extends BaseFieldDefinition {
|
|
93
|
+
type: 'radio';
|
|
94
|
+
defaultValue: string | number;
|
|
95
|
+
options: Array<{
|
|
96
|
+
value: string | number;
|
|
97
|
+
label: string;
|
|
98
|
+
description?: string;
|
|
99
|
+
}>;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Custom field that renders a provided Svelte component
|
|
103
|
+
*/
|
|
104
|
+
export interface CustomFieldDefinition extends BaseFieldDefinition {
|
|
105
|
+
type: 'custom';
|
|
106
|
+
defaultValue: any;
|
|
107
|
+
/** The Svelte component to render */
|
|
108
|
+
component: any;
|
|
109
|
+
/** Additional props to pass to the custom component */
|
|
110
|
+
componentProps?: Record<string, any>;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Union type of all field definitions
|
|
114
|
+
*/
|
|
115
|
+
export type FieldDefinition = BooleanFieldDefinition | TextFieldDefinition | NumberFieldDefinition | RangeFieldDefinition | ColorFieldDefinition | SelectFieldDefinition | RadioFieldDefinition | CustomFieldDefinition;
|
|
116
|
+
/**
|
|
117
|
+
* A segment groups related settings fields under a card/section
|
|
118
|
+
*/
|
|
119
|
+
export interface SegmentDefinition {
|
|
120
|
+
/** Unique identifier for the segment */
|
|
121
|
+
id: string;
|
|
122
|
+
/** Display title for the card/section */
|
|
123
|
+
title: string;
|
|
124
|
+
/** Optional description for the segment */
|
|
125
|
+
description?: string;
|
|
126
|
+
/** Optional icon (emoji or icon class) */
|
|
127
|
+
icon?: string;
|
|
128
|
+
/** Whether the segment starts collapsed */
|
|
129
|
+
collapsed?: boolean;
|
|
130
|
+
/** Array of field definitions in this segment */
|
|
131
|
+
fields: FieldDefinition[];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Complete settings schema passed to the Settings component
|
|
135
|
+
*/
|
|
136
|
+
export interface SettingsSchema {
|
|
137
|
+
/** Array of setting segments (cards) */
|
|
138
|
+
segments: SegmentDefinition[];
|
|
139
|
+
/** Optional schema version for migration support */
|
|
140
|
+
version?: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The live settings values (flat key-value pairs)
|
|
144
|
+
*/
|
|
145
|
+
export type SettingsValues = Record<string, any>;
|
|
146
|
+
/**
|
|
147
|
+
* Store interface for settings
|
|
148
|
+
*/
|
|
149
|
+
export interface SettingsStore {
|
|
150
|
+
/** Subscribe to settings changes */
|
|
151
|
+
subscribe: (callback: (value: SettingsValues) => void) => () => void;
|
|
152
|
+
/** Update a single setting value */
|
|
153
|
+
set: (key: string, value: any) => void;
|
|
154
|
+
/** Update multiple settings at once */
|
|
155
|
+
update: (values: Partial<SettingsValues>) => void;
|
|
156
|
+
/** Reset a single setting to default */
|
|
157
|
+
reset: (key: string) => void;
|
|
158
|
+
/** Reset all settings to defaults */
|
|
159
|
+
resetAll: () => void;
|
|
160
|
+
/** Get current values snapshot */
|
|
161
|
+
getValues: () => SettingsValues;
|
|
162
|
+
}
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
|
@@ -6,5 +6,7 @@ export * from './Desktop/index.js';
|
|
|
6
6
|
export * from './Charts/index.js';
|
|
7
7
|
// TreeView generic hierarchical component
|
|
8
8
|
export * from './TreeView/index.js';
|
|
9
|
+
// Settings system - data-driven settings component
|
|
10
|
+
export * from './Settings/index.js';
|
|
9
11
|
// Logger utility for debugging and monitoring
|
|
10
12
|
export * from './logger/index.js';
|