@jsonpages/cli 3.0.62 → 3.0.64
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/assets/src_tenant_alpha.sh +1321 -444
- package/package.json +1 -1
|
@@ -36,25 +36,26 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
|
|
|
36
36
|
"dev:clean": "vite --force",
|
|
37
37
|
"build": "tsc && vite build",
|
|
38
38
|
"dist": "bash ./src2Code.sh src index.html package.json",
|
|
39
|
-
"preview": "vite preview"
|
|
39
|
+
"preview": "vite preview",
|
|
40
|
+
"bake:email": "tsx scripts/bake-email.tsx",
|
|
41
|
+
"bakemail": "npm run bake:email --"
|
|
40
42
|
},
|
|
41
43
|
"dependencies": {
|
|
42
|
-
"@tiptap/extension-image": "
|
|
43
|
-
"@tiptap/extension-link": "
|
|
44
|
-
"@tiptap/
|
|
45
|
-
"@tiptap/
|
|
46
|
-
"@
|
|
47
|
-
"@jsonpages/core": "^1.0.49",
|
|
44
|
+
"@tiptap/extension-image": "^2.11.5",
|
|
45
|
+
"@tiptap/extension-link": "^2.11.5",
|
|
46
|
+
"@tiptap/react": "^2.11.5",
|
|
47
|
+
"@tiptap/starter-kit": "^2.11.5",
|
|
48
|
+
"@jsonpages/core": "^1.0.51",
|
|
48
49
|
"clsx": "^2.1.1",
|
|
49
50
|
"lucide-react": "^0.474.0",
|
|
50
51
|
"react": "^19.0.0",
|
|
51
|
-
"react-markdown": "
|
|
52
|
+
"react-markdown": "^9.0.1",
|
|
52
53
|
"react-dom": "^19.0.0",
|
|
53
54
|
"react-router-dom": "^6.30.3",
|
|
54
|
-
"rehype-sanitize": "
|
|
55
|
-
"remark-gfm": "
|
|
55
|
+
"rehype-sanitize": "^6.0.0",
|
|
56
|
+
"remark-gfm": "^4.0.1",
|
|
56
57
|
"tailwind-merge": "^3.0.1",
|
|
57
|
-
"tiptap-markdown": "
|
|
58
|
+
"tiptap-markdown": "^0.8.10",
|
|
58
59
|
"zod": "^3.24.1"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
@@ -63,7 +64,10 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
|
|
|
63
64
|
"@types/react-dom": "^19.0.3",
|
|
64
65
|
"@vitejs/plugin-react": "^4.3.4",
|
|
65
66
|
"typescript": "^5.7.3",
|
|
66
|
-
"vite": "^6.0.11"
|
|
67
|
+
"vite": "^6.0.11",
|
|
68
|
+
"@react-email/components": "^0.0.41",
|
|
69
|
+
"@react-email/render": "^1.0.5",
|
|
70
|
+
"tsx": "^4.20.5"
|
|
67
71
|
}
|
|
68
72
|
}
|
|
69
73
|
|
|
@@ -214,7 +218,6 @@ console.log("🔍 DEBUG ENV:", {
|
|
|
214
218
|
export default App;
|
|
215
219
|
|
|
216
220
|
END_OF_FILE_CONTENT
|
|
217
|
-
# SKIP: src/App.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
218
221
|
echo "Creating src/App_.tsx..."
|
|
219
222
|
cat << 'END_OF_FILE_CONTENT' > "src/App_.tsx"
|
|
220
223
|
import { useState, useEffect } from 'react';
|
|
@@ -3086,17 +3089,248 @@ export type ProductTriadSettings = z.infer<typeof BaseSectionSettingsSchema>;
|
|
|
3086
3089
|
|
|
3087
3090
|
END_OF_FILE_CONTENT
|
|
3088
3091
|
mkdir -p "src/components/save-drawer"
|
|
3089
|
-
# SKIP: src/components/save-drawer/DeployConnector.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3090
|
-
# SKIP: src/components/save-drawer/DeployConnector.tsx:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3091
|
-
# SKIP: src/components/save-drawer/DeployNode.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3092
|
-
# SKIP: src/components/save-drawer/DeployNode.tsx:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3093
|
-
# SKIP: src/components/save-drawer/DopaDrawer.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3094
|
-
# SKIP: src/components/save-drawer/DopaDrawer.tsx:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3095
|
-
# SKIP: src/components/save-drawer/Visuals.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3096
|
-
# SKIP: src/components/save-drawer/Visuals.tsx:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3097
|
-
# SKIP: src/components/save-drawer/saverStyle.css:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3098
|
-
# SKIP: src/components/save-drawer/saverStyle.css:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
3099
3092
|
mkdir -p "src/components/tiptap"
|
|
3093
|
+
echo "Creating src/components/tiptap/INTEGRATION.md..."
|
|
3094
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/INTEGRATION.md"
|
|
3095
|
+
# Tiptap Editorial — Integration Guide
|
|
3096
|
+
|
|
3097
|
+
How to add the `tiptap` section to a new tenant.
|
|
3098
|
+
|
|
3099
|
+
---
|
|
3100
|
+
|
|
3101
|
+
## 1. Copy the component
|
|
3102
|
+
|
|
3103
|
+
Copy the entire folder into the new tenant:
|
|
3104
|
+
|
|
3105
|
+
```
|
|
3106
|
+
src/components/tiptap/
|
|
3107
|
+
index.ts
|
|
3108
|
+
types.ts
|
|
3109
|
+
View.tsx
|
|
3110
|
+
```
|
|
3111
|
+
|
|
3112
|
+
---
|
|
3113
|
+
|
|
3114
|
+
## 2. Install npm dependencies
|
|
3115
|
+
|
|
3116
|
+
Add to the tenant's `package.json` and run `npm install`:
|
|
3117
|
+
|
|
3118
|
+
```json
|
|
3119
|
+
"@tiptap/extension-image": "^2.11.5",
|
|
3120
|
+
"@tiptap/extension-link": "^2.11.5",
|
|
3121
|
+
"@tiptap/react": "^2.11.5",
|
|
3122
|
+
"@tiptap/starter-kit": "^2.11.5",
|
|
3123
|
+
"react-markdown": "^9.0.1",
|
|
3124
|
+
"rehype-sanitize": "^6.0.0",
|
|
3125
|
+
"remark-gfm": "^4.0.1",
|
|
3126
|
+
"tiptap-markdown": "^0.8.10"
|
|
3127
|
+
```
|
|
3128
|
+
|
|
3129
|
+
---
|
|
3130
|
+
|
|
3131
|
+
## 3. Add CSS to `src/index.css`
|
|
3132
|
+
|
|
3133
|
+
Two blocks are required — one for the public (visitor) view, one for the editor (studio) view.
|
|
3134
|
+
|
|
3135
|
+
```css
|
|
3136
|
+
/* ==========================================================================
|
|
3137
|
+
TIPTAP — Public content typography (visitor view)
|
|
3138
|
+
========================================================================== */
|
|
3139
|
+
.jp-tiptap-content > * + * { margin-top: 0.75em; }
|
|
3140
|
+
|
|
3141
|
+
.jp-tiptap-content h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
3142
|
+
.jp-tiptap-content h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
3143
|
+
.jp-tiptap-content h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
3144
|
+
.jp-tiptap-content h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
|
|
3145
|
+
|
|
3146
|
+
.jp-tiptap-content p { line-height: 1.7; }
|
|
3147
|
+
.jp-tiptap-content strong { font-weight: 700; }
|
|
3148
|
+
.jp-tiptap-content em { font-style: italic; }
|
|
3149
|
+
.jp-tiptap-content s { text-decoration: line-through; }
|
|
3150
|
+
|
|
3151
|
+
.jp-tiptap-content a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
3152
|
+
.jp-tiptap-content a:hover { opacity: 0.8; }
|
|
3153
|
+
|
|
3154
|
+
.jp-tiptap-content code {
|
|
3155
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
3156
|
+
font-size: 0.875em;
|
|
3157
|
+
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
3158
|
+
border-radius: 0.25em;
|
|
3159
|
+
padding: 0.1em 0.35em;
|
|
3160
|
+
}
|
|
3161
|
+
.jp-tiptap-content pre {
|
|
3162
|
+
background: color-mix(in oklch, var(--background) 60%, black);
|
|
3163
|
+
border-radius: 0.5em;
|
|
3164
|
+
padding: 1em 1.25em;
|
|
3165
|
+
overflow-x: auto;
|
|
3166
|
+
}
|
|
3167
|
+
.jp-tiptap-content pre code { background: none; padding: 0; }
|
|
3168
|
+
|
|
3169
|
+
.jp-tiptap-content ul { list-style-type: disc; padding-left: 1.625em; }
|
|
3170
|
+
.jp-tiptap-content ol { list-style-type: decimal; padding-left: 1.625em; }
|
|
3171
|
+
.jp-tiptap-content li { line-height: 1.7; margin-top: 0.25em; }
|
|
3172
|
+
.jp-tiptap-content li + li { margin-top: 0.25em; }
|
|
3173
|
+
|
|
3174
|
+
.jp-tiptap-content blockquote {
|
|
3175
|
+
border-left: 3px solid var(--border);
|
|
3176
|
+
padding-left: 1em;
|
|
3177
|
+
color: var(--muted-foreground);
|
|
3178
|
+
font-style: italic;
|
|
3179
|
+
}
|
|
3180
|
+
.jp-tiptap-content hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
|
|
3181
|
+
.jp-tiptap-content img { max-width: 100%; height: auto; border-radius: 0.5rem; }
|
|
3182
|
+
|
|
3183
|
+
/* ==========================================================================
|
|
3184
|
+
TIPTAP / PROSEMIRROR — Editor typography (studio view)
|
|
3185
|
+
========================================================================== */
|
|
3186
|
+
.jp-simple-editor .ProseMirror { outline: none; word-break: break-word; }
|
|
3187
|
+
.jp-simple-editor .ProseMirror > * + * { margin-top: 0.75em; }
|
|
3188
|
+
|
|
3189
|
+
.jp-simple-editor .ProseMirror h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
3190
|
+
.jp-simple-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
3191
|
+
.jp-simple-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
3192
|
+
.jp-simple-editor .ProseMirror h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
|
|
3193
|
+
|
|
3194
|
+
.jp-simple-editor .ProseMirror p { line-height: 1.7; }
|
|
3195
|
+
.jp-simple-editor .ProseMirror strong { font-weight: 700; }
|
|
3196
|
+
.jp-simple-editor .ProseMirror em { font-style: italic; }
|
|
3197
|
+
.jp-simple-editor .ProseMirror s { text-decoration: line-through; }
|
|
3198
|
+
|
|
3199
|
+
.jp-simple-editor .ProseMirror a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
3200
|
+
.jp-simple-editor .ProseMirror a:hover { opacity: 0.8; }
|
|
3201
|
+
|
|
3202
|
+
.jp-simple-editor .ProseMirror code {
|
|
3203
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
3204
|
+
font-size: 0.875em;
|
|
3205
|
+
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
3206
|
+
border-radius: 0.25em;
|
|
3207
|
+
padding: 0.1em 0.35em;
|
|
3208
|
+
}
|
|
3209
|
+
.jp-simple-editor .ProseMirror pre {
|
|
3210
|
+
background: color-mix(in oklch, var(--background) 60%, black);
|
|
3211
|
+
border-radius: 0.5em;
|
|
3212
|
+
padding: 1em 1.25em;
|
|
3213
|
+
overflow-x: auto;
|
|
3214
|
+
}
|
|
3215
|
+
.jp-simple-editor .ProseMirror pre code { background: none; padding: 0; }
|
|
3216
|
+
|
|
3217
|
+
.jp-simple-editor .ProseMirror ul { list-style-type: disc; padding-left: 1.625em; }
|
|
3218
|
+
.jp-simple-editor .ProseMirror ol { list-style-type: decimal; padding-left: 1.625em; }
|
|
3219
|
+
.jp-simple-editor .ProseMirror li { line-height: 1.7; margin-top: 0.25em; }
|
|
3220
|
+
.jp-simple-editor .ProseMirror li + li { margin-top: 0.25em; }
|
|
3221
|
+
|
|
3222
|
+
.jp-simple-editor .ProseMirror blockquote {
|
|
3223
|
+
border-left: 3px solid var(--border);
|
|
3224
|
+
padding-left: 1em;
|
|
3225
|
+
color: var(--muted-foreground);
|
|
3226
|
+
font-style: italic;
|
|
3227
|
+
}
|
|
3228
|
+
.jp-simple-editor .ProseMirror hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
|
|
3229
|
+
|
|
3230
|
+
.jp-simple-editor .ProseMirror img { max-width: 100%; height: auto; border-radius: 0.5rem; }
|
|
3231
|
+
.jp-simple-editor .ProseMirror img[data-uploading="true"] {
|
|
3232
|
+
opacity: 0.6;
|
|
3233
|
+
filter: grayscale(0.25);
|
|
3234
|
+
outline: 2px dashed rgb(59 130 246 / 0.7);
|
|
3235
|
+
outline-offset: 2px;
|
|
3236
|
+
}
|
|
3237
|
+
.jp-simple-editor .ProseMirror img[data-upload-error="true"] {
|
|
3238
|
+
outline: 2px solid rgb(239 68 68 / 0.8);
|
|
3239
|
+
outline-offset: 2px;
|
|
3240
|
+
}
|
|
3241
|
+
.jp-simple-editor .ProseMirror p.is-editor-empty:first-child::before {
|
|
3242
|
+
content: attr(data-placeholder);
|
|
3243
|
+
color: var(--muted-foreground);
|
|
3244
|
+
opacity: 0.5;
|
|
3245
|
+
pointer-events: none;
|
|
3246
|
+
float: left;
|
|
3247
|
+
height: 0;
|
|
3248
|
+
}
|
|
3249
|
+
```
|
|
3250
|
+
|
|
3251
|
+
---
|
|
3252
|
+
|
|
3253
|
+
## 4. Register in `src/lib/schemas.ts`
|
|
3254
|
+
|
|
3255
|
+
```ts
|
|
3256
|
+
import { TiptapSchema } from '@/components/tiptap';
|
|
3257
|
+
|
|
3258
|
+
export const SECTION_SCHEMAS = {
|
|
3259
|
+
// ... existing schemas
|
|
3260
|
+
'tiptap': TiptapSchema,
|
|
3261
|
+
} as const;
|
|
3262
|
+
```
|
|
3263
|
+
|
|
3264
|
+
---
|
|
3265
|
+
|
|
3266
|
+
## 5. Register in `src/lib/addSectionConfig.ts`
|
|
3267
|
+
|
|
3268
|
+
```ts
|
|
3269
|
+
const addableSectionTypes = [
|
|
3270
|
+
// ... existing types
|
|
3271
|
+
'tiptap',
|
|
3272
|
+
] as const;
|
|
3273
|
+
|
|
3274
|
+
const sectionTypeLabels = {
|
|
3275
|
+
// ... existing labels
|
|
3276
|
+
'tiptap': 'Tiptap Editorial',
|
|
3277
|
+
};
|
|
3278
|
+
|
|
3279
|
+
function getDefaultSectionData(type: string) {
|
|
3280
|
+
switch (type) {
|
|
3281
|
+
// ... existing cases
|
|
3282
|
+
case 'tiptap': return { content: '# Post title\n\nStart writing in Markdown...' };
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
```
|
|
3286
|
+
|
|
3287
|
+
---
|
|
3288
|
+
|
|
3289
|
+
## 6. Register in `src/lib/ComponentRegistry.tsx`
|
|
3290
|
+
|
|
3291
|
+
```tsx
|
|
3292
|
+
import { Tiptap } from '@/components/tiptap';
|
|
3293
|
+
|
|
3294
|
+
export const ComponentRegistry = {
|
|
3295
|
+
// ... existing components
|
|
3296
|
+
'tiptap': Tiptap,
|
|
3297
|
+
};
|
|
3298
|
+
```
|
|
3299
|
+
|
|
3300
|
+
---
|
|
3301
|
+
|
|
3302
|
+
## 7. Register in `src/types.ts`
|
|
3303
|
+
|
|
3304
|
+
```ts
|
|
3305
|
+
import type { TiptapData, TiptapSettings } from '@/components/tiptap';
|
|
3306
|
+
|
|
3307
|
+
export type SectionComponentPropsMap = {
|
|
3308
|
+
// ... existing entries
|
|
3309
|
+
'tiptap': { data: TiptapData; settings?: TiptapSettings };
|
|
3310
|
+
};
|
|
3311
|
+
|
|
3312
|
+
declare module '@jsonpages/core' {
|
|
3313
|
+
export interface SectionDataRegistry {
|
|
3314
|
+
// ... existing entries
|
|
3315
|
+
'tiptap': TiptapData;
|
|
3316
|
+
}
|
|
3317
|
+
export interface SectionSettingsRegistry {
|
|
3318
|
+
// ... existing entries
|
|
3319
|
+
'tiptap': TiptapSettings;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
```
|
|
3323
|
+
|
|
3324
|
+
---
|
|
3325
|
+
|
|
3326
|
+
## Notes
|
|
3327
|
+
|
|
3328
|
+
- Typography uses tenant CSS variables (`--primary`, `--border`, `--muted-foreground`, `--font-mono`) — no hardcoded colors.
|
|
3329
|
+
- `@tailwindcss/typography` is **not** required; the CSS blocks above replace it.
|
|
3330
|
+
- The toolbar is admin-only (studio mode). In visitor mode, content is rendered via `ReactMarkdown`.
|
|
3331
|
+
- Underline is intentionally excluded: `tiptap-markdown` with `html: false` cannot round-trip `<u>` tags.
|
|
3332
|
+
|
|
3333
|
+
END_OF_FILE_CONTENT
|
|
3100
3334
|
echo "Creating src/components/tiptap/View.tsx..."
|
|
3101
3335
|
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/View.tsx"
|
|
3102
3336
|
import React from 'react';
|
|
@@ -3108,30 +3342,20 @@ import type { Editor } from '@tiptap/react';
|
|
|
3108
3342
|
import StarterKit from '@tiptap/starter-kit';
|
|
3109
3343
|
import Link from '@tiptap/extension-link';
|
|
3110
3344
|
import Image from '@tiptap/extension-image';
|
|
3111
|
-
import Underline from '@tiptap/extension-underline';
|
|
3112
3345
|
import { Markdown } from 'tiptap-markdown';
|
|
3113
3346
|
import {
|
|
3114
|
-
Undo2,
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
Bold,
|
|
3120
|
-
Italic,
|
|
3121
|
-
Strikethrough,
|
|
3122
|
-
Underline as UnderlineIcon,
|
|
3123
|
-
Code2,
|
|
3124
|
-
Quote,
|
|
3125
|
-
SquareCode,
|
|
3126
|
-
Link2,
|
|
3127
|
-
Unlink2,
|
|
3128
|
-
ImagePlus,
|
|
3129
|
-
Eraser,
|
|
3347
|
+
Undo2, Redo2,
|
|
3348
|
+
List, ListOrdered,
|
|
3349
|
+
Bold, Italic, Strikethrough,
|
|
3350
|
+
Code2, Quote, SquareCode,
|
|
3351
|
+
Link2, Unlink2, ImagePlus, Eraser,
|
|
3130
3352
|
} from 'lucide-react';
|
|
3131
3353
|
import { STUDIO_EVENTS, useConfig, useStudio } from '@jsonpages/core';
|
|
3132
3354
|
import type { TiptapData, TiptapSettings } from './types';
|
|
3133
3355
|
|
|
3134
|
-
|
|
3356
|
+
// ── UI primitives ─────────────────────────────────────────────────────────────
|
|
3357
|
+
|
|
3358
|
+
const Btn: React.FC<{
|
|
3135
3359
|
active?: boolean;
|
|
3136
3360
|
title: string;
|
|
3137
3361
|
onClick: () => void;
|
|
@@ -3143,171 +3367,196 @@ const ToolbarButton: React.FC<{
|
|
|
3143
3367
|
onMouseDown={(e) => e.preventDefault()}
|
|
3144
3368
|
onClick={onClick}
|
|
3145
3369
|
className={[
|
|
3146
|
-
'inline-flex h-7 min-w-7 items-center justify-center rounded-md
|
|
3370
|
+
'inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-xs transition-colors',
|
|
3147
3371
|
active
|
|
3148
|
-
? 'bg-
|
|
3149
|
-
: '
|
|
3372
|
+
? 'bg-zinc-700/70 text-zinc-100'
|
|
3373
|
+
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200',
|
|
3150
3374
|
].join(' ')}
|
|
3151
3375
|
>
|
|
3152
3376
|
{children}
|
|
3153
3377
|
</button>
|
|
3154
3378
|
);
|
|
3155
3379
|
|
|
3156
|
-
const
|
|
3380
|
+
const Sep: React.FC = () => (
|
|
3381
|
+
<span className="mx-0.5 h-5 w-px shrink-0 bg-zinc-800" aria-hidden />
|
|
3382
|
+
);
|
|
3383
|
+
|
|
3384
|
+
// ── Image extension with upload metadata ──────────────────────────────────────
|
|
3157
3385
|
|
|
3158
3386
|
const UploadableImage = Image.extend({
|
|
3159
3387
|
addAttributes() {
|
|
3388
|
+
const bool = (attr: string) => ({
|
|
3389
|
+
default: false,
|
|
3390
|
+
parseHTML: (el: HTMLElement) => el.getAttribute(attr) === 'true',
|
|
3391
|
+
renderHTML: (attrs: Record<string, unknown>) =>
|
|
3392
|
+
attrs[attr.replace('data-', '').replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())]
|
|
3393
|
+
? { [attr]: 'true' }
|
|
3394
|
+
: {},
|
|
3395
|
+
});
|
|
3160
3396
|
return {
|
|
3161
3397
|
...this.parent?.(),
|
|
3162
3398
|
uploadId: {
|
|
3163
3399
|
default: null,
|
|
3164
|
-
parseHTML: (
|
|
3165
|
-
renderHTML: (
|
|
3166
|
-
|
|
3167
|
-
},
|
|
3168
|
-
uploading: {
|
|
3169
|
-
default: false,
|
|
3170
|
-
parseHTML: (element: HTMLElement) => element.getAttribute('data-uploading') === 'true',
|
|
3171
|
-
renderHTML: (attributes: Record<string, unknown>) =>
|
|
3172
|
-
attributes.uploading ? { 'data-uploading': 'true' } : {},
|
|
3173
|
-
},
|
|
3174
|
-
uploadError: {
|
|
3175
|
-
default: false,
|
|
3176
|
-
parseHTML: (element: HTMLElement) => element.getAttribute('data-upload-error') === 'true',
|
|
3177
|
-
renderHTML: (attributes: Record<string, unknown>) =>
|
|
3178
|
-
attributes.uploadError ? { 'data-upload-error': 'true' } : {},
|
|
3179
|
-
},
|
|
3180
|
-
awaitingUpload: {
|
|
3181
|
-
default: false,
|
|
3182
|
-
parseHTML: (element: HTMLElement) => element.getAttribute('data-awaiting-upload') === 'true',
|
|
3183
|
-
renderHTML: (attributes: Record<string, unknown>) =>
|
|
3184
|
-
attributes.awaitingUpload ? { 'data-awaiting-upload': 'true' } : {},
|
|
3400
|
+
parseHTML: (el: HTMLElement) => el.getAttribute('data-upload-id'),
|
|
3401
|
+
renderHTML: (attrs: Record<string, unknown>) =>
|
|
3402
|
+
attrs.uploadId ? { 'data-upload-id': String(attrs.uploadId) } : {},
|
|
3185
3403
|
},
|
|
3404
|
+
uploading: bool('data-uploading'),
|
|
3405
|
+
uploadError: bool('data-upload-error'),
|
|
3406
|
+
awaitingUpload: bool('data-awaiting-upload'),
|
|
3186
3407
|
};
|
|
3187
3408
|
},
|
|
3188
3409
|
});
|
|
3189
3410
|
|
|
3190
|
-
|
|
3191
|
-
const storage = editor?.storage as
|
|
3192
|
-
| {
|
|
3193
|
-
markdown?: {
|
|
3194
|
-
getMarkdown?: () => string;
|
|
3195
|
-
};
|
|
3196
|
-
}
|
|
3197
|
-
| undefined;
|
|
3198
|
-
return storage?.markdown?.getMarkdown?.() ?? '';
|
|
3199
|
-
};
|
|
3411
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
3200
3412
|
|
|
3201
|
-
const
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
`<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='420' viewBox='0 0 1200 420'>
|
|
3205
|
-
<rect width='1200' height='420' fill='#090B14' stroke='#3F3F46' stroke-width='3' stroke-dasharray='10 10' rx='12' />
|
|
3206
|
-
<g font-family='Inter,Arial,sans-serif' text-anchor='middle' fill='#A1A1AA'>
|
|
3207
|
-
<text x='600' y='190' font-size='30' font-weight='700'>Uploading image...</text>
|
|
3208
|
-
<text x='600' y='235' font-size='22'>Please wait while we process your file</text>
|
|
3209
|
-
</g>
|
|
3210
|
-
</svg>`
|
|
3211
|
-
);
|
|
3413
|
+
const getMarkdown = (ed: Editor | null | undefined): string =>
|
|
3414
|
+
(ed?.storage as { markdown?: { getMarkdown?: () => string } } | undefined)
|
|
3415
|
+
?.markdown?.getMarkdown?.() ?? '';
|
|
3212
3416
|
|
|
3213
|
-
const
|
|
3214
|
-
|
|
3417
|
+
const svg = (body: string) =>
|
|
3418
|
+
'data:image/svg+xml;utf8,' +
|
|
3215
3419
|
encodeURIComponent(
|
|
3216
|
-
`<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='420' viewBox='0 0 1200 420'
|
|
3217
|
-
<rect width='1200' height='420' fill='#090B14' stroke='#3F3F46' stroke-width='3' stroke-dasharray='10 10' rx='12' />
|
|
3218
|
-
<g font-family='Inter,Arial,sans-serif' text-anchor='middle'>
|
|
3219
|
-
<text x='600' y='210' font-size='34' font-weight='700' fill='#E4E4E7'>Click to upload or drag and drop</text>
|
|
3220
|
-
<text x='600' y='255' font-size='24' font-weight='500' fill='#A1A1AA'>Maximum 3 files, 5MB each.</text>
|
|
3221
|
-
</g>
|
|
3222
|
-
</svg>`
|
|
3420
|
+
`<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='420' viewBox='0 0 1200 420'>${body}</svg>`
|
|
3223
3421
|
);
|
|
3224
3422
|
|
|
3225
|
-
const
|
|
3226
|
-
|
|
3423
|
+
const RECT = `<rect width='1200' height='420' fill='#090B14' stroke='#3F3F46' stroke-width='3' stroke-dasharray='10 10' rx='12'/>`;
|
|
3424
|
+
|
|
3425
|
+
const UPLOADING_SRC = svg(
|
|
3426
|
+
RECT +
|
|
3427
|
+
`<text x='600' y='215' font-family='Inter,Arial,sans-serif' font-size='28' font-weight='700' fill='#A1A1AA' text-anchor='middle'>Uploading image…</text>`
|
|
3428
|
+
);
|
|
3429
|
+
|
|
3430
|
+
const PICKER_SRC = svg(
|
|
3431
|
+
RECT +
|
|
3432
|
+
`<text x='600' y='200' font-family='Inter,Arial,sans-serif' font-size='32' font-weight='700' fill='#E4E4E7' text-anchor='middle'>Click to upload or drag & drop</text>` +
|
|
3433
|
+
`<text x='600' y='248' font-family='Inter,Arial,sans-serif' font-size='22' fill='#A1A1AA' text-anchor='middle'>Max 5 MB per file</text>`
|
|
3434
|
+
);
|
|
3435
|
+
|
|
3436
|
+
const patchImage = (
|
|
3437
|
+
ed: Editor,
|
|
3227
3438
|
uploadId: string,
|
|
3228
3439
|
patch: Record<string, unknown>
|
|
3229
3440
|
): boolean => {
|
|
3230
|
-
let
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3441
|
+
let pos: number | null = null;
|
|
3442
|
+
ed.state.doc.descendants(
|
|
3443
|
+
(node: { type: { name: string }; attrs?: Record<string, unknown> }, p: number) => {
|
|
3444
|
+
if (node.type.name === 'image' && node.attrs?.uploadId === uploadId) {
|
|
3445
|
+
pos = p;
|
|
3446
|
+
return false;
|
|
3447
|
+
}
|
|
3448
|
+
return true;
|
|
3235
3449
|
}
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
const attrs = { ...current.attrs, ...patch };
|
|
3242
|
-
editor.view.dispatch(editor.state.tr.setNodeMarkup(targetPos, undefined, attrs));
|
|
3450
|
+
);
|
|
3451
|
+
if (pos == null) return false;
|
|
3452
|
+
const cur = ed.state.doc.nodeAt(pos);
|
|
3453
|
+
if (!cur) return false;
|
|
3454
|
+
ed.view.dispatch(ed.state.tr.setNodeMarkup(pos, undefined, { ...cur.attrs, ...patch }));
|
|
3243
3455
|
return true;
|
|
3244
3456
|
};
|
|
3245
3457
|
|
|
3458
|
+
// Extensions defined outside component — stable reference, no re-creation on render
|
|
3459
|
+
const EXTENSIONS = [
|
|
3460
|
+
StarterKit,
|
|
3461
|
+
Link.configure({ openOnClick: false, autolink: true }),
|
|
3462
|
+
UploadableImage,
|
|
3463
|
+
// NOTE: Underline is intentionally excluded.
|
|
3464
|
+
// tiptap-markdown with html:false cannot round-trip <u> tags, so underline
|
|
3465
|
+
// would be silently dropped on save. Use bold/italic instead.
|
|
3466
|
+
Markdown.configure({ html: false }),
|
|
3467
|
+
];
|
|
3468
|
+
|
|
3469
|
+
const EDITOR_CLASSES =
|
|
3470
|
+
'min-h-[220px] p-4 outline-none';
|
|
3471
|
+
|
|
3472
|
+
// ── Studio editor component ───────────────────────────────────────────────────
|
|
3473
|
+
|
|
3246
3474
|
const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
|
|
3247
3475
|
const { assets } = useConfig();
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
const
|
|
3476
|
+
|
|
3477
|
+
// DOM refs
|
|
3478
|
+
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
|
3479
|
+
const sectionRef = React.useRef<HTMLElement | null>(null);
|
|
3251
3480
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
const
|
|
3255
|
-
const
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3481
|
+
|
|
3482
|
+
// Editor & upload state
|
|
3483
|
+
const editorRef = React.useRef<Editor | null>(null);
|
|
3484
|
+
const pendingUploads = React.useRef<Map<string, Promise<void>>>(new Map());
|
|
3485
|
+
const pendingPickerId = React.useRef<string | null>(null);
|
|
3486
|
+
|
|
3487
|
+
// Markdown sync refs
|
|
3488
|
+
const latestMd = React.useRef<string>(data.content ?? '');
|
|
3489
|
+
const emittedMd = React.useRef<string>(data.content ?? '');
|
|
3490
|
+
|
|
3491
|
+
// Link popover state
|
|
3492
|
+
const [linkOpen, setLinkOpen] = React.useState(false);
|
|
3493
|
+
const [linkUrl, setLinkUrl] = React.useState('');
|
|
3494
|
+
const linkInputRef = React.useRef<HTMLInputElement | null>(null);
|
|
3495
|
+
|
|
3496
|
+
// ── Core helpers ────────────────────────────────────────────────────────
|
|
3497
|
+
|
|
3498
|
+
const getSectionId = React.useCallback((): string | null => {
|
|
3499
|
+
const el =
|
|
3500
|
+
sectionRef.current ??
|
|
3501
|
+
(hostRef.current?.closest('[data-section-id]') as HTMLElement | null);
|
|
3502
|
+
sectionRef.current = el;
|
|
3503
|
+
return el?.getAttribute('data-section-id') ?? null;
|
|
3263
3504
|
}, []);
|
|
3264
3505
|
|
|
3265
|
-
const
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3506
|
+
const emit = React.useCallback(
|
|
3507
|
+
(markdown: string) => {
|
|
3508
|
+
latestMd.current = markdown;
|
|
3509
|
+
const sectionId = getSectionId();
|
|
3510
|
+
if (!sectionId) return;
|
|
3511
|
+
window.parent.postMessage(
|
|
3512
|
+
{
|
|
3513
|
+
type: STUDIO_EVENTS.INLINE_FIELD_UPDATE,
|
|
3514
|
+
sectionId,
|
|
3515
|
+
fieldKey: 'content',
|
|
3516
|
+
value: markdown,
|
|
3517
|
+
},
|
|
3518
|
+
window.location.origin
|
|
3519
|
+
);
|
|
3520
|
+
emittedMd.current = markdown;
|
|
3521
|
+
},
|
|
3522
|
+
[getSectionId]
|
|
3523
|
+
);
|
|
3524
|
+
|
|
3525
|
+
const setFocusLock = React.useCallback((on: boolean) => {
|
|
3526
|
+
sectionRef.current?.classList.toggle('jp-editorial-focus', on);
|
|
3527
|
+
}, []);
|
|
3528
|
+
|
|
3529
|
+
// ── Image upload ─────────────────────────────────────────────────────────
|
|
3280
3530
|
|
|
3281
|
-
const
|
|
3531
|
+
const insertPlaceholder = React.useCallback(
|
|
3282
3532
|
(uploadId: string, src: string, awaitingUpload: boolean) => {
|
|
3283
|
-
const
|
|
3284
|
-
if (!
|
|
3285
|
-
|
|
3286
|
-
.chain()
|
|
3533
|
+
const ed = editorRef.current;
|
|
3534
|
+
if (!ed) return;
|
|
3535
|
+
ed.chain()
|
|
3287
3536
|
.focus()
|
|
3288
3537
|
.setImage({
|
|
3289
3538
|
src,
|
|
3290
3539
|
alt: 'upload-placeholder',
|
|
3291
|
-
title: awaitingUpload ? 'Click to upload' : 'Uploading
|
|
3540
|
+
title: awaitingUpload ? 'Click to upload' : 'Uploading…',
|
|
3292
3541
|
uploadId,
|
|
3293
3542
|
uploading: !awaitingUpload,
|
|
3294
3543
|
awaitingUpload,
|
|
3295
3544
|
uploadError: false,
|
|
3296
3545
|
} as any)
|
|
3297
3546
|
.run();
|
|
3298
|
-
|
|
3547
|
+
emit(getMarkdown(ed));
|
|
3299
3548
|
},
|
|
3300
|
-
[
|
|
3549
|
+
[emit]
|
|
3301
3550
|
);
|
|
3302
3551
|
|
|
3303
|
-
const
|
|
3552
|
+
const doUpload = React.useCallback(
|
|
3304
3553
|
async (uploadId: string, file: File) => {
|
|
3305
|
-
const
|
|
3306
|
-
if (!
|
|
3307
|
-
const
|
|
3308
|
-
if (!
|
|
3309
|
-
|
|
3310
|
-
src:
|
|
3554
|
+
const uploadFn = assets?.onAssetUpload;
|
|
3555
|
+
if (!uploadFn) return;
|
|
3556
|
+
const ed = editorRef.current;
|
|
3557
|
+
if (!ed) return;
|
|
3558
|
+
patchImage(ed, uploadId, {
|
|
3559
|
+
src: UPLOADING_SRC,
|
|
3311
3560
|
alt: file.name,
|
|
3312
3561
|
title: file.name,
|
|
3313
3562
|
uploading: true,
|
|
@@ -3316,10 +3565,10 @@ const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
|
|
|
3316
3565
|
});
|
|
3317
3566
|
const task = (async () => {
|
|
3318
3567
|
try {
|
|
3319
|
-
const url = await
|
|
3320
|
-
const
|
|
3321
|
-
if (
|
|
3322
|
-
|
|
3568
|
+
const url = await uploadFn(file);
|
|
3569
|
+
const cur = editorRef.current;
|
|
3570
|
+
if (cur) {
|
|
3571
|
+
patchImage(cur, uploadId, {
|
|
3323
3572
|
src: url,
|
|
3324
3573
|
alt: file.name,
|
|
3325
3574
|
title: file.name,
|
|
@@ -3328,328 +3577,453 @@ const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
|
|
|
3328
3577
|
awaitingUpload: false,
|
|
3329
3578
|
uploadError: false,
|
|
3330
3579
|
});
|
|
3331
|
-
|
|
3580
|
+
emit(getMarkdown(cur));
|
|
3332
3581
|
}
|
|
3333
3582
|
} catch (err) {
|
|
3334
3583
|
console.error('[tiptap] upload failed', err);
|
|
3335
|
-
const
|
|
3336
|
-
if (
|
|
3337
|
-
|
|
3584
|
+
const cur = editorRef.current;
|
|
3585
|
+
if (cur)
|
|
3586
|
+
patchImage(cur, uploadId, {
|
|
3338
3587
|
uploading: false,
|
|
3339
3588
|
awaitingUpload: false,
|
|
3340
3589
|
uploadError: true,
|
|
3341
3590
|
});
|
|
3342
|
-
}
|
|
3343
3591
|
} finally {
|
|
3344
|
-
|
|
3592
|
+
pendingUploads.current.delete(uploadId);
|
|
3345
3593
|
}
|
|
3346
3594
|
})();
|
|
3347
|
-
|
|
3595
|
+
pendingUploads.current.set(uploadId, task);
|
|
3348
3596
|
await task;
|
|
3349
3597
|
},
|
|
3350
|
-
[assets,
|
|
3598
|
+
[assets, emit]
|
|
3351
3599
|
);
|
|
3352
3600
|
|
|
3353
|
-
const
|
|
3601
|
+
const uploadFile = React.useCallback(
|
|
3354
3602
|
async (file: File) => {
|
|
3355
|
-
const
|
|
3356
|
-
|
|
3357
|
-
await
|
|
3603
|
+
const id = crypto.randomUUID();
|
|
3604
|
+
insertPlaceholder(id, UPLOADING_SRC, false);
|
|
3605
|
+
await doUpload(id, file);
|
|
3358
3606
|
},
|
|
3359
|
-
[
|
|
3607
|
+
[insertPlaceholder, doUpload]
|
|
3360
3608
|
);
|
|
3361
3609
|
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
attributes: {
|
|
3382
|
-
class:
|
|
3383
|
-
'prose prose-invert max-w-none min-h-[220px] rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 text-zinc-100 outline-none',
|
|
3384
|
-
},
|
|
3385
|
-
handleDrop: (_view: unknown, event: DragEvent) => {
|
|
3386
|
-
const file = event.dataTransfer?.files?.[0];
|
|
3387
|
-
if (!file || !file.type.startsWith('image/') || !assets?.onAssetUpload) return false;
|
|
3388
|
-
event.preventDefault();
|
|
3389
|
-
void (async () => {
|
|
3390
|
-
try {
|
|
3391
|
-
await uploadImage(file);
|
|
3392
|
-
} catch (err) {
|
|
3393
|
-
console.error('[tiptap] upload failed on drop', err);
|
|
3394
|
-
}
|
|
3395
|
-
})();
|
|
3396
|
-
return true;
|
|
3397
|
-
},
|
|
3398
|
-
handlePaste: (_view: unknown, event: ClipboardEvent) => {
|
|
3399
|
-
const file = Array.from(event.clipboardData?.files ?? []).find((f: File) =>
|
|
3400
|
-
f.type.startsWith('image/')
|
|
3401
|
-
);
|
|
3402
|
-
if (!file || !assets?.onAssetUpload) return false;
|
|
3403
|
-
event.preventDefault();
|
|
3404
|
-
void (async () => {
|
|
3405
|
-
try {
|
|
3406
|
-
await uploadImage(file);
|
|
3407
|
-
} catch (err) {
|
|
3408
|
-
console.error('[tiptap] upload failed on paste', err);
|
|
3409
|
-
}
|
|
3410
|
-
})();
|
|
3411
|
-
return true;
|
|
3412
|
-
},
|
|
3413
|
-
handleClickOn: (_view: unknown, _pos: number, node: { type: { name: string }; attrs?: Record<string, unknown> }) => {
|
|
3414
|
-
if (node.type.name !== 'image') return false;
|
|
3415
|
-
if (node.attrs?.awaitingUpload !== true) return false;
|
|
3416
|
-
const uploadId = typeof node.attrs?.uploadId === 'string' ? node.attrs.uploadId : null;
|
|
3417
|
-
if (!uploadId) return false;
|
|
3418
|
-
pendingPickerUploadIdRef.current = uploadId;
|
|
3419
|
-
fileInputRef.current?.click();
|
|
3420
|
-
return true;
|
|
3421
|
-
},
|
|
3422
|
-
},
|
|
3423
|
-
onUpdate: ({ editor: currentEditor }: { editor: Editor }) => {
|
|
3424
|
-
const markdown = getEditorMarkdown(currentEditor);
|
|
3425
|
-
emitInlineUpdate(markdown);
|
|
3610
|
+
// ── Stable editorProps via refs (avoids stale closures in useEditor) ─────
|
|
3611
|
+
// Reads refs at call-time so useEditor never needs to rebuild the editor.
|
|
3612
|
+
|
|
3613
|
+
const uploadFileRef = React.useRef(uploadFile);
|
|
3614
|
+
uploadFileRef.current = uploadFile;
|
|
3615
|
+
const assetsRef = React.useRef(assets);
|
|
3616
|
+
assetsRef.current = assets;
|
|
3617
|
+
|
|
3618
|
+
const editorProps = React.useMemo(
|
|
3619
|
+
() => ({
|
|
3620
|
+
attributes: { class: EDITOR_CLASSES },
|
|
3621
|
+
handleDrop: (_v: unknown, event: DragEvent) => {
|
|
3622
|
+
const file = event.dataTransfer?.files?.[0];
|
|
3623
|
+
if (!file?.type.startsWith('image/') || !assetsRef.current?.onAssetUpload) return false;
|
|
3624
|
+
event.preventDefault();
|
|
3625
|
+
void uploadFileRef.current(file).catch((e) =>
|
|
3626
|
+
console.error('[tiptap] drop upload failed', e)
|
|
3627
|
+
);
|
|
3628
|
+
return true;
|
|
3426
3629
|
},
|
|
3427
|
-
|
|
3428
|
-
|
|
3630
|
+
handlePaste: (_v: unknown, event: ClipboardEvent) => {
|
|
3631
|
+
const file = Array.from(event.clipboardData?.files ?? []).find((f: File) =>
|
|
3632
|
+
f.type.startsWith('image/')
|
|
3633
|
+
);
|
|
3634
|
+
if (!file || !assetsRef.current?.onAssetUpload) return false;
|
|
3635
|
+
event.preventDefault();
|
|
3636
|
+
void uploadFileRef.current(file).catch((e) =>
|
|
3637
|
+
console.error('[tiptap] paste upload failed', e)
|
|
3638
|
+
);
|
|
3639
|
+
return true;
|
|
3429
3640
|
},
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3641
|
+
handleClickOn: (
|
|
3642
|
+
_v: unknown,
|
|
3643
|
+
_p: number,
|
|
3644
|
+
node: { type: { name: string }; attrs?: Record<string, unknown> }
|
|
3645
|
+
) => {
|
|
3646
|
+
if (node.type.name !== 'image' || node.attrs?.awaitingUpload !== true) return false;
|
|
3647
|
+
const uploadId =
|
|
3648
|
+
typeof node.attrs?.uploadId === 'string' ? node.attrs.uploadId : null;
|
|
3649
|
+
if (!uploadId) return false;
|
|
3650
|
+
pendingPickerId.current = uploadId;
|
|
3651
|
+
fileInputRef.current?.click();
|
|
3652
|
+
return true;
|
|
3436
3653
|
},
|
|
3437
|
-
}
|
|
3654
|
+
}),
|
|
3655
|
+
[] // intentionally empty — reads refs at call-time
|
|
3438
3656
|
);
|
|
3439
3657
|
|
|
3658
|
+
// ── useEditor ─────────────────────────────────────────────────────────────
|
|
3659
|
+
|
|
3660
|
+
const emitRef = React.useRef(emit);
|
|
3661
|
+
emitRef.current = emit;
|
|
3662
|
+
|
|
3663
|
+
const editor = useEditor({
|
|
3664
|
+
extensions: EXTENSIONS,
|
|
3665
|
+
content: data.content ?? '',
|
|
3666
|
+
autofocus: false,
|
|
3667
|
+
editorProps,
|
|
3668
|
+
onUpdate: ({ editor: e }: { editor: Editor }) => emitRef.current(getMarkdown(e)),
|
|
3669
|
+
onFocus: () => setFocusLock(true),
|
|
3670
|
+
onBlur: ({ editor: e }: { editor: Editor }) => {
|
|
3671
|
+
const md = getMarkdown(e);
|
|
3672
|
+
if (md !== emittedMd.current) emitRef.current(md);
|
|
3673
|
+
setFocusLock(false);
|
|
3674
|
+
},
|
|
3675
|
+
});
|
|
3676
|
+
|
|
3677
|
+
// ── Effects ───────────────────────────────────────────────────────────────
|
|
3678
|
+
|
|
3440
3679
|
React.useEffect(() => {
|
|
3441
|
-
|
|
3442
|
-
|
|
3680
|
+
sectionRef.current =
|
|
3681
|
+
hostRef.current?.closest('[data-section-id]') as HTMLElement | null;
|
|
3443
3682
|
}, []);
|
|
3444
3683
|
|
|
3445
3684
|
React.useEffect(() => {
|
|
3446
|
-
|
|
3685
|
+
editorRef.current = editor ?? null;
|
|
3447
3686
|
}, [editor]);
|
|
3448
3687
|
|
|
3688
|
+
// Sync external content changes into editor (e.g. engine-level undo)
|
|
3689
|
+
React.useEffect(() => {
|
|
3690
|
+
if (!editor) return;
|
|
3691
|
+
const next = data.content ?? '';
|
|
3692
|
+
if (next === latestMd.current) return;
|
|
3693
|
+
editor.commands.setContent(next);
|
|
3694
|
+
latestMd.current = next;
|
|
3695
|
+
}, [data.content, editor]);
|
|
3696
|
+
|
|
3697
|
+
// PreviewEntry receives REQUEST_INLINE_FLUSH via postMessage and re-dispatches
|
|
3698
|
+
// it as a DOM CustomEvent. Listen to the DOM event — do NOT send INLINE_FLUSHED
|
|
3699
|
+
// back (PreviewEntry already handles that acknowledgement).
|
|
3449
3700
|
React.useEffect(() => {
|
|
3450
|
-
const
|
|
3701
|
+
const handler = () => {
|
|
3451
3702
|
void (async () => {
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
await Promise.allSettled(pending);
|
|
3703
|
+
if (pendingUploads.current.size > 0) {
|
|
3704
|
+
await Promise.allSettled(Array.from(pendingUploads.current.values()));
|
|
3455
3705
|
}
|
|
3456
|
-
|
|
3457
|
-
emitInlineUpdate(markdown);
|
|
3706
|
+
emitRef.current(getMarkdown(editorRef.current));
|
|
3458
3707
|
})();
|
|
3459
3708
|
};
|
|
3460
|
-
window.addEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH,
|
|
3461
|
-
return () =>
|
|
3462
|
-
|
|
3463
|
-
};
|
|
3464
|
-
}, [emitInlineUpdate]);
|
|
3709
|
+
window.addEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handler);
|
|
3710
|
+
return () => window.removeEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handler);
|
|
3711
|
+
}, []);
|
|
3465
3712
|
|
|
3713
|
+
// File input cancel: modern browsers fire a 'cancel' event when user
|
|
3714
|
+
// closes the picker without selecting a file.
|
|
3466
3715
|
React.useEffect(() => {
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3716
|
+
const input = fileInputRef.current;
|
|
3717
|
+
if (!input) return;
|
|
3718
|
+
const onCancel = () => {
|
|
3719
|
+
const pickId = pendingPickerId.current;
|
|
3720
|
+
if (pickId && editorRef.current) {
|
|
3721
|
+
patchImage(editorRef.current, pickId, {
|
|
3722
|
+
uploading: false,
|
|
3723
|
+
awaitingUpload: false,
|
|
3724
|
+
uploadError: true,
|
|
3725
|
+
});
|
|
3726
|
+
}
|
|
3727
|
+
pendingPickerId.current = null;
|
|
3728
|
+
};
|
|
3729
|
+
input.addEventListener('cancel', onCancel);
|
|
3730
|
+
return () => input.removeEventListener('cancel', onCancel);
|
|
3731
|
+
}, []);
|
|
3473
3732
|
|
|
3733
|
+
// Emit on unmount (safety flush)
|
|
3474
3734
|
React.useEffect(
|
|
3475
3735
|
() => () => {
|
|
3476
|
-
const
|
|
3477
|
-
|
|
3478
|
-
if (markdown !== latestEmittedMarkdownRef.current) {
|
|
3479
|
-
emitInlineUpdate(markdown);
|
|
3480
|
-
}
|
|
3736
|
+
const md = getMarkdown(editorRef.current);
|
|
3737
|
+
if (md !== emittedMd.current) emitRef.current(md);
|
|
3481
3738
|
setFocusLock(false);
|
|
3482
3739
|
},
|
|
3483
|
-
[
|
|
3740
|
+
[setFocusLock]
|
|
3484
3741
|
);
|
|
3485
3742
|
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3743
|
+
// Focus link input when popover opens
|
|
3744
|
+
React.useEffect(() => {
|
|
3745
|
+
if (linkOpen) {
|
|
3746
|
+
const t = setTimeout(() => linkInputRef.current?.focus(), 0);
|
|
3747
|
+
return () => clearTimeout(t);
|
|
3748
|
+
}
|
|
3749
|
+
}, [linkOpen]);
|
|
3750
|
+
|
|
3751
|
+
// ── Toolbar actions ───────────────────────────────────────────────────────
|
|
3752
|
+
|
|
3753
|
+
const openLink = () => {
|
|
3489
3754
|
if (!editor) return;
|
|
3490
3755
|
const prev = editor.getAttributes('link').href as string | undefined;
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3756
|
+
setLinkUrl(prev ?? 'https://');
|
|
3757
|
+
setLinkOpen(true);
|
|
3758
|
+
};
|
|
3759
|
+
|
|
3760
|
+
const applyLink = () => {
|
|
3761
|
+
if (!editor) return;
|
|
3762
|
+
const href = linkUrl.trim();
|
|
3763
|
+
if (href === '' || href === 'https://') {
|
|
3494
3764
|
editor.chain().focus().unsetLink().run();
|
|
3495
|
-
|
|
3765
|
+
} else {
|
|
3766
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
|
|
3496
3767
|
}
|
|
3497
|
-
|
|
3768
|
+
setLinkOpen(false);
|
|
3498
3769
|
};
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3770
|
+
|
|
3771
|
+
const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
3772
|
+
const file = e.target.files?.[0];
|
|
3773
|
+
const pickId = pendingPickerId.current;
|
|
3774
|
+
e.target.value = '';
|
|
3775
|
+
|
|
3776
|
+
if (!file?.type.startsWith('image/') || !assets?.onAssetUpload) {
|
|
3777
|
+
// File picker opened but no valid file selected — clean up placeholder
|
|
3778
|
+
if (pickId && editorRef.current) {
|
|
3779
|
+
patchImage(editorRef.current, pickId, {
|
|
3780
|
+
uploading: false,
|
|
3781
|
+
awaitingUpload: false,
|
|
3782
|
+
uploadError: true,
|
|
3783
|
+
});
|
|
3784
|
+
}
|
|
3785
|
+
pendingPickerId.current = null;
|
|
3513
3786
|
return;
|
|
3514
3787
|
}
|
|
3788
|
+
|
|
3515
3789
|
void (async () => {
|
|
3516
3790
|
try {
|
|
3517
|
-
if (
|
|
3518
|
-
await
|
|
3519
|
-
|
|
3791
|
+
if (pickId) {
|
|
3792
|
+
await doUpload(pickId, file);
|
|
3793
|
+
pendingPickerId.current = null;
|
|
3520
3794
|
} else {
|
|
3521
|
-
await
|
|
3795
|
+
await uploadFile(file);
|
|
3522
3796
|
}
|
|
3523
3797
|
} catch (err) {
|
|
3524
|
-
console.error('[tiptap] upload failed
|
|
3525
|
-
|
|
3526
|
-
event.target.value = '';
|
|
3798
|
+
console.error('[tiptap] picker upload failed', err);
|
|
3799
|
+
pendingPickerId.current = null;
|
|
3527
3800
|
}
|
|
3528
3801
|
})();
|
|
3529
3802
|
};
|
|
3530
|
-
|
|
3803
|
+
|
|
3804
|
+
const onPickImage = () => {
|
|
3805
|
+
if (pendingPickerId.current) return;
|
|
3806
|
+
const id = crypto.randomUUID();
|
|
3807
|
+
pendingPickerId.current = id;
|
|
3808
|
+
insertPlaceholder(id, PICKER_SRC, true);
|
|
3809
|
+
};
|
|
3810
|
+
|
|
3811
|
+
const isActive = (name: string, attrs?: Record<string, unknown>) =>
|
|
3812
|
+
editor?.isActive(name, attrs) ?? false;
|
|
3813
|
+
|
|
3814
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
3531
3815
|
|
|
3532
3816
|
return (
|
|
3533
|
-
<div
|
|
3534
|
-
ref={editorHostRef}
|
|
3535
|
-
data-jp-field="content"
|
|
3536
|
-
className="space-y-3"
|
|
3537
|
-
>
|
|
3817
|
+
<div ref={hostRef} data-jp-field="content" className="space-y-2">
|
|
3538
3818
|
{editor && (
|
|
3539
3819
|
<div
|
|
3540
3820
|
data-jp-ignore-select="true"
|
|
3541
|
-
className="sticky top-
|
|
3821
|
+
className="sticky top-0 z-[65] border-b border-zinc-800 bg-zinc-950"
|
|
3542
3822
|
>
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
<
|
|
3823
|
+
{/* ── Main toolbar ── */}
|
|
3824
|
+
<div className="flex flex-wrap items-center justify-center gap-1 p-2">
|
|
3825
|
+
{/* History */}
|
|
3826
|
+
<Btn title="Undo" onClick={() => editor.chain().focus().undo().run()}>
|
|
3827
|
+
<Undo2 size={13} />
|
|
3828
|
+
</Btn>
|
|
3829
|
+
<Btn title="Redo" onClick={() => editor.chain().focus().redo().run()}>
|
|
3830
|
+
<Redo2 size={13} />
|
|
3831
|
+
</Btn>
|
|
3832
|
+
<Sep />
|
|
3833
|
+
|
|
3834
|
+
{/* Block type */}
|
|
3835
|
+
<Btn
|
|
3836
|
+
active={isActive('paragraph')}
|
|
3837
|
+
title="Paragraph"
|
|
3838
|
+
onClick={() => editor.chain().focus().setParagraph().run()}
|
|
3839
|
+
>
|
|
3553
3840
|
P
|
|
3554
|
-
</
|
|
3555
|
-
<
|
|
3841
|
+
</Btn>
|
|
3842
|
+
<Btn
|
|
3843
|
+
active={isActive('heading', { level: 1 })}
|
|
3844
|
+
title="Heading 1"
|
|
3845
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
3846
|
+
>
|
|
3556
3847
|
H1
|
|
3557
|
-
</
|
|
3558
|
-
<
|
|
3848
|
+
</Btn>
|
|
3849
|
+
<Btn
|
|
3850
|
+
active={isActive('heading', { level: 2 })}
|
|
3851
|
+
title="Heading 2"
|
|
3852
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
3853
|
+
>
|
|
3559
3854
|
H2
|
|
3560
|
-
</
|
|
3561
|
-
<
|
|
3855
|
+
</Btn>
|
|
3856
|
+
<Btn
|
|
3857
|
+
active={isActive('heading', { level: 3 })}
|
|
3858
|
+
title="Heading 3"
|
|
3859
|
+
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
3860
|
+
>
|
|
3562
3861
|
H3
|
|
3563
|
-
</
|
|
3564
|
-
<
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
<
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
<
|
|
3580
|
-
</
|
|
3581
|
-
<
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
<
|
|
3594
|
-
</
|
|
3595
|
-
<
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
<
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
<
|
|
3611
|
-
</
|
|
3862
|
+
</Btn>
|
|
3863
|
+
<Sep />
|
|
3864
|
+
|
|
3865
|
+
{/* Inline marks */}
|
|
3866
|
+
<Btn
|
|
3867
|
+
active={isActive('bold')}
|
|
3868
|
+
title="Bold (Ctrl+B)"
|
|
3869
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
3870
|
+
>
|
|
3871
|
+
<Bold size={13} />
|
|
3872
|
+
</Btn>
|
|
3873
|
+
<Btn
|
|
3874
|
+
active={isActive('italic')}
|
|
3875
|
+
title="Italic (Ctrl+I)"
|
|
3876
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
3877
|
+
>
|
|
3878
|
+
<Italic size={13} />
|
|
3879
|
+
</Btn>
|
|
3880
|
+
<Btn
|
|
3881
|
+
active={isActive('strike')}
|
|
3882
|
+
title="Strikethrough"
|
|
3883
|
+
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
3884
|
+
>
|
|
3885
|
+
<Strikethrough size={13} />
|
|
3886
|
+
</Btn>
|
|
3887
|
+
<Btn
|
|
3888
|
+
active={isActive('code')}
|
|
3889
|
+
title="Inline code"
|
|
3890
|
+
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
3891
|
+
>
|
|
3892
|
+
<Code2 size={13} />
|
|
3893
|
+
</Btn>
|
|
3894
|
+
<Sep />
|
|
3895
|
+
|
|
3896
|
+
{/* Lists & block nodes */}
|
|
3897
|
+
<Btn
|
|
3898
|
+
active={isActive('bulletList')}
|
|
3899
|
+
title="Bullet list"
|
|
3900
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
3901
|
+
>
|
|
3902
|
+
<List size={13} />
|
|
3903
|
+
</Btn>
|
|
3904
|
+
<Btn
|
|
3905
|
+
active={isActive('orderedList')}
|
|
3906
|
+
title="Ordered list"
|
|
3907
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
3908
|
+
>
|
|
3909
|
+
<ListOrdered size={13} />
|
|
3910
|
+
</Btn>
|
|
3911
|
+
<Btn
|
|
3912
|
+
active={isActive('blockquote')}
|
|
3913
|
+
title="Blockquote"
|
|
3914
|
+
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
3915
|
+
>
|
|
3916
|
+
<Quote size={13} />
|
|
3917
|
+
</Btn>
|
|
3918
|
+
<Btn
|
|
3919
|
+
active={isActive('codeBlock')}
|
|
3920
|
+
title="Code block"
|
|
3921
|
+
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
3922
|
+
>
|
|
3923
|
+
<SquareCode size={13} />
|
|
3924
|
+
</Btn>
|
|
3925
|
+
<Sep />
|
|
3926
|
+
|
|
3927
|
+
{/* Link / image / clear */}
|
|
3928
|
+
<Btn
|
|
3929
|
+
active={isActive('link') || linkOpen}
|
|
3930
|
+
title="Set link"
|
|
3931
|
+
onClick={openLink}
|
|
3932
|
+
>
|
|
3933
|
+
<Link2 size={13} />
|
|
3934
|
+
</Btn>
|
|
3935
|
+
<Btn
|
|
3936
|
+
title="Remove link"
|
|
3937
|
+
onClick={() => editor.chain().focus().unsetLink().run()}
|
|
3938
|
+
>
|
|
3939
|
+
<Unlink2 size={13} />
|
|
3940
|
+
</Btn>
|
|
3941
|
+
<Btn title="Insert image" onClick={onPickImage}>
|
|
3942
|
+
<ImagePlus size={13} />
|
|
3943
|
+
</Btn>
|
|
3944
|
+
<Btn
|
|
3945
|
+
title="Clear formatting"
|
|
3946
|
+
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
|
|
3947
|
+
>
|
|
3948
|
+
<Eraser size={13} />
|
|
3949
|
+
</Btn>
|
|
3612
3950
|
</div>
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3951
|
+
|
|
3952
|
+
{/* ── Link popover row (replaces window.prompt) ── */}
|
|
3953
|
+
{linkOpen && (
|
|
3954
|
+
<div className="flex items-center gap-2 border-t border-zinc-700 px-2 py-1.5">
|
|
3955
|
+
<Link2 size={12} className="shrink-0 text-zinc-500" />
|
|
3956
|
+
<input
|
|
3957
|
+
ref={linkInputRef}
|
|
3958
|
+
type="url"
|
|
3959
|
+
value={linkUrl}
|
|
3960
|
+
onChange={(e) => setLinkUrl(e.target.value)}
|
|
3961
|
+
onKeyDown={(e) => {
|
|
3962
|
+
if (e.key === 'Enter') {
|
|
3963
|
+
e.preventDefault();
|
|
3964
|
+
applyLink();
|
|
3965
|
+
}
|
|
3966
|
+
if (e.key === 'Escape') setLinkOpen(false);
|
|
3967
|
+
}}
|
|
3968
|
+
placeholder="https://example.com"
|
|
3969
|
+
className="min-w-0 flex-1 bg-transparent text-xs text-zinc-100 placeholder:text-zinc-500 outline-none"
|
|
3970
|
+
/>
|
|
3971
|
+
<button
|
|
3972
|
+
type="button"
|
|
3973
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
3974
|
+
onClick={applyLink}
|
|
3975
|
+
className="shrink-0 rounded px-2 py-0.5 text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors"
|
|
3976
|
+
>
|
|
3977
|
+
Set
|
|
3978
|
+
</button>
|
|
3979
|
+
<button
|
|
3980
|
+
type="button"
|
|
3981
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
3982
|
+
onClick={() => setLinkOpen(false)}
|
|
3983
|
+
className="shrink-0 rounded px-2 py-0.5 text-xs bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
|
|
3984
|
+
>
|
|
3985
|
+
Cancel
|
|
3986
|
+
</button>
|
|
3987
|
+
</div>
|
|
3988
|
+
)}
|
|
3620
3989
|
</div>
|
|
3621
3990
|
)}
|
|
3991
|
+
|
|
3622
3992
|
<EditorContent editor={editor} className="jp-simple-editor" />
|
|
3993
|
+
|
|
3994
|
+
<input
|
|
3995
|
+
ref={fileInputRef}
|
|
3996
|
+
type="file"
|
|
3997
|
+
accept="image/*"
|
|
3998
|
+
className="hidden"
|
|
3999
|
+
onChange={onFileSelected}
|
|
4000
|
+
/>
|
|
3623
4001
|
</div>
|
|
3624
4002
|
);
|
|
3625
4003
|
};
|
|
3626
4004
|
|
|
4005
|
+
// ── Public view ───────────────────────────────────────────────────────────────
|
|
4006
|
+
|
|
3627
4007
|
const PublicTiptapContent: React.FC<{ content: string }> = ({ content }) => (
|
|
3628
|
-
<article className="
|
|
4008
|
+
<article className="jp-tiptap-content" data-jp-field="content">
|
|
3629
4009
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
|
|
3630
4010
|
{content}
|
|
3631
4011
|
</ReactMarkdown>
|
|
3632
4012
|
</article>
|
|
3633
4013
|
);
|
|
3634
4014
|
|
|
4015
|
+
// ── Export ────────────────────────────────────────────────────────────────────
|
|
4016
|
+
|
|
3635
4017
|
export const Tiptap: React.FC<{ data: TiptapData; settings?: TiptapSettings }> = ({ data }) => {
|
|
3636
4018
|
const { mode } = useStudio();
|
|
3637
|
-
const isStudio = mode === 'studio';
|
|
3638
|
-
|
|
3639
4019
|
return (
|
|
3640
|
-
<section
|
|
3641
|
-
|
|
3642
|
-
{
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
'
|
|
3646
|
-
|
|
3647
|
-
} as React.CSSProperties
|
|
3648
|
-
}
|
|
3649
|
-
className="py-12 bg-[var(--local-bg)]"
|
|
3650
|
-
>
|
|
3651
|
-
<div className="container mx-auto px-6 max-w-4xl">
|
|
3652
|
-
{isStudio ? <StudioTiptapEditor data={data} /> : <PublicTiptapContent content={data.content ?? ''} />}
|
|
4020
|
+
<section className="w-full py-12">
|
|
4021
|
+
<div className="container mx-auto px-6 max-w-3xl">
|
|
4022
|
+
{mode === 'studio' ? (
|
|
4023
|
+
<StudioTiptapEditor data={data} />
|
|
4024
|
+
) : (
|
|
4025
|
+
<PublicTiptapContent content={data.content ?? ''} />
|
|
4026
|
+
)}
|
|
3653
4027
|
</div>
|
|
3654
4028
|
</section>
|
|
3655
4029
|
);
|
|
@@ -4256,7 +4630,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
|
|
|
4256
4630
|
]
|
|
4257
4631
|
}
|
|
4258
4632
|
END_OF_FILE_CONTENT
|
|
4259
|
-
# SKIP: src/data/config/menu.json:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
4260
4633
|
echo "Creating src/data/config/site.json..."
|
|
4261
4634
|
cat << 'END_OF_FILE_CONTENT' > "src/data/config/site.json"
|
|
4262
4635
|
{
|
|
@@ -4344,7 +4717,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/site.json"
|
|
|
4344
4717
|
}
|
|
4345
4718
|
}
|
|
4346
4719
|
END_OF_FILE_CONTENT
|
|
4347
|
-
# SKIP: src/data/config/site.json:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
4348
4720
|
echo "Creating src/data/config/theme.json..."
|
|
4349
4721
|
cat << 'END_OF_FILE_CONTENT' > "src/data/config/theme.json"
|
|
4350
4722
|
{
|
|
@@ -4376,7 +4748,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/theme.json"
|
|
|
4376
4748
|
}
|
|
4377
4749
|
}
|
|
4378
4750
|
END_OF_FILE_CONTENT
|
|
4379
|
-
# SKIP: src/data/config/theme.json:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
4380
4751
|
mkdir -p "src/data/pages"
|
|
4381
4752
|
echo "Creating src/data/pages/docs.json..."
|
|
4382
4753
|
cat << 'END_OF_FILE_CONTENT' > "src/data/pages/docs.json"
|
|
@@ -4969,7 +5340,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/home.json"
|
|
|
4969
5340
|
]
|
|
4970
5341
|
}
|
|
4971
5342
|
END_OF_FILE_CONTENT
|
|
4972
|
-
# SKIP: src/data/pages/home.json:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
4973
5343
|
echo "Creating src/data/pages/post.json..."
|
|
4974
5344
|
cat << 'END_OF_FILE_CONTENT' > "src/data/pages/post.json"
|
|
4975
5345
|
{
|
|
@@ -4984,7 +5354,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/post.json"
|
|
|
4984
5354
|
"id": "post-editorial-main",
|
|
4985
5355
|
"type": "tiptap",
|
|
4986
5356
|
"data": {
|
|
4987
|
-
"content": "
|
|
5357
|
+
"content": "# JsonPages Cloud – Terms of Service & EULA\n\n---\n\n### **Last Updated:** March 2026\n\n### 1. THE SERVICE\n\nJsonPages provides a hybrid content management infrastructure consisting of:\n\n- **The Core:** An open-source library (@jsonpages/core) governed by the **MIT License**.\n- **The Cloud:** A proprietary SaaS platform (`cloud.jsonpages.io`) that provides the \"Git Bridge,\" Asset Pipeline, and Managed Infrastructure.\n\nBy using the Cloud Service, you agree to these terms.\n\n### 2. DATA SOVEREIGNTY & OWNERSHIP\n\n- **Your Content:** All data (JSON files), code, and assets managed through JsonPages remain your exclusive property. JsonPages acts only as an **orchestrator**.\n- **The Bridge:** You grant JsonPages the necessary permissions to perform Git operations (commits/pushes) on your behalf to your designated repositories (GitHub/GitLab).\n- **Portability:** Since your content is stored as flat JSON files in your own repository, you retain the right to migrate away from the Cloud Service at any time without data lock-in.\n- \n\n### 3. SUBSCRIPTIONS & ENTITLEMENTS\n\n- **Billing:** The Cloud Service is billed on a subscription basis (**Monthly Recurring Revenue**).\n- **Entitlements:** Each \"Project\" or \"Tenant\" consumes one entitlement. Active entitlements grant access to the Visual Studio (ICE) and the Cloud Save API.\n- **Third-Party Costs:** You are solely responsible for any costs incurred on third-party platforms (e.g., **Vercel** hosting, **GitHub** storage, **Cloudflare** workers).\n\n### 4. ACCEPTABLE USE\n\nYou may not use JsonPages Cloud to:\n\n- Host or manage illegal, harmful, or offensive content.\n- Attempt to reverse-engineer the proprietary Cloud Bridge or bypass entitlement checks.\n- Interfere with the stability of the API for other users.\n- \n\n### 5. LIMITATION OF LIABILITY\n\n- **\"As-Is\" Basis:** The service is provided \"as-is.\" While we strive for 99.9% uptime, JsonPages is not liable for data loss resulting from Git conflicts, third-party outages (Vercel/GitHub), or user error.\n- **No Warranty:** We do not warrant that the service will be error-free or uninterrupted.\n- \n\n### 6. TERMINATION\n\n- **By You:** You can cancel your subscription at any time. Your Studio access will remain active until the end of the current billing cycle.\n- \n- **By Us:** We reserve the right to suspend accounts that violate these terms or fail to settle outstanding invoices.\n\n### 7. GOVERNING LAW\n\nThese terms are governed by the laws of **Italy/European Union**, without regard to conflict of law principles."
|
|
4988
5358
|
},
|
|
4989
5359
|
"settings": {}
|
|
4990
5360
|
}
|
|
@@ -4994,35 +5364,35 @@ END_OF_FILE_CONTENT
|
|
|
4994
5364
|
mkdir -p "src/data/pages/servizi"
|
|
4995
5365
|
echo "Creating src/data/pages/servizi/trattamento.json..."
|
|
4996
5366
|
cat << 'END_OF_FILE_CONTENT' > "src/data/pages/servizi/trattamento.json"
|
|
4997
|
-
{
|
|
4998
|
-
"id": "servizi-trattamento-page",
|
|
4999
|
-
"slug": "servizi/trattamento",
|
|
5000
|
-
"meta": {
|
|
5001
|
-
"title": "Servizi - Trattamento",
|
|
5002
|
-
"description": "Pagina nested di smoke test per verificare routing visitor/admin/preview."
|
|
5003
|
-
},
|
|
5004
|
-
"sections": [
|
|
5005
|
-
{
|
|
5006
|
-
"id": "hero-servizi-trattamento",
|
|
5007
|
-
"type": "hero",
|
|
5008
|
-
"data": {
|
|
5009
|
-
"badge": "Smoke Test",
|
|
5010
|
-
"title": "Trattamento",
|
|
5011
|
-
"titleHighlight": "Pagina Nested",
|
|
5012
|
-
"description": "Questa pagina verifica il supporto ai nested slug su filesystem e router.",
|
|
5013
|
-
"ctas": [
|
|
5014
|
-
{
|
|
5015
|
-
"id": "cta-home",
|
|
5016
|
-
"label": "Torna Home",
|
|
5017
|
-
"href": "/",
|
|
5018
|
-
"variant": "primary"
|
|
5019
|
-
}
|
|
5020
|
-
]
|
|
5021
|
-
},
|
|
5022
|
-
"settings": {}
|
|
5023
|
-
}
|
|
5024
|
-
]
|
|
5025
|
-
}
|
|
5367
|
+
{
|
|
5368
|
+
"id": "servizi-trattamento-page",
|
|
5369
|
+
"slug": "servizi/trattamento",
|
|
5370
|
+
"meta": {
|
|
5371
|
+
"title": "Servizi - Trattamento",
|
|
5372
|
+
"description": "Pagina nested di smoke test per verificare routing visitor/admin/preview."
|
|
5373
|
+
},
|
|
5374
|
+
"sections": [
|
|
5375
|
+
{
|
|
5376
|
+
"id": "hero-servizi-trattamento",
|
|
5377
|
+
"type": "hero",
|
|
5378
|
+
"data": {
|
|
5379
|
+
"badge": "Smoke Test",
|
|
5380
|
+
"title": "Trattamento",
|
|
5381
|
+
"titleHighlight": "Pagina Nested",
|
|
5382
|
+
"description": "Questa pagina verifica il supporto ai nested slug su filesystem e router.",
|
|
5383
|
+
"ctas": [
|
|
5384
|
+
{
|
|
5385
|
+
"id": "cta-home",
|
|
5386
|
+
"label": "Torna Home",
|
|
5387
|
+
"href": "/",
|
|
5388
|
+
"variant": "primary"
|
|
5389
|
+
}
|
|
5390
|
+
]
|
|
5391
|
+
},
|
|
5392
|
+
"settings": {}
|
|
5393
|
+
}
|
|
5394
|
+
]
|
|
5395
|
+
}
|
|
5026
5396
|
|
|
5027
5397
|
END_OF_FILE_CONTENT
|
|
5028
5398
|
echo "Creating src/data/pages/servizi_trattamento.json..."
|
|
@@ -5056,6 +5426,317 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/servizi_trattamento.json"
|
|
|
5056
5426
|
}
|
|
5057
5427
|
]
|
|
5058
5428
|
}
|
|
5429
|
+
END_OF_FILE_CONTENT
|
|
5430
|
+
mkdir -p "src/emails"
|
|
5431
|
+
echo "Creating src/emails/LeadNotificationEmail.tsx..."
|
|
5432
|
+
cat << 'END_OF_FILE_CONTENT' > "src/emails/LeadNotificationEmail.tsx"
|
|
5433
|
+
import {
|
|
5434
|
+
Body,
|
|
5435
|
+
Button,
|
|
5436
|
+
Container,
|
|
5437
|
+
Head,
|
|
5438
|
+
Heading,
|
|
5439
|
+
Hr,
|
|
5440
|
+
Html,
|
|
5441
|
+
Img,
|
|
5442
|
+
Preview,
|
|
5443
|
+
Section,
|
|
5444
|
+
Text,
|
|
5445
|
+
} from "@react-email/components";
|
|
5446
|
+
|
|
5447
|
+
type LeadData = Record<string, unknown>;
|
|
5448
|
+
|
|
5449
|
+
type EmailTheme = {
|
|
5450
|
+
colors?: {
|
|
5451
|
+
primary?: string;
|
|
5452
|
+
secondary?: string;
|
|
5453
|
+
accent?: string;
|
|
5454
|
+
background?: string;
|
|
5455
|
+
surface?: string;
|
|
5456
|
+
surfaceAlt?: string;
|
|
5457
|
+
text?: string;
|
|
5458
|
+
textMuted?: string;
|
|
5459
|
+
border?: string;
|
|
5460
|
+
};
|
|
5461
|
+
typography?: {
|
|
5462
|
+
fontFamily?: {
|
|
5463
|
+
primary?: string;
|
|
5464
|
+
display?: string;
|
|
5465
|
+
mono?: string;
|
|
5466
|
+
};
|
|
5467
|
+
};
|
|
5468
|
+
borderRadius?: {
|
|
5469
|
+
sm?: string;
|
|
5470
|
+
md?: string;
|
|
5471
|
+
lg?: string;
|
|
5472
|
+
xl?: string;
|
|
5473
|
+
};
|
|
5474
|
+
};
|
|
5475
|
+
|
|
5476
|
+
export type LeadNotificationEmailProps = {
|
|
5477
|
+
tenantName: string;
|
|
5478
|
+
correlationId: string;
|
|
5479
|
+
replyTo?: string | null;
|
|
5480
|
+
leadData: LeadData;
|
|
5481
|
+
brandName?: string;
|
|
5482
|
+
logoUrl?: string;
|
|
5483
|
+
logoAlt?: string;
|
|
5484
|
+
tagline?: string;
|
|
5485
|
+
theme?: EmailTheme;
|
|
5486
|
+
};
|
|
5487
|
+
|
|
5488
|
+
function safeString(value: unknown): string {
|
|
5489
|
+
if (value == null) return "-";
|
|
5490
|
+
if (typeof value === "string") {
|
|
5491
|
+
const trimmed = value.trim();
|
|
5492
|
+
return trimmed || "-";
|
|
5493
|
+
}
|
|
5494
|
+
return JSON.stringify(value);
|
|
5495
|
+
}
|
|
5496
|
+
|
|
5497
|
+
function flattenLeadData(data: LeadData) {
|
|
5498
|
+
return Object.entries(data)
|
|
5499
|
+
.filter(([key]) => !key.startsWith("_"))
|
|
5500
|
+
.slice(0, 20)
|
|
5501
|
+
.map(([key, value]) => ({ label: key, value: safeString(value) }));
|
|
5502
|
+
}
|
|
5503
|
+
|
|
5504
|
+
export function LeadNotificationEmail({
|
|
5505
|
+
tenantName,
|
|
5506
|
+
correlationId,
|
|
5507
|
+
replyTo,
|
|
5508
|
+
leadData,
|
|
5509
|
+
brandName,
|
|
5510
|
+
logoUrl,
|
|
5511
|
+
logoAlt,
|
|
5512
|
+
tagline,
|
|
5513
|
+
theme,
|
|
5514
|
+
}: LeadNotificationEmailProps) {
|
|
5515
|
+
const fields = flattenLeadData(leadData);
|
|
5516
|
+
const brandLabel = brandName || tenantName;
|
|
5517
|
+
|
|
5518
|
+
const colors = {
|
|
5519
|
+
primary: theme?.colors?.primary || "#2D5016",
|
|
5520
|
+
background: theme?.colors?.background || "#FAFAF5",
|
|
5521
|
+
surface: theme?.colors?.surface || "#FFFFFF",
|
|
5522
|
+
text: theme?.colors?.text || "#1C1C14",
|
|
5523
|
+
textMuted: theme?.colors?.textMuted || "#5A5A4A",
|
|
5524
|
+
border: theme?.colors?.border || "#D8D5C5",
|
|
5525
|
+
};
|
|
5526
|
+
|
|
5527
|
+
const fonts = {
|
|
5528
|
+
primary: theme?.typography?.fontFamily?.primary || "Inter, Arial, sans-serif",
|
|
5529
|
+
display: theme?.typography?.fontFamily?.display || "Georgia, serif",
|
|
5530
|
+
};
|
|
5531
|
+
|
|
5532
|
+
const radius = {
|
|
5533
|
+
md: theme?.borderRadius?.md || "10px",
|
|
5534
|
+
lg: theme?.borderRadius?.lg || "16px",
|
|
5535
|
+
};
|
|
5536
|
+
|
|
5537
|
+
return (
|
|
5538
|
+
<Html>
|
|
5539
|
+
<Head />
|
|
5540
|
+
<Preview>Nuovo lead ricevuto da {brandLabel}</Preview>
|
|
5541
|
+
<Body style={{ backgroundColor: colors.background, color: colors.text, fontFamily: fonts.primary, padding: "24px" }}>
|
|
5542
|
+
<Container style={{ backgroundColor: colors.surface, border: `1px solid ${colors.border}`, borderRadius: radius.lg, padding: "24px" }}>
|
|
5543
|
+
<Section>
|
|
5544
|
+
{logoUrl ? <Img src={logoUrl} alt={logoAlt || brandLabel} height="44" style={{ marginBottom: "8px" }} /> : null}
|
|
5545
|
+
<Text style={{ color: colors.text, fontSize: "18px", fontWeight: 700, margin: "0 0 6px 0" }}>{brandLabel}</Text>
|
|
5546
|
+
<Text style={{ color: colors.textMuted, marginTop: "0", marginBottom: "0" }}>{tagline || "Notifica automatica lead"}</Text>
|
|
5547
|
+
</Section>
|
|
5548
|
+
|
|
5549
|
+
<Hr style={{ borderColor: colors.border, margin: "20px 0" }} />
|
|
5550
|
+
|
|
5551
|
+
<Heading as="h2" style={{ color: colors.text, margin: "0 0 12px 0", fontSize: "22px", fontFamily: fonts.display }}>
|
|
5552
|
+
Nuovo lead da {tenantName}
|
|
5553
|
+
</Heading>
|
|
5554
|
+
<Text style={{ color: colors.textMuted, marginTop: "0", marginBottom: "16px" }}>Correlation ID: {correlationId}</Text>
|
|
5555
|
+
|
|
5556
|
+
<Section style={{ border: `1px solid ${colors.border}`, borderRadius: radius.md, padding: "12px" }}>
|
|
5557
|
+
{fields.length === 0 ? (
|
|
5558
|
+
<Text style={{ color: colors.textMuted, margin: 0 }}>Nessun campo lead disponibile.</Text>
|
|
5559
|
+
) : (
|
|
5560
|
+
fields.map((field) => (
|
|
5561
|
+
<Text key={field.label} style={{ margin: "0 0 8px 0", color: colors.text, fontSize: "14px", wordBreak: "break-word" }}>
|
|
5562
|
+
<strong>{field.label}:</strong> {field.value}
|
|
5563
|
+
</Text>
|
|
5564
|
+
))
|
|
5565
|
+
)}
|
|
5566
|
+
</Section>
|
|
5567
|
+
|
|
5568
|
+
<Section style={{ marginTop: "18px" }}>
|
|
5569
|
+
<Button
|
|
5570
|
+
href={replyTo ? `mailto:${replyTo}` : "mailto:"}
|
|
5571
|
+
style={{
|
|
5572
|
+
backgroundColor: colors.primary,
|
|
5573
|
+
color: "#ffffff",
|
|
5574
|
+
borderRadius: radius.md,
|
|
5575
|
+
textDecoration: "none",
|
|
5576
|
+
padding: "12px 18px",
|
|
5577
|
+
fontWeight: 600,
|
|
5578
|
+
}}
|
|
5579
|
+
>
|
|
5580
|
+
Rispondi ora
|
|
5581
|
+
</Button>
|
|
5582
|
+
</Section>
|
|
5583
|
+
</Container>
|
|
5584
|
+
</Body>
|
|
5585
|
+
</Html>
|
|
5586
|
+
);
|
|
5587
|
+
}
|
|
5588
|
+
|
|
5589
|
+
export default LeadNotificationEmail;
|
|
5590
|
+
|
|
5591
|
+
END_OF_FILE_CONTENT
|
|
5592
|
+
echo "Creating src/emails/LeadSenderConfirmationEmail.tsx..."
|
|
5593
|
+
cat << 'END_OF_FILE_CONTENT' > "src/emails/LeadSenderConfirmationEmail.tsx"
|
|
5594
|
+
import {
|
|
5595
|
+
Body,
|
|
5596
|
+
Container,
|
|
5597
|
+
Head,
|
|
5598
|
+
Heading,
|
|
5599
|
+
Hr,
|
|
5600
|
+
Html,
|
|
5601
|
+
Img,
|
|
5602
|
+
Preview,
|
|
5603
|
+
Section,
|
|
5604
|
+
Text,
|
|
5605
|
+
} from "@react-email/components";
|
|
5606
|
+
|
|
5607
|
+
type LeadData = Record<string, unknown>;
|
|
5608
|
+
|
|
5609
|
+
type EmailTheme = {
|
|
5610
|
+
colors?: {
|
|
5611
|
+
primary?: string;
|
|
5612
|
+
secondary?: string;
|
|
5613
|
+
accent?: string;
|
|
5614
|
+
background?: string;
|
|
5615
|
+
surface?: string;
|
|
5616
|
+
surfaceAlt?: string;
|
|
5617
|
+
text?: string;
|
|
5618
|
+
textMuted?: string;
|
|
5619
|
+
border?: string;
|
|
5620
|
+
};
|
|
5621
|
+
typography?: {
|
|
5622
|
+
fontFamily?: {
|
|
5623
|
+
primary?: string;
|
|
5624
|
+
display?: string;
|
|
5625
|
+
mono?: string;
|
|
5626
|
+
};
|
|
5627
|
+
};
|
|
5628
|
+
borderRadius?: {
|
|
5629
|
+
sm?: string;
|
|
5630
|
+
md?: string;
|
|
5631
|
+
lg?: string;
|
|
5632
|
+
xl?: string;
|
|
5633
|
+
};
|
|
5634
|
+
};
|
|
5635
|
+
|
|
5636
|
+
export type LeadSenderConfirmationEmailProps = {
|
|
5637
|
+
tenantName: string;
|
|
5638
|
+
correlationId: string;
|
|
5639
|
+
leadData: LeadData;
|
|
5640
|
+
brandName?: string;
|
|
5641
|
+
logoUrl?: string;
|
|
5642
|
+
logoAlt?: string;
|
|
5643
|
+
tagline?: string;
|
|
5644
|
+
theme?: EmailTheme;
|
|
5645
|
+
};
|
|
5646
|
+
|
|
5647
|
+
function safeString(value: unknown): string {
|
|
5648
|
+
if (value == null) return "-";
|
|
5649
|
+
if (typeof value === "string") {
|
|
5650
|
+
const trimmed = value.trim();
|
|
5651
|
+
return trimmed || "-";
|
|
5652
|
+
}
|
|
5653
|
+
return JSON.stringify(value);
|
|
5654
|
+
}
|
|
5655
|
+
|
|
5656
|
+
function flattenLeadData(data: LeadData) {
|
|
5657
|
+
const skipKeys = new Set(["recipientEmail", "tenant", "source", "submittedAt", "email_confirm"]);
|
|
5658
|
+
return Object.entries(data)
|
|
5659
|
+
.filter(([key]) => !key.startsWith("_") && !skipKeys.has(key))
|
|
5660
|
+
.slice(0, 12)
|
|
5661
|
+
.map(([key, value]) => ({ label: key, value: safeString(value) }));
|
|
5662
|
+
}
|
|
5663
|
+
|
|
5664
|
+
export function LeadSenderConfirmationEmail({
|
|
5665
|
+
tenantName,
|
|
5666
|
+
correlationId,
|
|
5667
|
+
leadData,
|
|
5668
|
+
brandName,
|
|
5669
|
+
logoUrl,
|
|
5670
|
+
logoAlt,
|
|
5671
|
+
tagline,
|
|
5672
|
+
theme,
|
|
5673
|
+
}: LeadSenderConfirmationEmailProps) {
|
|
5674
|
+
const fields = flattenLeadData(leadData);
|
|
5675
|
+
const brandLabel = brandName || tenantName;
|
|
5676
|
+
|
|
5677
|
+
const colors = {
|
|
5678
|
+
primary: theme?.colors?.primary || "#2D5016",
|
|
5679
|
+
background: theme?.colors?.background || "#FAFAF5",
|
|
5680
|
+
surface: theme?.colors?.surface || "#FFFFFF",
|
|
5681
|
+
text: theme?.colors?.text || "#1C1C14",
|
|
5682
|
+
textMuted: theme?.colors?.textMuted || "#5A5A4A",
|
|
5683
|
+
border: theme?.colors?.border || "#D8D5C5",
|
|
5684
|
+
};
|
|
5685
|
+
|
|
5686
|
+
const fonts = {
|
|
5687
|
+
primary: theme?.typography?.fontFamily?.primary || "Inter, Arial, sans-serif",
|
|
5688
|
+
display: theme?.typography?.fontFamily?.display || "Georgia, serif",
|
|
5689
|
+
};
|
|
5690
|
+
|
|
5691
|
+
const radius = {
|
|
5692
|
+
md: theme?.borderRadius?.md || "10px",
|
|
5693
|
+
lg: theme?.borderRadius?.lg || "16px",
|
|
5694
|
+
};
|
|
5695
|
+
|
|
5696
|
+
return (
|
|
5697
|
+
<Html>
|
|
5698
|
+
<Head />
|
|
5699
|
+
<Preview>Conferma invio richiesta - {brandLabel}</Preview>
|
|
5700
|
+
<Body style={{ backgroundColor: colors.background, color: colors.background, fontFamily: fonts.primary, padding: "24px" }}>
|
|
5701
|
+
<Container style={{ backgroundColor: colors.primary, color: colors.background, border: `1px solid ${colors.border}`, borderRadius: radius.lg, padding: "24px" }}>
|
|
5702
|
+
<Section>
|
|
5703
|
+
{logoUrl ? <Img src={logoUrl} alt={logoAlt || brandLabel} height="44" style={{ marginBottom: "8px" }} /> : null}
|
|
5704
|
+
<Text style={{ color: colors.background, fontSize: "18px", fontWeight: 700, margin: "0 0 6px 0" }}>{brandLabel}</Text>
|
|
5705
|
+
<Text style={{ color: colors.background, marginTop: "0", marginBottom: "0" }}>{tagline || "Conferma automatica di ricezione"}</Text>
|
|
5706
|
+
</Section>
|
|
5707
|
+
|
|
5708
|
+
<Hr style={{ borderColor: colors.border, margin: "20px 0" }} />
|
|
5709
|
+
|
|
5710
|
+
<Heading as="h2" style={{ color: colors.background, margin: "0 0 12px 0", fontSize: "22px", fontFamily: fonts.display }}>
|
|
5711
|
+
Richiesta ricevuta
|
|
5712
|
+
</Heading>
|
|
5713
|
+
<Text style={{ color: colors.background, marginTop: "0", marginBottom: "16px" }}>
|
|
5714
|
+
Grazie, abbiamo ricevuto la tua richiesta per {tenantName}. Ti risponderemo il prima possibile.
|
|
5715
|
+
</Text>
|
|
5716
|
+
|
|
5717
|
+
<Section style={{ border: `1px solid ${colors.border}`, borderRadius: radius.md, padding: "12px" }}>
|
|
5718
|
+
<Text style={{ margin: "0 0 8px 0", color: colors.background, fontWeight: 600 }}>Riepilogo inviato</Text>
|
|
5719
|
+
{fields.length === 0 ? (
|
|
5720
|
+
<Text style={{ color: colors.background, margin: 0 }}>Nessun dettaglio disponibile.</Text>
|
|
5721
|
+
) : (
|
|
5722
|
+
fields.map((field) => (
|
|
5723
|
+
<Text key={field.label} style={{ margin: "0 0 8px 0", color: colors.background, fontSize: "14px", wordBreak: "break-word" }}>
|
|
5724
|
+
<strong>{field.label}:</strong> {field.value}
|
|
5725
|
+
</Text>
|
|
5726
|
+
))
|
|
5727
|
+
)}
|
|
5728
|
+
</Section>
|
|
5729
|
+
|
|
5730
|
+
<Hr style={{ borderColor: colors.border, margin: "20px 0 12px 0" }} />
|
|
5731
|
+
<Text style={{ color: colors.background, fontSize: "12px", margin: 0 }}>Riferimento richiesta: {correlationId}</Text>
|
|
5732
|
+
</Container>
|
|
5733
|
+
</Body>
|
|
5734
|
+
</Html>
|
|
5735
|
+
);
|
|
5736
|
+
}
|
|
5737
|
+
|
|
5738
|
+
export default LeadSenderConfirmationEmail;
|
|
5739
|
+
|
|
5059
5740
|
END_OF_FILE_CONTENT
|
|
5060
5741
|
echo "Creating src/fonts.css..."
|
|
5061
5742
|
cat << 'END_OF_FILE_CONTENT' > "src/fonts.css"
|
|
@@ -5275,9 +5956,120 @@ html {
|
|
|
5275
5956
|
pointer-events: none;
|
|
5276
5957
|
}
|
|
5277
5958
|
|
|
5278
|
-
/*
|
|
5279
|
-
|
|
5959
|
+
/* ==========================================================================
|
|
5960
|
+
TIPTAP — Public content typography (visitor view)
|
|
5961
|
+
ReactMarkdown renders plain HTML; preflight resets it. Re-apply here.
|
|
5962
|
+
========================================================================== */
|
|
5963
|
+
.jp-tiptap-content > * + * { margin-top: 0.75em; }
|
|
5964
|
+
|
|
5965
|
+
.jp-tiptap-content h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5966
|
+
.jp-tiptap-content h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5967
|
+
.jp-tiptap-content h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5968
|
+
.jp-tiptap-content h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
|
|
5969
|
+
|
|
5970
|
+
.jp-tiptap-content p { line-height: 1.7; }
|
|
5971
|
+
|
|
5972
|
+
.jp-tiptap-content strong { font-weight: 700; }
|
|
5973
|
+
.jp-tiptap-content em { font-style: italic; }
|
|
5974
|
+
.jp-tiptap-content s { text-decoration: line-through; }
|
|
5975
|
+
|
|
5976
|
+
.jp-tiptap-content a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
5977
|
+
.jp-tiptap-content a:hover { opacity: 0.8; }
|
|
5978
|
+
|
|
5979
|
+
.jp-tiptap-content code {
|
|
5980
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
5981
|
+
font-size: 0.875em;
|
|
5982
|
+
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
5983
|
+
border-radius: 0.25em;
|
|
5984
|
+
padding: 0.1em 0.35em;
|
|
5985
|
+
}
|
|
5986
|
+
.jp-tiptap-content pre {
|
|
5987
|
+
background: color-mix(in oklch, var(--background) 60%, black);
|
|
5988
|
+
border-radius: 0.5em;
|
|
5989
|
+
padding: 1em 1.25em;
|
|
5990
|
+
overflow-x: auto;
|
|
5991
|
+
}
|
|
5992
|
+
.jp-tiptap-content pre code { background: none; padding: 0; }
|
|
5993
|
+
|
|
5994
|
+
.jp-tiptap-content ul { list-style-type: disc; padding-left: 1.625em; }
|
|
5995
|
+
.jp-tiptap-content ol { list-style-type: decimal; padding-left: 1.625em; }
|
|
5996
|
+
.jp-tiptap-content li { line-height: 1.7; margin-top: 0.25em; }
|
|
5997
|
+
.jp-tiptap-content li + li { margin-top: 0.25em; }
|
|
5998
|
+
|
|
5999
|
+
.jp-tiptap-content blockquote {
|
|
6000
|
+
border-left: 3px solid var(--border);
|
|
6001
|
+
padding-left: 1em;
|
|
6002
|
+
color: var(--muted-foreground);
|
|
6003
|
+
font-style: italic;
|
|
6004
|
+
}
|
|
6005
|
+
|
|
6006
|
+
.jp-tiptap-content hr {
|
|
6007
|
+
border: none;
|
|
6008
|
+
border-top: 1px solid var(--border);
|
|
6009
|
+
margin: 1.5em 0;
|
|
6010
|
+
}
|
|
6011
|
+
|
|
6012
|
+
.jp-tiptap-content img { max-width: 100%; height: auto; border-radius: 0.5rem; }
|
|
6013
|
+
|
|
6014
|
+
/* ==========================================================================
|
|
6015
|
+
TIPTAP / PROSEMIRROR — Editor typography
|
|
6016
|
+
Tailwind preflight resets all heading/list styles. Re-apply here using
|
|
6017
|
+
tenant theme tokens so the editor is WYSIWYG.
|
|
6018
|
+
========================================================================== */
|
|
6019
|
+
.jp-simple-editor .ProseMirror {
|
|
5280
6020
|
outline: none;
|
|
6021
|
+
word-break: break-word;
|
|
6022
|
+
}
|
|
6023
|
+
.jp-simple-editor .ProseMirror > * + * { margin-top: 0.75em; }
|
|
6024
|
+
|
|
6025
|
+
.jp-simple-editor .ProseMirror h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
6026
|
+
.jp-simple-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
6027
|
+
.jp-simple-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
6028
|
+
.jp-simple-editor .ProseMirror h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
|
|
6029
|
+
|
|
6030
|
+
.jp-simple-editor .ProseMirror p { line-height: 1.7; }
|
|
6031
|
+
|
|
6032
|
+
.jp-simple-editor .ProseMirror strong { font-weight: 700; }
|
|
6033
|
+
.jp-simple-editor .ProseMirror em { font-style: italic; }
|
|
6034
|
+
.jp-simple-editor .ProseMirror s { text-decoration: line-through; }
|
|
6035
|
+
|
|
6036
|
+
.jp-simple-editor .ProseMirror a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
6037
|
+
.jp-simple-editor .ProseMirror a:hover { opacity: 0.8; }
|
|
6038
|
+
|
|
6039
|
+
.jp-simple-editor .ProseMirror code {
|
|
6040
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
6041
|
+
font-size: 0.875em;
|
|
6042
|
+
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
6043
|
+
border-radius: 0.25em;
|
|
6044
|
+
padding: 0.1em 0.35em;
|
|
6045
|
+
}
|
|
6046
|
+
.jp-simple-editor .ProseMirror pre {
|
|
6047
|
+
background: color-mix(in oklch, var(--background) 60%, black);
|
|
6048
|
+
border-radius: 0.5em;
|
|
6049
|
+
padding: 1em 1.25em;
|
|
6050
|
+
overflow-x: auto;
|
|
6051
|
+
}
|
|
6052
|
+
.jp-simple-editor .ProseMirror pre code {
|
|
6053
|
+
background: none;
|
|
6054
|
+
padding: 0;
|
|
6055
|
+
}
|
|
6056
|
+
|
|
6057
|
+
.jp-simple-editor .ProseMirror ul { list-style-type: disc; padding-left: 1.625em; }
|
|
6058
|
+
.jp-simple-editor .ProseMirror ol { list-style-type: decimal; padding-left: 1.625em; }
|
|
6059
|
+
.jp-simple-editor .ProseMirror li { line-height: 1.7; margin-top: 0.25em; }
|
|
6060
|
+
.jp-simple-editor .ProseMirror li + li { margin-top: 0.25em; }
|
|
6061
|
+
|
|
6062
|
+
.jp-simple-editor .ProseMirror blockquote {
|
|
6063
|
+
border-left: 3px solid var(--border);
|
|
6064
|
+
padding-left: 1em;
|
|
6065
|
+
color: var(--muted-foreground);
|
|
6066
|
+
font-style: italic;
|
|
6067
|
+
}
|
|
6068
|
+
|
|
6069
|
+
.jp-simple-editor .ProseMirror hr {
|
|
6070
|
+
border: none;
|
|
6071
|
+
border-top: 1px solid var(--border);
|
|
6072
|
+
margin: 1.5em 0;
|
|
5281
6073
|
}
|
|
5282
6074
|
|
|
5283
6075
|
.jp-simple-editor .ProseMirror img {
|
|
@@ -5298,6 +6090,16 @@ html {
|
|
|
5298
6090
|
outline-offset: 2px;
|
|
5299
6091
|
}
|
|
5300
6092
|
|
|
6093
|
+
/* Placeholder when editor is empty */
|
|
6094
|
+
.jp-simple-editor .ProseMirror p.is-editor-empty:first-child::before {
|
|
6095
|
+
content: attr(data-placeholder);
|
|
6096
|
+
color: var(--muted-foreground);
|
|
6097
|
+
opacity: 0.5;
|
|
6098
|
+
pointer-events: none;
|
|
6099
|
+
float: left;
|
|
6100
|
+
height: 0;
|
|
6101
|
+
}
|
|
6102
|
+
|
|
5301
6103
|
|
|
5302
6104
|
|
|
5303
6105
|
END_OF_FILE_CONTENT
|
|
@@ -5353,7 +6155,6 @@ export const ComponentRegistry: {
|
|
|
5353
6155
|
};
|
|
5354
6156
|
|
|
5355
6157
|
END_OF_FILE_CONTENT
|
|
5356
|
-
# SKIP: src/lib/ComponentRegistry.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5357
6158
|
echo "Creating src/lib/IconResolver.tsx..."
|
|
5358
6159
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/IconResolver.tsx"
|
|
5359
6160
|
import React from 'react';
|
|
@@ -5412,7 +6213,6 @@ export const Icon: React.FC<IconProps> = ({ name, size = 20, className }) => {
|
|
|
5412
6213
|
|
|
5413
6214
|
|
|
5414
6215
|
END_OF_FILE_CONTENT
|
|
5415
|
-
# SKIP: src/lib/IconResolver.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5416
6216
|
echo "Creating src/lib/addSectionConfig.ts..."
|
|
5417
6217
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/addSectionConfig.ts"
|
|
5418
6218
|
import type { AddSectionConfig } from '@jsonpages/core';
|
|
@@ -5474,7 +6274,6 @@ export const addSectionConfig: AddSectionConfig = {
|
|
|
5474
6274
|
};
|
|
5475
6275
|
|
|
5476
6276
|
END_OF_FILE_CONTENT
|
|
5477
|
-
# SKIP: src/lib/addSectionConfig.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5478
6277
|
echo "Creating src/lib/base-schemas.ts..."
|
|
5479
6278
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/base-schemas.ts"
|
|
5480
6279
|
import { z } from 'zod';
|
|
@@ -5517,11 +6316,6 @@ export const CtaSchema = z.object({
|
|
|
5517
6316
|
});
|
|
5518
6317
|
|
|
5519
6318
|
END_OF_FILE_CONTENT
|
|
5520
|
-
# SKIP: src/lib/base-schemas.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5521
|
-
# SKIP: src/lib/cloudSaveStream.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5522
|
-
# SKIP: src/lib/cloudSaveStream.ts:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5523
|
-
# SKIP: src/lib/deploySteps.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5524
|
-
# SKIP: src/lib/deploySteps.ts:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5525
6319
|
echo "Creating src/lib/draftStorage.ts..."
|
|
5526
6320
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/draftStorage.ts"
|
|
5527
6321
|
/**
|
|
@@ -5550,7 +6344,6 @@ export function getHydratedData(
|
|
|
5550
6344
|
}
|
|
5551
6345
|
|
|
5552
6346
|
END_OF_FILE_CONTENT
|
|
5553
|
-
# SKIP: src/lib/draftStorage.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5554
6347
|
echo "Creating src/lib/getFilePages.ts..."
|
|
5555
6348
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/getFilePages.ts"
|
|
5556
6349
|
/**
|
|
@@ -5599,7 +6392,6 @@ export function getFilePages(): Record<string, PageConfig> {
|
|
|
5599
6392
|
}
|
|
5600
6393
|
|
|
5601
6394
|
END_OF_FILE_CONTENT
|
|
5602
|
-
# SKIP: src/lib/getFilePages.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5603
6395
|
echo "Creating src/lib/schemas.ts..."
|
|
5604
6396
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
|
|
5605
6397
|
export { BaseSectionData, BaseArrayItem, BaseSectionSettingsSchema, CtaSchema } from './base-schemas';
|
|
@@ -5649,7 +6441,95 @@ export const SECTION_SCHEMAS = {
|
|
|
5649
6441
|
export type SectionType = keyof typeof SECTION_SCHEMAS;
|
|
5650
6442
|
|
|
5651
6443
|
END_OF_FILE_CONTENT
|
|
5652
|
-
|
|
6444
|
+
echo "Creating src/lib/useFormSubmit.ts..."
|
|
6445
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/useFormSubmit.ts"
|
|
6446
|
+
import { useState, useCallback } from 'react';
|
|
6447
|
+
|
|
6448
|
+
export type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error';
|
|
6449
|
+
|
|
6450
|
+
interface UseFormSubmitOptions {
|
|
6451
|
+
source: string;
|
|
6452
|
+
tenantId: string;
|
|
6453
|
+
}
|
|
6454
|
+
|
|
6455
|
+
export function useFormSubmit({ source, tenantId }: UseFormSubmitOptions) {
|
|
6456
|
+
const [status, setStatus] = useState<SubmitStatus>('idle');
|
|
6457
|
+
const [message, setMessage] = useState<string>('');
|
|
6458
|
+
|
|
6459
|
+
const submit = useCallback(async (
|
|
6460
|
+
formData: FormData,
|
|
6461
|
+
recipientEmail: string,
|
|
6462
|
+
pageSlug: string,
|
|
6463
|
+
sectionId: string
|
|
6464
|
+
) => {
|
|
6465
|
+
const cloudApiUrl = import.meta.env.VITE_JSONPAGES_CLOUD_URL as string | undefined;
|
|
6466
|
+
const cloudApiKey = import.meta.env.VITE_JSONPAGES_API_KEY as string | undefined;
|
|
6467
|
+
|
|
6468
|
+
if (!cloudApiUrl || !cloudApiKey) {
|
|
6469
|
+
setStatus('error');
|
|
6470
|
+
setMessage('Configurazione API non disponibile. Riprova tra poco.');
|
|
6471
|
+
return false;
|
|
6472
|
+
}
|
|
6473
|
+
|
|
6474
|
+
// Trasformiamo FormData in un oggetto piatto per il payload JSON
|
|
6475
|
+
const data: Record<string, any> = {};
|
|
6476
|
+
formData.forEach((value, key) => {
|
|
6477
|
+
data[key] = String(value).trim();
|
|
6478
|
+
});
|
|
6479
|
+
|
|
6480
|
+
const payload = {
|
|
6481
|
+
...data,
|
|
6482
|
+
recipientEmail,
|
|
6483
|
+
page: pageSlug,
|
|
6484
|
+
section: sectionId,
|
|
6485
|
+
tenant: tenantId,
|
|
6486
|
+
source: source,
|
|
6487
|
+
submittedAt: new Date().toISOString(),
|
|
6488
|
+
};
|
|
6489
|
+
|
|
6490
|
+
// Idempotency Key per evitare doppi invii accidentali
|
|
6491
|
+
const idempotencyKey = `form-${sectionId}-${Date.now()}`;
|
|
6492
|
+
|
|
6493
|
+
setStatus('submitting');
|
|
6494
|
+
setMessage('Invio in corso...');
|
|
6495
|
+
|
|
6496
|
+
try {
|
|
6497
|
+
const apiBase = cloudApiUrl.replace(/\/$/, '');
|
|
6498
|
+
const response = await fetch(`${apiBase}/forms/submit`, {
|
|
6499
|
+
method: 'POST',
|
|
6500
|
+
headers: {
|
|
6501
|
+
Authorization: `Bearer ${cloudApiKey}`,
|
|
6502
|
+
'Content-Type': 'application/json',
|
|
6503
|
+
'Idempotency-Key': idempotencyKey,
|
|
6504
|
+
},
|
|
6505
|
+
body: JSON.stringify(payload),
|
|
6506
|
+
});
|
|
6507
|
+
|
|
6508
|
+
const body = (await response.json().catch(() => ({}))) as { error?: string; code?: string };
|
|
6509
|
+
|
|
6510
|
+
if (!response.ok) {
|
|
6511
|
+
throw new Error(body.error || body.code || `Submit failed (${response.status})`);
|
|
6512
|
+
}
|
|
6513
|
+
|
|
6514
|
+
setStatus('success');
|
|
6515
|
+
setMessage('Richiesta inviata con successo. Ti risponderemo al più presto.');
|
|
6516
|
+
return true;
|
|
6517
|
+
} catch (error: unknown) {
|
|
6518
|
+
const errorMsg = error instanceof Error ? error.message : 'Invio non riuscito. Riprova tra poco.';
|
|
6519
|
+
setStatus('error');
|
|
6520
|
+
setMessage(errorMsg);
|
|
6521
|
+
return false;
|
|
6522
|
+
}
|
|
6523
|
+
}, [source, tenantId]);
|
|
6524
|
+
|
|
6525
|
+
const reset = useCallback(() => {
|
|
6526
|
+
setStatus('idle');
|
|
6527
|
+
setMessage('');
|
|
6528
|
+
}, []);
|
|
6529
|
+
|
|
6530
|
+
return { submit, status, message, reset };
|
|
6531
|
+
}
|
|
6532
|
+
END_OF_FILE_CONTENT
|
|
5653
6533
|
echo "Creating src/lib/utils.ts..."
|
|
5654
6534
|
cat << 'END_OF_FILE_CONTENT' > "src/lib/utils.ts"
|
|
5655
6535
|
import { clsx, type ClassValue } from 'clsx';
|
|
@@ -5660,7 +6540,6 @@ export function cn(...inputs: ClassValue[]) {
|
|
|
5660
6540
|
}
|
|
5661
6541
|
|
|
5662
6542
|
END_OF_FILE_CONTENT
|
|
5663
|
-
# SKIP: src/lib/utils.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5664
6543
|
echo "Creating src/main.tsx..."
|
|
5665
6544
|
cat << 'END_OF_FILE_CONTENT' > "src/main.tsx"
|
|
5666
6545
|
import '@/types'; // TBP: load type augmentation from capsule-driven types
|
|
@@ -5679,7 +6558,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
|
5679
6558
|
|
|
5680
6559
|
|
|
5681
6560
|
END_OF_FILE_CONTENT
|
|
5682
|
-
# SKIP: src/main.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5683
6561
|
echo "Creating src/main_.tsx..."
|
|
5684
6562
|
cat << 'END_OF_FILE_CONTENT' > "src/main_.tsx"
|
|
5685
6563
|
import '@/types'; // TBP: load type augmentation from capsule-driven types
|
|
@@ -5794,7 +6672,6 @@ declare module '@jsonpages/core' {
|
|
|
5794
6672
|
export * from '@jsonpages/core';
|
|
5795
6673
|
|
|
5796
6674
|
END_OF_FILE_CONTENT
|
|
5797
|
-
# SKIP: src/types/deploy.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
|
|
5798
6675
|
echo "Creating src/vite-env.d.ts..."
|
|
5799
6676
|
cat << 'END_OF_FILE_CONTENT' > "src/vite-env.d.ts"
|
|
5800
6677
|
/// <reference types="vite/client" />
|