@webmcp-auto-ui/ui 2.5.6 → 2.5.8
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/package.json +1 -1
- package/src/agent/TokenBubble.svelte +3 -2
- package/src/index.ts +3 -1
- package/src/widgets/ExportModal.svelte +117 -0
- package/src/widgets/WidgetRenderer.svelte +26 -1
- package/src/widgets/export-widget.ts +81 -0
- package/src/widgets/rich/Cards.svelte +1 -1
- package/src/widgets/rich/Chart.svelte +34 -3
- package/src/widgets/rich/DataTable.svelte +2 -1
- package/src/widgets/rich/Gallery.svelte +14 -1
- package/src/widgets/rich/GridData.svelte +3 -2
- package/src/widgets/rich/Hemicycle.svelte +4 -2
- package/src/widgets/rich/Timeline.svelte +1 -1
- package/src/widgets/simple/ListBlock.svelte +2 -1
- package/src/widgets/simple/TextBlock.svelte +117 -1
package/package.json
CHANGED
|
@@ -80,13 +80,14 @@
|
|
|
80
80
|
{#if !metrics.isWasm && metrics.totalCacheReadTokens > 0}
|
|
81
81
|
<span class="cached-active">Cached {fmt(metrics.totalCacheReadTokens)}</span>
|
|
82
82
|
{/if}
|
|
83
|
+
<span class="sep">|</span>
|
|
83
84
|
{#if metrics.isWasm}
|
|
84
|
-
<span>Req {metrics.requestsPerMin}/min</span>
|
|
85
85
|
<span>Out {fmt(metrics.outputTokensPerMin)}/min</span>
|
|
86
|
+
<span>Req {metrics.requestsPerMin}/min</span>
|
|
86
87
|
{:else}
|
|
87
|
-
<span class:warn={metrics.requestsPerMin > LIMITS.reqPerMin * 0.8}>Req {metrics.requestsPerMin}/min</span> <span class="limit">({fmt(LIMITS.reqPerMin)} max)</span>
|
|
88
88
|
<span class:warn={metrics.inputTokensPerMin > LIMITS.inPerMin * 0.8}>In {fmt(metrics.inputTokensPerMin)}/min</span> <span class="limit">({fmt(LIMITS.inPerMin)} max)</span>
|
|
89
89
|
<span class:warn={metrics.outputTokensPerMin > LIMITS.outPerMin * 0.8}>Out {fmt(metrics.outputTokensPerMin)}/min</span> <span class="limit">({fmt(LIMITS.outPerMin)} max)</span>
|
|
90
|
+
<span class:warn={metrics.requestsPerMin > LIMITS.reqPerMin * 0.8}>Req {metrics.requestsPerMin}/min</span> <span class="limit">({fmt(LIMITS.reqPerMin)} max)</span>
|
|
90
91
|
{/if}
|
|
91
92
|
</div>
|
|
92
93
|
{/if}
|
package/src/index.ts
CHANGED
|
@@ -47,7 +47,9 @@ export { default as Carousel } from './widgets/rich/Carousel.svelte';
|
|
|
47
47
|
export { default as SafeImage } from './widgets/SafeImage.svelte';
|
|
48
48
|
|
|
49
49
|
// Widget export utility
|
|
50
|
-
export { exportWidget } from './widgets/export-widget.js';
|
|
50
|
+
export { exportWidget, getExportFormats, exportWidgetAs } from './widgets/export-widget.js';
|
|
51
|
+
export type { ExportFormat } from './widgets/export-widget.js';
|
|
52
|
+
export { default as ExportModal } from './widgets/ExportModal.svelte';
|
|
51
53
|
|
|
52
54
|
// Dispatcher
|
|
53
55
|
export { default as BlockRenderer } from './widgets/BlockRenderer.svelte';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getExportFormats, exportWidgetAs, type ExportFormat } from './export-widget.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
open: boolean;
|
|
6
|
+
type: string;
|
|
7
|
+
data: Record<string, unknown>;
|
|
8
|
+
containerEl?: HTMLElement;
|
|
9
|
+
onclose?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { open = $bindable(false), type, data, containerEl, onclose }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const formats = $derived(open ? getExportFormats(type, containerEl) : []);
|
|
15
|
+
|
|
16
|
+
function doExport(fmt: ExportFormat) {
|
|
17
|
+
exportWidgetAs(fmt.id, type, data, containerEl);
|
|
18
|
+
open = false;
|
|
19
|
+
onclose?.();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function handleBackdrop() {
|
|
23
|
+
open = false;
|
|
24
|
+
onclose?.();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
28
|
+
if (e.key === 'Escape') {
|
|
29
|
+
open = false;
|
|
30
|
+
onclose?.();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
{#if open}
|
|
36
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
37
|
+
<div class="export-modal-backdrop" onclick={handleBackdrop} onkeydown={handleKeydown}>
|
|
38
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
39
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
40
|
+
<div class="export-modal" onclick={(e) => e.stopPropagation()}>
|
|
41
|
+
<div class="export-modal-title">Exporter — {type}</div>
|
|
42
|
+
<div class="export-modal-list">
|
|
43
|
+
{#each formats as fmt}
|
|
44
|
+
<button class="export-modal-btn" onclick={() => doExport(fmt)}>
|
|
45
|
+
<span class="export-modal-icon">{fmt.icon}</span>
|
|
46
|
+
<span class="export-modal-label">{fmt.label}</span>
|
|
47
|
+
</button>
|
|
48
|
+
{/each}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
{/if}
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
.export-modal-backdrop {
|
|
56
|
+
position: fixed;
|
|
57
|
+
inset: 0;
|
|
58
|
+
z-index: 9999;
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
background: rgba(0, 0, 0, 0.35);
|
|
63
|
+
}
|
|
64
|
+
.export-modal {
|
|
65
|
+
background: var(--color-surface, #1a1a2e);
|
|
66
|
+
border: 1px solid var(--color-border, #333);
|
|
67
|
+
border-radius: 8px;
|
|
68
|
+
padding: 12px;
|
|
69
|
+
max-width: 250px;
|
|
70
|
+
width: 100%;
|
|
71
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
72
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
73
|
+
}
|
|
74
|
+
.export-modal-title {
|
|
75
|
+
font-size: 11px;
|
|
76
|
+
color: var(--color-text2, #888);
|
|
77
|
+
margin-bottom: 8px;
|
|
78
|
+
padding-bottom: 6px;
|
|
79
|
+
border-bottom: 1px solid var(--color-border, #333);
|
|
80
|
+
text-transform: uppercase;
|
|
81
|
+
letter-spacing: 0.05em;
|
|
82
|
+
}
|
|
83
|
+
.export-modal-list {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
gap: 4px;
|
|
87
|
+
}
|
|
88
|
+
.export-modal-btn {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 8px;
|
|
92
|
+
width: 100%;
|
|
93
|
+
padding: 8px 10px;
|
|
94
|
+
border: 1px solid transparent;
|
|
95
|
+
border-radius: 6px;
|
|
96
|
+
background: transparent;
|
|
97
|
+
color: var(--color-text1, #eee);
|
|
98
|
+
font-family: inherit;
|
|
99
|
+
font-size: 13px;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
transition: background 0.15s, border-color 0.15s;
|
|
102
|
+
}
|
|
103
|
+
.export-modal-btn:hover {
|
|
104
|
+
background: var(--color-surface2, #2a2a3e);
|
|
105
|
+
border-color: var(--color-border, #444);
|
|
106
|
+
}
|
|
107
|
+
.export-modal-icon {
|
|
108
|
+
font-size: 16px;
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
width: 20px;
|
|
111
|
+
text-align: center;
|
|
112
|
+
}
|
|
113
|
+
.export-modal-label {
|
|
114
|
+
flex: 1;
|
|
115
|
+
text-align: left;
|
|
116
|
+
}
|
|
117
|
+
</style>
|
|
@@ -168,6 +168,10 @@
|
|
|
168
168
|
const mc = getMC();
|
|
169
169
|
if (!mc) return;
|
|
170
170
|
|
|
171
|
+
// Unregister first in case widget was re-mounted with same busId
|
|
172
|
+
const toolNames = [`widget_${busId}_get`, `widget_${busId}_update`, `widget_${busId}_remove`];
|
|
173
|
+
toolNames.forEach(n => { try { mc.unregisterTool(n); } catch { /* ok */ } });
|
|
174
|
+
|
|
171
175
|
mc.registerTool({
|
|
172
176
|
name: `widget_${busId}_get`,
|
|
173
177
|
description: `Get current data of ${type} widget (id: ${busId}).`,
|
|
@@ -211,7 +215,7 @@
|
|
|
211
215
|
</script>
|
|
212
216
|
|
|
213
217
|
{#if isVanillaRenderer}
|
|
214
|
-
<div bind:this={vanillaContainer} class="w-full h-full overflow-
|
|
218
|
+
<div bind:this={vanillaContainer} class="vanilla-container w-full h-full overflow-auto p-2"></div>
|
|
215
219
|
{:else if customRenderer}
|
|
216
220
|
<svelte:component this={customRenderer as Component<any>} {data} {id} />
|
|
217
221
|
{:else if nativeEntry}
|
|
@@ -219,3 +223,24 @@
|
|
|
219
223
|
{:else}
|
|
220
224
|
<div class="p-3 font-mono text-xs text-text2">[{type}]</div>
|
|
221
225
|
{/if}
|
|
226
|
+
|
|
227
|
+
<style>
|
|
228
|
+
.vanilla-container :global(svg) {
|
|
229
|
+
width: 100%;
|
|
230
|
+
height: auto;
|
|
231
|
+
max-height: 100%;
|
|
232
|
+
display: block;
|
|
233
|
+
}
|
|
234
|
+
.vanilla-container :global(canvas) {
|
|
235
|
+
width: 100%;
|
|
236
|
+
height: auto;
|
|
237
|
+
max-height: 100%;
|
|
238
|
+
display: block;
|
|
239
|
+
}
|
|
240
|
+
.vanilla-container :global(img) {
|
|
241
|
+
width: 100%;
|
|
242
|
+
height: auto;
|
|
243
|
+
max-height: 100%;
|
|
244
|
+
object-fit: contain;
|
|
245
|
+
}
|
|
246
|
+
</style>
|
|
@@ -191,10 +191,91 @@ function exportImageUrls(type: string, data: Record<string, unknown>): void {
|
|
|
191
191
|
downloadFile(content, filename(type, 'json'), 'application/json');
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// ── Export Format types ───────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
export interface ExportFormat {
|
|
197
|
+
id: string; // 'csv', 'png', 'json', 'md', 'html'
|
|
198
|
+
label: string; // 'CSV', 'PNG', 'JSON', 'Markdown', 'HTML'
|
|
199
|
+
icon: string; // emoji or unicode
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const CSV_FMT: ExportFormat = { id: 'csv', label: 'CSV', icon: '📊' };
|
|
203
|
+
const PNG_FMT: ExportFormat = { id: 'png', label: 'PNG', icon: '📷' };
|
|
204
|
+
const JSON_FMT: ExportFormat = { id: 'json', label: 'JSON', icon: '📋' };
|
|
205
|
+
const MD_FMT: ExportFormat = { id: 'md', label: 'Markdown', icon: '📝' };
|
|
206
|
+
const HTML_FMT: ExportFormat = { id: 'html', label: 'HTML', icon: '🌐' };
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Return the list of export formats available for a given widget type.
|
|
210
|
+
*/
|
|
211
|
+
export function getExportFormats(type: string, containerEl?: HTMLElement): ExportFormat[] {
|
|
212
|
+
switch (type) {
|
|
213
|
+
case 'data-table':
|
|
214
|
+
case 'grid-data':
|
|
215
|
+
case 'kv':
|
|
216
|
+
case 'list':
|
|
217
|
+
return [CSV_FMT, JSON_FMT];
|
|
218
|
+
|
|
219
|
+
case 'chart':
|
|
220
|
+
case 'chart-rich':
|
|
221
|
+
case 'sankey':
|
|
222
|
+
case 'd3':
|
|
223
|
+
case 'hemicycle':
|
|
224
|
+
case 'map':
|
|
225
|
+
return [PNG_FMT, JSON_FMT];
|
|
226
|
+
|
|
227
|
+
case 'text':
|
|
228
|
+
case 'code':
|
|
229
|
+
case 'log':
|
|
230
|
+
return [MD_FMT, JSON_FMT];
|
|
231
|
+
|
|
232
|
+
case 'js-sandbox':
|
|
233
|
+
return [HTML_FMT, JSON_FMT];
|
|
234
|
+
|
|
235
|
+
case 'gallery':
|
|
236
|
+
case 'carousel':
|
|
237
|
+
return [JSON_FMT];
|
|
238
|
+
|
|
239
|
+
default:
|
|
240
|
+
return [JSON_FMT];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Export a widget in a specific format chosen by the user.
|
|
246
|
+
*/
|
|
247
|
+
export function exportWidgetAs(
|
|
248
|
+
format: string,
|
|
249
|
+
type: string,
|
|
250
|
+
data: Record<string, unknown>,
|
|
251
|
+
containerEl?: HTMLElement
|
|
252
|
+
): void {
|
|
253
|
+
switch (format) {
|
|
254
|
+
case 'csv':
|
|
255
|
+
exportCsv(type, data);
|
|
256
|
+
break;
|
|
257
|
+
case 'png':
|
|
258
|
+
if (containerEl) exportPng(type, containerEl);
|
|
259
|
+
else console.warn('[exportWidgetAs] containerEl required for PNG export');
|
|
260
|
+
break;
|
|
261
|
+
case 'md':
|
|
262
|
+
exportMarkdown(type, data);
|
|
263
|
+
break;
|
|
264
|
+
case 'html':
|
|
265
|
+
exportHtml(type, data, containerEl);
|
|
266
|
+
break;
|
|
267
|
+
case 'json':
|
|
268
|
+
default:
|
|
269
|
+
exportJson(type, data);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
194
274
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
195
275
|
|
|
196
276
|
/**
|
|
197
277
|
* Export a widget's data in the most appropriate format for its type.
|
|
278
|
+
* @deprecated Use getExportFormats() + exportWidgetAs() for format selection modal.
|
|
198
279
|
*
|
|
199
280
|
* @param type Widget type identifier (e.g. "data-table", "chart", "text")
|
|
200
281
|
* @param data The widget's data object
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
21
21
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
22
22
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
23
|
-
<div class="bg-surface2 border border-border rounded-lg overflow-hidden hover:border-border2 transition-all {oncardclick?'cursor-pointer':''}" role={oncardclick?"button":undefined} tabindex={oncardclick?0:undefined}
|
|
23
|
+
<div class="bg-surface2 border border-border rounded-lg overflow-hidden hover:border-border2 transition-all {oncardclick?'cursor-pointer':''}" role={oncardclick?"button":undefined} tabindex={oncardclick?0:undefined} title={oncardclick?"Double-cliquez pour interagir":undefined} ondblclick={()=>oncardclick?.(card)}>
|
|
24
24
|
{#if card.image}<SafeImage src={card.image} alt={card.title} class="w-full h-32 object-cover" hideOnError />{/if}
|
|
25
25
|
<div class="p-3">
|
|
26
26
|
<div class="font-semibold text-sm text-text1 leading-tight">{card.title}</div>
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
export interface ChartSpec { title?: string; type?: 'bar'|'line'|'area'|'pie'|'donut'; labels?: string[]; data?: ChartDataset[]; legend?: boolean; xAxis?: {label?:string}; yAxis?: {label?:string}; }
|
|
4
4
|
interface Props { spec: Partial<ChartSpec>; }
|
|
5
5
|
let { spec }: Props = $props();
|
|
6
|
-
const PAL=[
|
|
6
|
+
const PAL=[
|
|
7
|
+
'#6366f1','#f59e0b','#10b981','#ef4444','#8b5cf6',
|
|
8
|
+
'#06b6d4','#f97316','#ec4899','#14b8a6','#a855f7',
|
|
9
|
+
'#eab308','#3b82f6','#22c55e','#e11d48','#0ea5e9',
|
|
10
|
+
];
|
|
7
11
|
const fmt=new Intl.NumberFormat('fr-FR');
|
|
8
12
|
const datasets=$derived<ChartDataset[]>(Array.isArray(spec.data)?spec.data:[]);
|
|
9
13
|
const labels=$derived<string[]>(Array.isArray(spec.labels)?spec.labels:[]);
|
|
@@ -14,6 +18,10 @@
|
|
|
14
18
|
const xLabels=$derived(labels.length>0?labels:(datasets[0]?.values??[]).map((_:number,i:number)=>String(i+1)));
|
|
15
19
|
const showLegend=$derived(spec.legend!==false&&datasets.length>1);
|
|
16
20
|
function col(ds:ChartDataset,i:number){return ds.color??PAL[i%PAL.length];}
|
|
21
|
+
/** For single-series bar charts, color each bar distinctly by x-index */
|
|
22
|
+
const isCategoricalBar=$derived(type==='bar'&&datasets.length===1);
|
|
23
|
+
const isCategoricalLine=$derived((type==='line'||type==='area')&&datasets.length===1);
|
|
24
|
+
function barCol(ds:ChartDataset,di:number,xi:number){return isCategoricalBar?PAL[xi%PAL.length]:col(ds,di);}
|
|
17
25
|
// Pie
|
|
18
26
|
const pieTotal=$derived.by<number>(()=>{const ds=datasets[0];if(!ds)return 1;return ds.values.reduce((a,b)=>a+b,0)||1;});
|
|
19
27
|
interface Slice{label:string;value:number;color:string;startAngle:number;endAngle:number;pct:number}
|
|
@@ -71,7 +79,7 @@
|
|
|
71
79
|
{@const v=ds.values[xi]??0}
|
|
72
80
|
{@const pct=Math.round(v/maxVal*100)}
|
|
73
81
|
<div class="flex-1 rounded-t transition-all hover:opacity-80 cursor-default"
|
|
74
|
-
style="height:{pct}%;background:{
|
|
82
|
+
style="height:{pct}%;background:{barCol(ds,di,xi)};"
|
|
75
83
|
title="{ds.label??''} {lbl}: {fmt.format(v)}">
|
|
76
84
|
</div>
|
|
77
85
|
{/each}
|
|
@@ -90,6 +98,11 @@
|
|
|
90
98
|
<polygon points="{pad},{H-pad} {pts.join(' ')} {pad+((ds.values.length-1)*((W-pad*2)/(ds.values.length-1||1)))},{H-pad}" fill={col(ds,di)} opacity="0.15"/>
|
|
91
99
|
{/if}
|
|
92
100
|
<polyline points={pts.join(' ')} fill="none" stroke={col(ds,di)} stroke-width="2" stroke-linejoin="round"/>
|
|
101
|
+
{#each ds.values as v, i}
|
|
102
|
+
{@const cx = pad + i * ((W - pad * 2) / (ds.values.length - 1 || 1))}
|
|
103
|
+
{@const cy = H - pad - (v / maxVal) * (H - pad * 2)}
|
|
104
|
+
<circle cx={cx} cy={cy} r="4" fill={isCategoricalLine ? PAL[i % PAL.length] : col(ds, di)} stroke="var(--color-surface, white)" stroke-width="1.5" />
|
|
105
|
+
{/each}
|
|
93
106
|
{/each}
|
|
94
107
|
</svg>
|
|
95
108
|
<div class="flex gap-1">
|
|
@@ -97,7 +110,25 @@
|
|
|
97
110
|
</div>
|
|
98
111
|
{/if}
|
|
99
112
|
</div>
|
|
100
|
-
{#if
|
|
113
|
+
{#if isCategoricalLine && xLabels.length>1}
|
|
114
|
+
<div class="flex gap-3 flex-wrap mt-2">
|
|
115
|
+
{#each xLabels as lbl, xi}
|
|
116
|
+
<div class="flex items-center gap-1 text-xs">
|
|
117
|
+
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:{PAL[xi%PAL.length]};"></div>
|
|
118
|
+
<span class="text-text2">{lbl}</span>
|
|
119
|
+
</div>
|
|
120
|
+
{/each}
|
|
121
|
+
</div>
|
|
122
|
+
{:else if isCategoricalBar && xLabels.length>1}
|
|
123
|
+
<div class="flex gap-3 flex-wrap mt-2">
|
|
124
|
+
{#each xLabels as lbl,xi}
|
|
125
|
+
<div class="flex items-center gap-1 text-xs">
|
|
126
|
+
<div class="w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:{PAL[xi%PAL.length]};"></div>
|
|
127
|
+
<span class="text-text2">{lbl}</span>
|
|
128
|
+
</div>
|
|
129
|
+
{/each}
|
|
130
|
+
</div>
|
|
131
|
+
{:else if showLegend}
|
|
101
132
|
<div class="flex gap-3 flex-wrap mt-2">
|
|
102
133
|
{#each datasets as ds,i}
|
|
103
134
|
<div class="flex items-center gap-1 text-xs">
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
<tbody>
|
|
38
38
|
{#each displayed as row, i}
|
|
39
39
|
<tr class="hover:bg-surface2 transition-colors {spec.striped!==false&&i%2===1?'bg-white/[0.02]':''} {onrowclick?'cursor-pointer':''}"
|
|
40
|
-
|
|
40
|
+
title={onrowclick?"Double-cliquez pour interagir":undefined}
|
|
41
|
+
ondblclick={()=>onrowclick?.(row)}>
|
|
41
42
|
{#each columns as col}
|
|
42
43
|
{@const val=row[col.key]}
|
|
43
44
|
<td class="border-b border-border text-text1 overflow-hidden text-ellipsis whitespace-nowrap max-w-[280px] {compact?'px-2 py-1 text-xs':'px-3 py-2 text-sm'} {col.align==='center'?'text-center':col.align==='right'?'text-right':''}">
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
function open(i: number) {
|
|
18
18
|
lightboxIdx = i;
|
|
19
|
+
}
|
|
20
|
+
function interact(i: number) {
|
|
19
21
|
onimageclick?.(images[i], i);
|
|
20
22
|
}
|
|
21
23
|
function close() { lightboxIdx = null; }
|
|
@@ -35,11 +37,22 @@
|
|
|
35
37
|
{#if spec.title}<h3 class="text-sm font-semibold text-text1 mb-3">{spec.title}</h3>{/if}
|
|
36
38
|
{#if images.length === 0}
|
|
37
39
|
<p class="text-text2 text-sm">{spec.emptyMessage ?? 'Aucune image'}</p>
|
|
40
|
+
{:else if images.length === 1}
|
|
41
|
+
{@const img = images[0]}
|
|
42
|
+
<button class="relative overflow-hidden rounded-lg border border-border hover:border-border2 transition-all cursor-pointer bg-transparent p-0 group w-full"
|
|
43
|
+
onclick={() => open(0)}>
|
|
44
|
+
<SafeImage src={img.src} alt={img.alt ?? ''} class="w-full max-h-[400px] object-contain rounded-lg" loading="eager" />
|
|
45
|
+
{#if img.caption || img.alt}
|
|
46
|
+
<div class="mt-2 text-center text-xs text-text2">{img.caption ?? img.alt}</div>
|
|
47
|
+
{/if}
|
|
48
|
+
</button>
|
|
38
49
|
{:else}
|
|
39
50
|
<div class="grid gap-2 responsive-gallery" style="--gallery-cols: repeat({spec.columns ?? 3}, minmax(0, 1fr));">
|
|
40
51
|
{#each images as img, i}
|
|
41
52
|
<button class="relative overflow-hidden rounded-lg border border-border hover:border-border2 transition-all cursor-pointer bg-transparent p-0 group"
|
|
42
|
-
|
|
53
|
+
title={onimageclick?"Double-cliquez pour interagir":undefined}
|
|
54
|
+
onclick={() => open(i)}
|
|
55
|
+
ondblclick={() => interact(i)}>
|
|
43
56
|
<SafeImage src={img.src} alt={img.alt ?? ''} class="w-full h-32 sm:h-40 object-cover transition-transform group-hover:scale-105" loading="lazy" />
|
|
44
57
|
{#if img.caption}
|
|
45
58
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
|
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
{#each rows as row, ri}
|
|
31
31
|
<tr class="hover:bg-surface2">
|
|
32
32
|
{#each (Array.isArray(row)?row:[]) as cell, ci}
|
|
33
|
-
<td class="px-3 text-text2 border-b border-r border-border cursor-pointer hover:bg-surface2"
|
|
33
|
+
<td class="px-3 text-text2 border-b border-r border-border {oncellclick?'cursor-pointer':''} hover:bg-surface2"
|
|
34
34
|
style="height:{cellH}px;{bg(ri,ci)?`background:${bg(ri,ci)};`:''}"
|
|
35
|
-
|
|
35
|
+
title={oncellclick?"Double-cliquez pour interagir":undefined}
|
|
36
|
+
ondblclick={()=>oncellclick?.(ri,ci,cell)}>{dv(cell)}</td>
|
|
36
37
|
{/each}
|
|
37
38
|
</tr>
|
|
38
39
|
{/each}
|
|
@@ -51,7 +51,9 @@
|
|
|
51
51
|
class={s.gid?'cursor-pointer':''}
|
|
52
52
|
onmouseenter={()=>{const g=groups.find(g=>g.id===s.gid);if(g)tooltip={label:g.label,seats:g.seats};}}
|
|
53
53
|
onmouseleave={()=>tooltip=null}
|
|
54
|
-
|
|
54
|
+
ondblclick={()=>{const g=groups.find(g=>g.id===s.gid);if(g)ongroupclick?.(g);}}>
|
|
55
|
+
{#if s.gid}<title>Double-cliquez pour interagir</title>{/if}
|
|
56
|
+
</circle>
|
|
55
57
|
{/each}
|
|
56
58
|
<text x={cx} y={cy+18} text-anchor="middle" font-size="11" fill="var(--color-text2)" font-family="system-ui">{total} sièges</text>
|
|
57
59
|
</svg>
|
|
@@ -65,7 +67,7 @@
|
|
|
65
67
|
{#each legend as g}
|
|
66
68
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
67
69
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
68
|
-
<div class="flex items-center gap-1.5 text-xs cursor-pointer hover:opacity-80" role="button" tabindex="0"
|
|
70
|
+
<div class="flex items-center gap-1.5 text-xs cursor-pointer hover:opacity-80" role="button" tabindex="0" title="Double-cliquez pour interagir" ondblclick={()=>ongroupclick?.(g)} onkeydown={(e)=>{if(e.key==="Enter")ongroupclick?.(g)}}>
|
|
69
71
|
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background:{g.color};"></div>
|
|
70
72
|
<span class="text-text2">{g.label}</span>
|
|
71
73
|
<span class="text-text2">{g.seats}</span>
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
19
19
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
20
20
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
21
|
-
<div class="flex gap-4 relative {!isLast?'pb-5':''} {oneventclick?'cursor-pointer':''}" role={oneventclick?"button":undefined} tabindex={oneventclick?0:undefined}
|
|
21
|
+
<div class="flex gap-4 relative {!isLast?'pb-5':''} {oneventclick?'cursor-pointer':''}" role={oneventclick?"button":undefined} tabindex={oneventclick?0:undefined} title={oneventclick?"Double-cliquez pour interagir":undefined} ondblclick={()=>oneventclick?.(event)} onkeydown={(e)=>{if(e.key==='Enter')oneventclick?.(event)}}>
|
|
22
22
|
<div class="flex flex-col items-center flex-shrink-0">
|
|
23
23
|
<div class="w-3 h-3 rounded-full flex-shrink-0 mt-0.5" style="background:{dotColor};{event.status==='active'?`box-shadow:0 0 0 3px ${dotColor}33;`:''}"></div>
|
|
24
24
|
{#if !isLast}<div class="w-0.5 flex-1 bg-border mt-1"></div>{/if}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
11
11
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
12
12
|
<li class="text-sm text-text1 bg-surface2 rounded px-3 py-2 border-l-2 border-accent {onitemclick ? 'cursor-pointer hover:bg-surface2/80' : ''}"
|
|
13
|
-
|
|
13
|
+
title={onitemclick?"Double-cliquez pour interagir":undefined}
|
|
14
|
+
ondblclick={() => onitemclick?.(item, i)}>{item}</li>
|
|
14
15
|
{/each}
|
|
15
16
|
</ul>
|
|
16
17
|
</div>
|
|
@@ -2,5 +2,121 @@
|
|
|
2
2
|
export interface TextBlockData { content?: string; }
|
|
3
3
|
interface Props { data: Partial<TextBlockData>; }
|
|
4
4
|
let { data }: Props = $props();
|
|
5
|
+
|
|
6
|
+
/** Minimal markdown → HTML renderer (no deps, naturally XSS-safe: only produces known tags) */
|
|
7
|
+
function renderMarkdown(src: string): string {
|
|
8
|
+
if (!src) return '';
|
|
9
|
+
|
|
10
|
+
// Escape HTML entities first (XSS protection)
|
|
11
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
12
|
+
|
|
13
|
+
const lines = src.split('\n');
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
let inCode = false;
|
|
16
|
+
let codeLines: string[] = [];
|
|
17
|
+
let inUl = false;
|
|
18
|
+
let inOl = false;
|
|
19
|
+
|
|
20
|
+
const closeList = () => {
|
|
21
|
+
if (inUl) { out.push('</ul>'); inUl = false; }
|
|
22
|
+
if (inOl) { out.push('</ol>'); inOl = false; }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Inline formatting: bold, italic, code, links */
|
|
26
|
+
const inline = (s: string): string => {
|
|
27
|
+
return esc(s)
|
|
28
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
29
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
30
|
+
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
|
31
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
// Fenced code block toggle
|
|
36
|
+
if (line.trimStart().startsWith('```')) {
|
|
37
|
+
if (!inCode) {
|
|
38
|
+
closeList();
|
|
39
|
+
inCode = true;
|
|
40
|
+
codeLines = [];
|
|
41
|
+
} else {
|
|
42
|
+
out.push(`<pre><code>${esc(codeLines.join('\n'))}</code></pre>`);
|
|
43
|
+
inCode = false;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (inCode) { codeLines.push(line); continue; }
|
|
48
|
+
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
|
|
51
|
+
// Empty line → close lists, push break
|
|
52
|
+
if (!trimmed) { closeList(); out.push(''); continue; }
|
|
53
|
+
|
|
54
|
+
// Headers
|
|
55
|
+
const hMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
|
56
|
+
if (hMatch) {
|
|
57
|
+
closeList();
|
|
58
|
+
const level = hMatch[1].length;
|
|
59
|
+
out.push(`<h${level}>${inline(hMatch[2])}</h${level}>`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Unordered list
|
|
64
|
+
if (/^[-*+]\s+/.test(trimmed)) {
|
|
65
|
+
if (inOl) { out.push('</ol>'); inOl = false; }
|
|
66
|
+
if (!inUl) { out.push('<ul>'); inUl = true; }
|
|
67
|
+
out.push(`<li>${inline(trimmed.replace(/^[-*+]\s+/, ''))}</li>`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Ordered list
|
|
72
|
+
const olMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
|
|
73
|
+
if (olMatch) {
|
|
74
|
+
if (inUl) { out.push('</ul>'); inUl = false; }
|
|
75
|
+
if (!inOl) { out.push('<ol>'); inOl = true; }
|
|
76
|
+
out.push(`<li>${inline(olMatch[2])}</li>`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Horizontal rule
|
|
81
|
+
if (/^[-*_]{3,}$/.test(trimmed)) {
|
|
82
|
+
closeList();
|
|
83
|
+
out.push('<hr>');
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Regular paragraph line
|
|
88
|
+
closeList();
|
|
89
|
+
out.push(`<p>${inline(trimmed)}</p>`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Close any open blocks
|
|
93
|
+
if (inCode) out.push(`<pre><code>${esc(codeLines.join('\n'))}</code></pre>`);
|
|
94
|
+
closeList();
|
|
95
|
+
|
|
96
|
+
return out.join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let rendered = $derived(renderMarkdown(data.content ?? ''));
|
|
5
100
|
</script>
|
|
6
|
-
<div class="p-4 md:p-5 text-sm
|
|
101
|
+
<div class="tb-md p-4 md:p-5 text-sm leading-relaxed">{@html rendered}</div>
|
|
102
|
+
|
|
103
|
+
<style>
|
|
104
|
+
.tb-md { color: var(--color-text2); }
|
|
105
|
+
.tb-md :global(h1) { font-size: 1.5em; font-weight: 700; color: var(--color-text1); margin: 0.8em 0 0.4em; }
|
|
106
|
+
.tb-md :global(h2) { font-size: 1.25em; font-weight: 600; color: var(--color-text1); margin: 0.7em 0 0.35em; }
|
|
107
|
+
.tb-md :global(h3) { font-size: 1.1em; font-weight: 600; color: var(--color-text1); margin: 0.6em 0 0.3em; }
|
|
108
|
+
.tb-md :global(h4), .tb-md :global(h5), .tb-md :global(h6) { font-size: 1em; font-weight: 600; color: var(--color-text1); margin: 0.5em 0 0.25em; }
|
|
109
|
+
.tb-md :global(p) { margin: 0.4em 0; }
|
|
110
|
+
.tb-md :global(strong) { font-weight: 600; color: var(--color-text1); }
|
|
111
|
+
.tb-md :global(em) { font-style: italic; }
|
|
112
|
+
.tb-md :global(a) { color: var(--color-accent); text-decoration: underline; text-underline-offset: 2px; }
|
|
113
|
+
.tb-md :global(a:hover) { opacity: 0.8; }
|
|
114
|
+
.tb-md :global(ul), .tb-md :global(ol) { margin: 0.4em 0; padding-left: 1.5em; }
|
|
115
|
+
.tb-md :global(ul) { list-style: disc; }
|
|
116
|
+
.tb-md :global(ol) { list-style: decimal; }
|
|
117
|
+
.tb-md :global(li) { margin: 0.15em 0; }
|
|
118
|
+
.tb-md :global(code) { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 0.9em; background: var(--color-surface2); padding: 0.15em 0.35em; border-radius: 4px; }
|
|
119
|
+
.tb-md :global(pre) { background: var(--color-surface2); border-radius: 6px; padding: 0.75em 1em; margin: 0.5em 0; overflow-x: auto; }
|
|
120
|
+
.tb-md :global(pre code) { background: none; padding: 0; font-size: 0.85em; }
|
|
121
|
+
.tb-md :global(hr) { border: none; border-top: 1px solid var(--color-surface2); margin: 0.8em 0; }
|
|
122
|
+
</style>
|