seeemess 1.0.0
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/.github/workflows/publish.yml +34 -0
- package/.github/workflows/test.yml +30 -0
- package/CLAUDE.md +73 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/_config/filters.js +77 -0
- package/admin/config.js +84 -0
- package/admin/routes/images.js +126 -0
- package/admin/routes/posts.js +169 -0
- package/admin/routes/preview.js +111 -0
- package/admin/routes/publish.js +155 -0
- package/admin/routes/synopsis.js +39 -0
- package/admin/server.js +80 -0
- package/admin/templates/admin.eta +1377 -0
- package/admin/templates/preview.eta +266 -0
- package/admin/utils/git.js +16 -0
- package/admin/utils/markdown.js +137 -0
- package/index.js +29 -0
- package/package.json +51 -0
- package/test/config.test.js +157 -0
- package/test/filters.test.js +201 -0
- package/test/markdown.test.js +245 -0
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>CMS Admin</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body { font-family: -apple-system, system-ui, sans-serif; max-width: 1400px; margin: 0 auto; padding: 2rem; background: #f5f5f5; }
|
|
10
|
+
h1 { color: #333; border-bottom: 0px solid #333; padding-bottom: 0.5rem; margin: 0; }
|
|
11
|
+
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; border-bottom: 3px solid #333; padding-bottom: 0.5rem; }
|
|
12
|
+
.btn-publish-global { padding: 0.6rem 1.5rem; background: #28a745; color: #fff; border: none; border-radius: 4px; font-size: 0.9rem; font-weight: 600; cursor: pointer; }
|
|
13
|
+
.btn-publish-global:hover:not(:disabled) { background: #218838; }
|
|
14
|
+
.btn-publish-global:disabled { background: #ccc; color: #666; cursor: not-allowed; }
|
|
15
|
+
.container { display: grid; grid-template-columns: 280px 1fr; gap: 2rem; }
|
|
16
|
+
.sidebar { background: #fff; padding: 1rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); height: fit-content; }
|
|
17
|
+
.main { background: #fff; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
18
|
+
.post-list { list-style: none; padding: 0; margin: 0; max-height: 600px; overflow-y: auto; }
|
|
19
|
+
.post-list li { padding: 0.75rem; border-bottom: 1px solid #eee; cursor: pointer; }
|
|
20
|
+
.post-list li:hover { background: #f0f0f0; }
|
|
21
|
+
.post-list li.active { background: #e0e0e0; }
|
|
22
|
+
.post-title { font-weight: 600; font-size: 0.85rem; }
|
|
23
|
+
.post-date { font-size: 0.7rem; color: #888; }
|
|
24
|
+
.post-status { font-size: 0.65rem; padding: 2px 6px; border-radius: 3px; margin-left: 5px; }
|
|
25
|
+
.post-status.draft { background: #ffeeba; color: #856404; }
|
|
26
|
+
.post-status.published { background: #d4edda; color: #155724; }
|
|
27
|
+
.post-status.lede { background: #cce5ff; color: #004085; }
|
|
28
|
+
|
|
29
|
+
.section-tabs { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 2px solid #333; }
|
|
30
|
+
.section-tab { flex: 1; padding: 0.6rem 0.5rem; text-align: center; background: #f0f0f0; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 600; border-radius: 4px 4px 0 0; }
|
|
31
|
+
.section-tab:hover { background: #e0e0e0; }
|
|
32
|
+
.section-tab.active { background: #333; color: #fff; }
|
|
33
|
+
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
|
|
34
|
+
.section-header h3 { margin: 0; font-size: 0.9rem; }
|
|
35
|
+
.lede-indicator { font-size: 0.7rem; color: #004085; }
|
|
36
|
+
.btn-lede { font-size: 0.65rem; padding: 2px 6px; background: #17a2b8; color: #fff; border: none; border-radius: 3px; cursor: pointer; margin-left: auto; }
|
|
37
|
+
.btn-lede:hover { background: #138496; }
|
|
38
|
+
.btn-lede.is-lede { background: #28a745; }
|
|
39
|
+
.post-row { display: flex; align-items: center; gap: 0.5rem; }
|
|
40
|
+
.post-info { flex: 1; min-width: 0; }
|
|
41
|
+
.post-actions { flex-shrink: 0; }
|
|
42
|
+
|
|
43
|
+
form { display: flex; flex-direction: column; gap: 1rem; }
|
|
44
|
+
label { font-weight: 600; color: #333; font-size: 0.9rem; }
|
|
45
|
+
input, textarea, select { width: 100%; padding: 0.6rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.95rem; font-family: inherit; }
|
|
46
|
+
textarea { min-height: 300px; font-family: monospace; font-size: 0.85rem; }
|
|
47
|
+
|
|
48
|
+
.btn { padding: 0.6rem 1.2rem; border: none; border-radius: 4px; font-size: 0.9rem; cursor: pointer; }
|
|
49
|
+
.btn-primary { background: #333; color: #fff; }
|
|
50
|
+
.btn-danger { background: #c00; color: #fff; }
|
|
51
|
+
.btn-secondary { background: #ddd; color: #333; }
|
|
52
|
+
.btn-preview { background: #17a2b8; color: #fff; }
|
|
53
|
+
.btn-preview:hover { background: #138496; }
|
|
54
|
+
.btn-publish { background: #28a745; color: #fff; }
|
|
55
|
+
.btn-publish:hover { background: #218838; }
|
|
56
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
57
|
+
|
|
58
|
+
.btn-row { display: flex; justify-content: space-between; margin-top: 1rem; }
|
|
59
|
+
.btn-group { display: flex; gap: 0.5rem; }
|
|
60
|
+
|
|
61
|
+
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
|
62
|
+
.field-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
|
|
63
|
+
|
|
64
|
+
.image-section { border: 2px dashed #ddd; padding: 1rem; border-radius: 8px; }
|
|
65
|
+
.image-preview-large { max-width: 100%; max-height: 200px; margin-top: 0.5rem; border: 1px solid #ddd; }
|
|
66
|
+
.image-upload-area { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
|
|
67
|
+
.image-upload-area > div { flex: 1; min-width: 200px; }
|
|
68
|
+
|
|
69
|
+
.drop-zone { border: 2px dashed #ccc; padding: 2rem; text-align: center; cursor: pointer; border-radius: 4px; margin-top: 0.5rem; }
|
|
70
|
+
.drop-zone:hover, .drop-zone.dragover { border-color: #28a745; background: #f0fff0; }
|
|
71
|
+
.drop-zone input { display: none; }
|
|
72
|
+
|
|
73
|
+
.gallery-thumbnails { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; }
|
|
74
|
+
.gallery-thumb { position: relative; width: 120px; }
|
|
75
|
+
.gallery-thumb img { width: 120px; height: 90px; object-fit: cover; border: 1px solid #ddd; border-radius: 4px; }
|
|
76
|
+
.gallery-thumb-actions { position: absolute; top: 4px; right: 4px; display: flex; gap: 4px; }
|
|
77
|
+
.gallery-thumb-btn { width: 24px; height: 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; }
|
|
78
|
+
.gallery-thumb-btn.copy { background: #007bff; color: #fff; }
|
|
79
|
+
.gallery-thumb-btn.copy:hover { background: #0056b3; }
|
|
80
|
+
.gallery-thumb-btn.remove { background: #dc3545; color: #fff; }
|
|
81
|
+
.gallery-thumb-btn.remove:hover { background: #c82333; }
|
|
82
|
+
.gallery-thumb-name { font-size: 0.7rem; color: #666; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
83
|
+
.gallery-thumb { cursor: grab; }
|
|
84
|
+
.gallery-thumb.dragging { opacity: 0.5; cursor: grabbing; }
|
|
85
|
+
.gallery-thumb.drag-over { outline: 2px dashed #007bff; outline-offset: 2px; }
|
|
86
|
+
.gallery-thumb.has-metadata::after { content: '✓'; position: absolute; bottom: 22px; left: 4px; background: #28a745; color: #fff; font-size: 10px; width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
|
87
|
+
.gallery-thumb-btn.edit { background: #6c757d; color: #fff; }
|
|
88
|
+
.gallery-thumb-btn.edit:hover { background: #5a6268; }
|
|
89
|
+
.drop-zone input { display: none; }
|
|
90
|
+
|
|
91
|
+
.gallery-modal-fields { display: flex; flex-direction: column; gap: 1rem; }
|
|
92
|
+
.gallery-modal-fields label { font-weight: 600; font-size: 0.9rem; display: block; margin-bottom: 0.25rem; }
|
|
93
|
+
.gallery-modal-fields input { width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.9rem; }
|
|
94
|
+
.gallery-modal-fields .char-count { font-size: 0.75rem; color: #888; text-align: right; }
|
|
95
|
+
|
|
96
|
+
.status { padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; display: none; }
|
|
97
|
+
.status.success { background: #d4edda; color: #155724; display: block; }
|
|
98
|
+
.status.error { background: #f8d7da; color: #721c24; display: block; }
|
|
99
|
+
.status.info { background: #cce5ff; color: #004085; display: block; }
|
|
100
|
+
|
|
101
|
+
/* Modal styles */
|
|
102
|
+
.modal-overlay {
|
|
103
|
+
position: fixed;
|
|
104
|
+
top: 0;
|
|
105
|
+
left: 0;
|
|
106
|
+
right: 0;
|
|
107
|
+
bottom: 0;
|
|
108
|
+
background: rgba(0, 0, 0, 0.5);
|
|
109
|
+
display: flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
justify-content: center;
|
|
112
|
+
z-index: 1000;
|
|
113
|
+
opacity: 0;
|
|
114
|
+
visibility: hidden;
|
|
115
|
+
transition: opacity 0.2s, visibility 0.2s;
|
|
116
|
+
}
|
|
117
|
+
.modal-overlay.active {
|
|
118
|
+
opacity: 1;
|
|
119
|
+
visibility: visible;
|
|
120
|
+
}
|
|
121
|
+
.modal-box {
|
|
122
|
+
background: #fff;
|
|
123
|
+
border-radius: 12px;
|
|
124
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
125
|
+
max-width: 420px;
|
|
126
|
+
width: 90%;
|
|
127
|
+
transform: scale(0.9);
|
|
128
|
+
transition: transform 0.2s;
|
|
129
|
+
}
|
|
130
|
+
.modal-overlay.active .modal-box {
|
|
131
|
+
transform: scale(1);
|
|
132
|
+
}
|
|
133
|
+
.modal-header {
|
|
134
|
+
padding: 1.25rem 1.5rem;
|
|
135
|
+
border-bottom: 1px solid #eee;
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
gap: 0.75rem;
|
|
139
|
+
}
|
|
140
|
+
.modal-icon {
|
|
141
|
+
width: 40px;
|
|
142
|
+
height: 40px;
|
|
143
|
+
border-radius: 50%;
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
font-size: 1.25rem;
|
|
148
|
+
}
|
|
149
|
+
.modal-icon.warning { background: #fff3cd; }
|
|
150
|
+
.modal-icon.danger { background: #f8d7da; }
|
|
151
|
+
.modal-icon.success { background: #d4edda; }
|
|
152
|
+
.modal-title {
|
|
153
|
+
font-weight: 600;
|
|
154
|
+
font-size: 1.1rem;
|
|
155
|
+
color: #333;
|
|
156
|
+
}
|
|
157
|
+
.modal-body {
|
|
158
|
+
padding: 1.25rem 1.5rem;
|
|
159
|
+
color: #555;
|
|
160
|
+
font-size: 0.95rem;
|
|
161
|
+
line-height: 1.5;
|
|
162
|
+
}
|
|
163
|
+
.modal-footer {
|
|
164
|
+
padding: 1rem 1.5rem;
|
|
165
|
+
border-top: 1px solid #eee;
|
|
166
|
+
display: flex;
|
|
167
|
+
justify-content: flex-end;
|
|
168
|
+
gap: 0.75rem;
|
|
169
|
+
}
|
|
170
|
+
.modal-btn {
|
|
171
|
+
padding: 0.6rem 1.25rem;
|
|
172
|
+
border: none;
|
|
173
|
+
border-radius: 6px;
|
|
174
|
+
font-size: 0.9rem;
|
|
175
|
+
font-weight: 500;
|
|
176
|
+
cursor: pointer;
|
|
177
|
+
transition: background 0.15s;
|
|
178
|
+
}
|
|
179
|
+
.modal-btn-cancel {
|
|
180
|
+
background: #e9ecef;
|
|
181
|
+
color: #495057;
|
|
182
|
+
}
|
|
183
|
+
.modal-btn-cancel:hover {
|
|
184
|
+
background: #dee2e6;
|
|
185
|
+
}
|
|
186
|
+
.modal-btn-danger {
|
|
187
|
+
background: #dc3545;
|
|
188
|
+
color: #fff;
|
|
189
|
+
}
|
|
190
|
+
.modal-btn-danger:hover {
|
|
191
|
+
background: #c82333;
|
|
192
|
+
}
|
|
193
|
+
.modal-btn-success {
|
|
194
|
+
background: #28a745;
|
|
195
|
+
color: #fff;
|
|
196
|
+
}
|
|
197
|
+
.modal-btn-success:hover {
|
|
198
|
+
background: #218838;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* Preview Modal */
|
|
202
|
+
.preview-modal {
|
|
203
|
+
position: fixed;
|
|
204
|
+
top: 0;
|
|
205
|
+
left: 0;
|
|
206
|
+
right: 0;
|
|
207
|
+
bottom: 0;
|
|
208
|
+
background: rgba(0, 0, 0, 0.85);
|
|
209
|
+
z-index: 2000;
|
|
210
|
+
display: none;
|
|
211
|
+
}
|
|
212
|
+
.preview-modal.active {
|
|
213
|
+
display: flex;
|
|
214
|
+
flex-direction: column;
|
|
215
|
+
}
|
|
216
|
+
.preview-modal-header {
|
|
217
|
+
display: flex;
|
|
218
|
+
justify-content: space-between;
|
|
219
|
+
align-items: center;
|
|
220
|
+
padding: 0.75rem 1rem;
|
|
221
|
+
background: #222;
|
|
222
|
+
color: #fff;
|
|
223
|
+
}
|
|
224
|
+
.preview-modal-title {
|
|
225
|
+
font-size: 0.9rem;
|
|
226
|
+
font-weight: 600;
|
|
227
|
+
}
|
|
228
|
+
.preview-modal-close {
|
|
229
|
+
width: 32px;
|
|
230
|
+
height: 32px;
|
|
231
|
+
border: none;
|
|
232
|
+
background: #444;
|
|
233
|
+
color: #fff;
|
|
234
|
+
font-size: 1.25rem;
|
|
235
|
+
border-radius: 4px;
|
|
236
|
+
cursor: pointer;
|
|
237
|
+
display: flex;
|
|
238
|
+
align-items: center;
|
|
239
|
+
justify-content: center;
|
|
240
|
+
}
|
|
241
|
+
.preview-modal-close:hover {
|
|
242
|
+
background: #666;
|
|
243
|
+
}
|
|
244
|
+
.preview-modal-iframe {
|
|
245
|
+
flex: 1;
|
|
246
|
+
border: none;
|
|
247
|
+
background: #fff;
|
|
248
|
+
}
|
|
249
|
+
</style>
|
|
250
|
+
</head>
|
|
251
|
+
<body>
|
|
252
|
+
<div class="header-row">
|
|
253
|
+
<h1>CMS Admin</h1>
|
|
254
|
+
<button class="btn-publish-global" id="publishGlobalBtn" disabled onclick="publishChanges()">Publish Changes</button>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="container">
|
|
257
|
+
<div class="sidebar">
|
|
258
|
+
<button class="btn btn-primary" style="width:100%; margin-bottom:1rem;" onclick="newPost()">+ New Post</button>
|
|
259
|
+
<div class="section-tabs" id="sectionTabs"></div>
|
|
260
|
+
<div class="section-header">
|
|
261
|
+
<h3 id="sectionTitle">Posts</h3>
|
|
262
|
+
<span class="lede-indicator" id="ledeIndicator"></span>
|
|
263
|
+
</div>
|
|
264
|
+
<ul class="post-list" id="postList"></ul>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="main">
|
|
267
|
+
<div id="status" class="status"></div>
|
|
268
|
+
<form id="postForm" onsubmit="savePost(event)">
|
|
269
|
+
<input type="hidden" id="postPath" value="">
|
|
270
|
+
<input type="hidden" id="postStatus" value="draft">
|
|
271
|
+
<input type="hidden" id="postLede" value="false">
|
|
272
|
+
|
|
273
|
+
<div>
|
|
274
|
+
<label>Title</label>
|
|
275
|
+
<input type="text" id="title" required>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<div>
|
|
279
|
+
<label>Synopsis</label>
|
|
280
|
+
<div style="display: flex; gap: 0.5rem; align-items: flex-start;">
|
|
281
|
+
<textarea id="synopsis" rows="2" style="flex: 1; resize: vertical; min-height: 4.75em;"></textarea>
|
|
282
|
+
<button type="button" class="btn btn-secondary" onclick="generateSynopsis()" id="generateBtn" title="Generate synopsis using Ollama">✨ Generate</button>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<div class="field-row-3">
|
|
287
|
+
<div>
|
|
288
|
+
<label>Tags (comma-separated)</label>
|
|
289
|
+
<input type="text" id="tags" placeholder="politics, local">
|
|
290
|
+
</div>
|
|
291
|
+
<div>
|
|
292
|
+
<label>Author (optional)</label>
|
|
293
|
+
<input type="text" id="author" placeholder="e.g., Jane Doe">
|
|
294
|
+
</div>
|
|
295
|
+
<div>
|
|
296
|
+
<label>Date & Time</label>
|
|
297
|
+
<input type="datetime-local" id="date">
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div>
|
|
302
|
+
<label>Folder (optional)</label>
|
|
303
|
+
<input type="text" id="folder" placeholder="e.g., breaking-news">
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<div class="image-section">
|
|
307
|
+
<label>Lead Photo (required)</label>
|
|
308
|
+
<input type="hidden" id="image" value="">
|
|
309
|
+
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
|
310
|
+
Drop image here or click to upload
|
|
311
|
+
<input type="file" id="fileInput" accept="image/*" onchange="handleFileSelect(event)">
|
|
312
|
+
</div>
|
|
313
|
+
<img id="imagePreview" class="image-preview-large" style="display:none;">
|
|
314
|
+
<div class="lead-photo-fields" id="leadPhotoFields" style="display:none; margin-top: 0.75rem;">
|
|
315
|
+
<div class="field-row">
|
|
316
|
+
<div>
|
|
317
|
+
<label style="font-size: 0.8rem;">Caption</label>
|
|
318
|
+
<input type="text" id="imageCaption" placeholder="Describe the image..." maxlength="255">
|
|
319
|
+
</div>
|
|
320
|
+
<div>
|
|
321
|
+
<label style="font-size: 0.8rem;">Photo Credit</label>
|
|
322
|
+
<input type="text" id="imageCredit" placeholder="Photographer or source..." maxlength="255">
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<div class="image-section">
|
|
329
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
330
|
+
<label>Photo Gallery (optional)</label>
|
|
331
|
+
<label style="font-weight: normal; font-size: 0.85rem;">
|
|
332
|
+
<input type="checkbox" id="showGallery"> Show gallery at bottom of post
|
|
333
|
+
</label>
|
|
334
|
+
</div>
|
|
335
|
+
<div class="gallery-upload-area">
|
|
336
|
+
<div class="drop-zone" id="galleryDropZone" onclick="document.getElementById('galleryFileInput').click()">
|
|
337
|
+
Drop images here or click to upload to gallery
|
|
338
|
+
<input type="file" id="galleryFileInput" accept="image/*" multiple onchange="handleGalleryFileSelect(event)">
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
<div id="galleryThumbnails" class="gallery-thumbnails"></div>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<div>
|
|
345
|
+
<label>Content (Markdown)</label>
|
|
346
|
+
<textarea id="body" required></textarea>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<div class="image-section" id="inlineImagesSection" style="display: none;">
|
|
350
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
351
|
+
<label>Inline Image Captions & Credits</label>
|
|
352
|
+
<button type="button" class="btn btn-secondary" onclick="syncInlineImages()" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">Refresh</button>
|
|
353
|
+
</div>
|
|
354
|
+
<p style="font-size: 0.85rem; color: #666; margin: 0.25rem 0 0.5rem;">Add captions and credits for images in your content</p>
|
|
355
|
+
<div id="inlineImagesList" class="gallery-thumbnails"></div>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<div class="btn-row">
|
|
359
|
+
<div class="btn-group">
|
|
360
|
+
<button type="submit" class="btn btn-primary" id="saveBtn" disabled>Save Draft</button>
|
|
361
|
+
<button type="button" class="btn btn-preview" onclick="previewPost()">Preview</button>
|
|
362
|
+
<button type="button" class="btn btn-danger" onclick="deletePost()" id="deleteBtn" style="display:none;">Delete</button>
|
|
363
|
+
<button type="button" class="btn btn-secondary" onclick="cancelPost()">Cancel</button>
|
|
364
|
+
</div>
|
|
365
|
+
<button type="button" class="btn btn-publish" onclick="publishPost()" id="publishBtn" style="display:none;">Publish</button>
|
|
366
|
+
</div>
|
|
367
|
+
</form>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<!-- Custom Modal -->
|
|
372
|
+
<div class="modal-overlay" id="modal">
|
|
373
|
+
<div class="modal-box">
|
|
374
|
+
<div class="modal-header">
|
|
375
|
+
<div class="modal-icon" id="modalIcon"></div>
|
|
376
|
+
<div class="modal-title" id="modalTitle"></div>
|
|
377
|
+
</div>
|
|
378
|
+
<div class="modal-body" id="modalBody"></div>
|
|
379
|
+
<div class="modal-footer">
|
|
380
|
+
<button class="modal-btn modal-btn-cancel" id="modalCancel">Cancel</button>
|
|
381
|
+
<button class="modal-btn" id="modalConfirm">Confirm</button>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
<!-- Preview Modal -->
|
|
387
|
+
<div class="preview-modal" id="previewModal">
|
|
388
|
+
<div class="preview-modal-header">
|
|
389
|
+
<div class="preview-modal-title">📄 Preview Mode</div>
|
|
390
|
+
<button class="preview-modal-close" onclick="closePreviewModal()" title="Close preview">×</button>
|
|
391
|
+
</div>
|
|
392
|
+
<iframe class="preview-modal-iframe" id="previewIframe"></iframe>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<!-- Gallery Edit Modal -->
|
|
396
|
+
<div class="modal-overlay" id="galleryEditModal">
|
|
397
|
+
<div class="modal-box" style="max-width: 500px;">
|
|
398
|
+
<div class="modal-header">
|
|
399
|
+
<div class="modal-icon" style="background: #e3f2fd;">📷</div>
|
|
400
|
+
<div class="modal-title">Edit Photo Details</div>
|
|
401
|
+
</div>
|
|
402
|
+
<div class="modal-body">
|
|
403
|
+
<div class="gallery-modal-fields">
|
|
404
|
+
<div>
|
|
405
|
+
<label for="galleryCaption">Caption</label>
|
|
406
|
+
<input type="text" id="galleryCaption" maxlength="255" placeholder="Describe the image...">
|
|
407
|
+
<div class="char-count"><span id="captionCount">0</span>/255</div>
|
|
408
|
+
</div>
|
|
409
|
+
<div>
|
|
410
|
+
<label for="galleryCredit">Photo Credit</label>
|
|
411
|
+
<input type="text" id="galleryCredit" maxlength="255" placeholder="Photographer or source...">
|
|
412
|
+
<div class="char-count"><span id="creditCount">0</span>/255</div>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
<div class="modal-footer">
|
|
417
|
+
<button class="modal-btn modal-btn-cancel" onclick="closeGalleryEditModal()">Cancel</button>
|
|
418
|
+
<button class="modal-btn modal-btn-success" onclick="saveGalleryEdit()">Save</button>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
<script>
|
|
424
|
+
const imageUrlPath = '<%= it.imageUrlPath || "/uploads" %>';
|
|
425
|
+
let posts = [], gallery = [], images = [], sections = [];
|
|
426
|
+
let modalResolve = null;
|
|
427
|
+
let formDirty = false;
|
|
428
|
+
let editingGalleryIndex = null;
|
|
429
|
+
let editingImageIndex = null;
|
|
430
|
+
let currentSection = 'news';
|
|
431
|
+
let pendingChanges = false;
|
|
432
|
+
|
|
433
|
+
function markPendingChanges() {
|
|
434
|
+
pendingChanges = true;
|
|
435
|
+
const btn = document.getElementById('publishGlobalBtn');
|
|
436
|
+
btn.disabled = false;
|
|
437
|
+
btn.textContent = 'Publish Changes';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function clearPendingChanges() {
|
|
441
|
+
pendingChanges = false;
|
|
442
|
+
const btn = document.getElementById('publishGlobalBtn');
|
|
443
|
+
btn.disabled = true;
|
|
444
|
+
btn.textContent = 'Publish Changes';
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function showModal(options) {
|
|
448
|
+
return new Promise((resolve) => {
|
|
449
|
+
modalResolve = resolve;
|
|
450
|
+
const modal = document.getElementById('modal');
|
|
451
|
+
const icon = document.getElementById('modalIcon');
|
|
452
|
+
const title = document.getElementById('modalTitle');
|
|
453
|
+
const body = document.getElementById('modalBody');
|
|
454
|
+
const confirmBtn = document.getElementById('modalConfirm');
|
|
455
|
+
const cancelBtn = document.getElementById('modalCancel');
|
|
456
|
+
|
|
457
|
+
// Set icon based on type
|
|
458
|
+
icon.className = 'modal-icon ' + (options.type || 'warning');
|
|
459
|
+
if (options.type === 'danger') {
|
|
460
|
+
icon.innerHTML = '🗑'; // trash icon
|
|
461
|
+
} else if (options.type === 'success') {
|
|
462
|
+
icon.innerHTML = '🚀'; // rocket icon
|
|
463
|
+
} else {
|
|
464
|
+
icon.innerHTML = '⚠'; // warning icon
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
title.textContent = options.title || 'Confirm';
|
|
468
|
+
body.textContent = options.message || 'Are you sure?';
|
|
469
|
+
|
|
470
|
+
// Set confirm button style based on type
|
|
471
|
+
confirmBtn.className = 'modal-btn ' + (options.type === 'danger' ? 'modal-btn-danger' : 'modal-btn-success');
|
|
472
|
+
confirmBtn.textContent = options.confirmText || 'Confirm';
|
|
473
|
+
cancelBtn.textContent = options.cancelText || 'Cancel';
|
|
474
|
+
|
|
475
|
+
modal.classList.add('active');
|
|
476
|
+
confirmBtn.focus();
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function closeModal(result) {
|
|
481
|
+
const modal = document.getElementById('modal');
|
|
482
|
+
modal.classList.remove('active');
|
|
483
|
+
if (modalResolve) {
|
|
484
|
+
modalResolve(result);
|
|
485
|
+
modalResolve = null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Set up modal event listeners
|
|
490
|
+
document.getElementById('modalConfirm').onclick = () => closeModal(true);
|
|
491
|
+
document.getElementById('modalCancel').onclick = () => closeModal(false);
|
|
492
|
+
document.getElementById('modal').onclick = (e) => {
|
|
493
|
+
if (e.target.id === 'modal') closeModal(false);
|
|
494
|
+
};
|
|
495
|
+
document.addEventListener('keydown', (e) => {
|
|
496
|
+
if (e.key === 'Escape') {
|
|
497
|
+
if (document.getElementById('previewModal').classList.contains('active')) {
|
|
498
|
+
closePreviewModal();
|
|
499
|
+
} else if (document.getElementById('modal').classList.contains('active')) {
|
|
500
|
+
closeModal(false);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
async function init() {
|
|
506
|
+
await loadSections();
|
|
507
|
+
await loadPosts();
|
|
508
|
+
newPost();
|
|
509
|
+
setupDropZone();
|
|
510
|
+
setupGalleryDropZone();
|
|
511
|
+
setupFormDirtyTracking();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function loadSections() {
|
|
515
|
+
sections = await (await fetch('/api/sections')).json();
|
|
516
|
+
const tabsContainer = document.getElementById('sectionTabs');
|
|
517
|
+
tabsContainer.innerHTML = '';
|
|
518
|
+
sections.forEach(s => {
|
|
519
|
+
const tab = document.createElement('button');
|
|
520
|
+
tab.className = 'section-tab' + (s.id === currentSection ? ' active' : '');
|
|
521
|
+
tab.textContent = s.name;
|
|
522
|
+
tab.onclick = () => switchSection(s.id);
|
|
523
|
+
tabsContainer.appendChild(tab);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function switchSection(sectionId) {
|
|
528
|
+
currentSection = sectionId;
|
|
529
|
+
document.querySelectorAll('.section-tab').forEach((tab, idx) => {
|
|
530
|
+
tab.classList.toggle('active', sections[idx].id === sectionId);
|
|
531
|
+
});
|
|
532
|
+
renderPostList();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function setupFormDirtyTracking() {
|
|
536
|
+
const form = document.getElementById('postForm');
|
|
537
|
+
const inputs = form.querySelectorAll('input, textarea, select');
|
|
538
|
+
inputs.forEach(input => {
|
|
539
|
+
input.addEventListener('input', () => markFormDirty());
|
|
540
|
+
input.addEventListener('change', () => markFormDirty());
|
|
541
|
+
// Auto-convert smart quotes on paste
|
|
542
|
+
if (input.tagName === 'INPUT' || input.tagName === 'TEXTAREA') {
|
|
543
|
+
input.addEventListener('paste', (e) => {
|
|
544
|
+
e.preventDefault();
|
|
545
|
+
const text = (e.clipboardData || window.clipboardData).getData('text');
|
|
546
|
+
const cleaned = text
|
|
547
|
+
.replace(/[\u201C\u201D]/g, '"') // " " → "
|
|
548
|
+
.replace(/[\u2018\u2019]/g, "'"); // ' ' → '
|
|
549
|
+
document.execCommand('insertText', false, cleaned);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function markFormDirty() {
|
|
556
|
+
formDirty = true;
|
|
557
|
+
const btn = document.getElementById('saveBtn');
|
|
558
|
+
btn.disabled = false;
|
|
559
|
+
btn.textContent = 'Save Draft';
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function markFormClean(saved = false) {
|
|
563
|
+
formDirty = false;
|
|
564
|
+
const btn = document.getElementById('saveBtn');
|
|
565
|
+
btn.disabled = true;
|
|
566
|
+
btn.textContent = saved ? 'Saved' : 'Save Draft';
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function loadPosts() {
|
|
570
|
+
posts = await (await fetch('/api/posts')).json();
|
|
571
|
+
renderPostList();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function renderPostList() {
|
|
575
|
+
const list = document.getElementById('postList');
|
|
576
|
+
list.innerHTML = '';
|
|
577
|
+
|
|
578
|
+
// Filter posts by current section
|
|
579
|
+
const sectionPosts = posts.filter(p => p.section === currentSection);
|
|
580
|
+
|
|
581
|
+
// Find current lede
|
|
582
|
+
const ledePost = sectionPosts.find(p => p.lede === true || p.lede === 'true');
|
|
583
|
+
const ledeIndicator = document.getElementById('ledeIndicator');
|
|
584
|
+
if (ledePost) {
|
|
585
|
+
ledeIndicator.textContent = 'Lead: ' + (ledePost.title || '').substring(0, 20) + (ledePost.title && ledePost.title.length > 20 ? '...' : '');
|
|
586
|
+
} else {
|
|
587
|
+
ledeIndicator.textContent = 'Lead: Most recent';
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Update section title
|
|
591
|
+
const section = sections.find(s => s.id === currentSection);
|
|
592
|
+
document.getElementById('sectionTitle').textContent = section ? section.name : 'Posts';
|
|
593
|
+
|
|
594
|
+
sectionPosts.forEach(p => {
|
|
595
|
+
const li = document.createElement('li');
|
|
596
|
+
li.dataset.path = p.path;
|
|
597
|
+
|
|
598
|
+
const statusClass = p.status === 'published' ? 'published' : 'draft';
|
|
599
|
+
const statusLabel = p.status === 'published' ? 'Published' : 'Draft';
|
|
600
|
+
const isLede = p.lede === true || p.lede === 'true';
|
|
601
|
+
|
|
602
|
+
li.innerHTML = '<div class="post-row">' +
|
|
603
|
+
'<div class="post-info" onclick="loadPost(\'' + p.path.replace(/'/g, "\\'") + '\')">' +
|
|
604
|
+
'<div class="post-title">' + (p.title || p.path) +
|
|
605
|
+
'<span class="post-status ' + statusClass + '">' + statusLabel + '</span>' +
|
|
606
|
+
(isLede ? '<span class="post-status lede">Lead</span>' : '') +
|
|
607
|
+
'</div>' +
|
|
608
|
+
'<div class="post-date">' + (p.date || '') + '</div>' +
|
|
609
|
+
'</div>' +
|
|
610
|
+
'<div class="post-actions">' +
|
|
611
|
+
'<button class="btn-lede' + (isLede ? ' is-lede' : '') + '" onclick="event.stopPropagation(); setAsLede(\'' + p.path.replace(/'/g, "\\'") + '\')" title="' + (isLede ? 'Currently lead story' : 'Set as lead story') + '">' +
|
|
612
|
+
(isLede ? '★ Lead' : '☆ Set Lead') +
|
|
613
|
+
'</button>' +
|
|
614
|
+
'</div>' +
|
|
615
|
+
'</div>';
|
|
616
|
+
|
|
617
|
+
li.querySelector('.post-info').onclick = () => loadPost(p.path);
|
|
618
|
+
list.appendChild(li);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function setAsLede(path) {
|
|
623
|
+
const confirmed = await showModal({
|
|
624
|
+
type: 'success',
|
|
625
|
+
title: 'Set Lead Story',
|
|
626
|
+
message: 'This will make this post the lead story for this section. The previous lead story (if any) will be demoted. Remember to Publish Changes when done.',
|
|
627
|
+
confirmText: 'Set as Lead',
|
|
628
|
+
cancelText: 'Cancel'
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
if (!confirmed) return;
|
|
632
|
+
|
|
633
|
+
showStatus('Setting lead story...', 'info');
|
|
634
|
+
try {
|
|
635
|
+
const res = await fetch('/api/posts/set-lede/' + path, { method: 'POST' });
|
|
636
|
+
const result = await res.json();
|
|
637
|
+
if (result.success) {
|
|
638
|
+
showStatus('Lead story updated! Click "Publish Changes" to push to live site.', 'success');
|
|
639
|
+
markPendingChanges();
|
|
640
|
+
await loadPosts();
|
|
641
|
+
} else {
|
|
642
|
+
showStatus('Error: ' + result.error, 'error');
|
|
643
|
+
}
|
|
644
|
+
} catch (err) {
|
|
645
|
+
showStatus('Error: ' + err.message, 'error');
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function loadPost(path) {
|
|
650
|
+
const post = await (await fetch('/api/posts/' + path)).json();
|
|
651
|
+
document.getElementById('postPath').value = path;
|
|
652
|
+
document.getElementById('postStatus').value = post.status || 'draft';
|
|
653
|
+
document.getElementById('postLede').value = post.lede === true || post.lede === 'true' ? 'true' : 'false';
|
|
654
|
+
document.getElementById('title').value = post.title || '';
|
|
655
|
+
document.getElementById('synopsis').value = post.synopsis || '';
|
|
656
|
+
document.getElementById('tags').value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags || '');
|
|
657
|
+
document.getElementById('author').value = post.author || '';
|
|
658
|
+
// Handle both date-only (YYYY-MM-DD) and datetime (YYYY-MM-DDTHH:MM) formats
|
|
659
|
+
let dateVal = post.date || '';
|
|
660
|
+
if (dateVal && dateVal.length === 10) {
|
|
661
|
+
dateVal = dateVal + 'T12:00'; // Add noon time for date-only values
|
|
662
|
+
}
|
|
663
|
+
document.getElementById('date').value = dateVal;
|
|
664
|
+
document.getElementById('image').value = post.image || '';
|
|
665
|
+
document.getElementById('imageCaption').value = post.imageCaption || '';
|
|
666
|
+
document.getElementById('imageCredit').value = post.imageCredit || '';
|
|
667
|
+
// Extract folder from path (e.g., 'heroes/alex-pretti.md' -> 'heroes')
|
|
668
|
+
const pathParts = path.split('/');
|
|
669
|
+
document.getElementById('folder').value = pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : '';
|
|
670
|
+
document.getElementById('body').value = post.body || '';
|
|
671
|
+
document.getElementById('showGallery').checked = post.showGallery === true || post.showGallery === 'true';
|
|
672
|
+
gallery = Array.isArray(post.gallery) ? [...post.gallery] : [];
|
|
673
|
+
images = Array.isArray(post.images) ? [...post.images] : [];
|
|
674
|
+
renderGalleryThumbnails();
|
|
675
|
+
syncInlineImages();
|
|
676
|
+
document.getElementById('deleteBtn').style.display = 'inline-block';
|
|
677
|
+
document.getElementById('publishBtn').style.display = 'inline-block';
|
|
678
|
+
previewImage();
|
|
679
|
+
document.querySelectorAll('.post-list li').forEach(li => li.classList.toggle('active', li.dataset.path === path));
|
|
680
|
+
showStatus('');
|
|
681
|
+
markFormClean(false);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function newPost() {
|
|
685
|
+
document.getElementById('postPath').value = '';
|
|
686
|
+
document.getElementById('postStatus').value = 'draft';
|
|
687
|
+
document.getElementById('postLede').value = 'false';
|
|
688
|
+
document.getElementById('postForm').reset();
|
|
689
|
+
// Set current date and time (format: YYYY-MM-DDTHH:MM)
|
|
690
|
+
const now = new Date();
|
|
691
|
+
document.getElementById('date').value = now.toISOString().slice(0, 16);
|
|
692
|
+
// Set default folder based on current section
|
|
693
|
+
const section = sections.find(s => s.id === currentSection);
|
|
694
|
+
document.getElementById('folder').value = section ? section.folder : '';
|
|
695
|
+
document.getElementById('deleteBtn').style.display = 'none';
|
|
696
|
+
document.getElementById('publishBtn').style.display = 'none';
|
|
697
|
+
document.getElementById('imagePreview').style.display = 'none';
|
|
698
|
+
document.getElementById('leadPhotoFields').style.display = 'none';
|
|
699
|
+
document.getElementById('imageCaption').value = '';
|
|
700
|
+
document.getElementById('imageCredit').value = '';
|
|
701
|
+
gallery = [];
|
|
702
|
+
images = [];
|
|
703
|
+
renderGalleryThumbnails();
|
|
704
|
+
syncInlineImages();
|
|
705
|
+
document.querySelectorAll('.post-list li').forEach(li => li.classList.remove('active'));
|
|
706
|
+
showStatus('');
|
|
707
|
+
markFormClean(false);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function cancelPost() {
|
|
711
|
+
if (formDirty) {
|
|
712
|
+
const confirmed = await showModal({
|
|
713
|
+
type: 'warning',
|
|
714
|
+
title: 'Discard Changes',
|
|
715
|
+
message: 'You have unsaved changes. Are you sure you want to discard them?',
|
|
716
|
+
confirmText: 'Discard',
|
|
717
|
+
cancelText: 'Keep Editing'
|
|
718
|
+
});
|
|
719
|
+
if (!confirmed) return;
|
|
720
|
+
}
|
|
721
|
+
newPost();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function savePost(e) {
|
|
725
|
+
e.preventDefault();
|
|
726
|
+
syncInlineImages();
|
|
727
|
+
const btn = document.getElementById('saveBtn');
|
|
728
|
+
const path = document.getElementById('postPath').value;
|
|
729
|
+
const data = {
|
|
730
|
+
title: document.getElementById('title').value,
|
|
731
|
+
synopsis: document.getElementById('synopsis').value,
|
|
732
|
+
tags: document.getElementById('tags').value,
|
|
733
|
+
author: document.getElementById('author').value,
|
|
734
|
+
date: document.getElementById('date').value,
|
|
735
|
+
image: document.getElementById('image').value,
|
|
736
|
+
imageCaption: document.getElementById('imageCaption').value,
|
|
737
|
+
imageCredit: document.getElementById('imageCredit').value,
|
|
738
|
+
folder: document.getElementById('folder').value,
|
|
739
|
+
body: document.getElementById('body').value,
|
|
740
|
+
status: document.getElementById('postStatus').value,
|
|
741
|
+
lede: document.getElementById('postLede').value === 'true',
|
|
742
|
+
gallery: gallery,
|
|
743
|
+
images: images,
|
|
744
|
+
showGallery: document.getElementById('showGallery').checked
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
btn.textContent = 'Saving...';
|
|
748
|
+
btn.disabled = true;
|
|
749
|
+
|
|
750
|
+
try {
|
|
751
|
+
const res = path
|
|
752
|
+
? await fetch('/api/posts/' + path, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) })
|
|
753
|
+
: await fetch('/api/posts', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) });
|
|
754
|
+
const result = await res.json();
|
|
755
|
+
if (result.success) {
|
|
756
|
+
showStatus('Draft saved!', 'success');
|
|
757
|
+
markFormClean(true);
|
|
758
|
+
await loadPosts();
|
|
759
|
+
if (result.path) loadPost(result.path);
|
|
760
|
+
} else {
|
|
761
|
+
showStatus('Error: ' + (result.error || 'Unknown'), 'error');
|
|
762
|
+
btn.textContent = 'Save Draft';
|
|
763
|
+
btn.disabled = false;
|
|
764
|
+
}
|
|
765
|
+
} catch (err) {
|
|
766
|
+
showStatus('Error: ' + err.message, 'error');
|
|
767
|
+
btn.textContent = 'Save Draft';
|
|
768
|
+
btn.disabled = false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function deletePost() {
|
|
773
|
+
const path = document.getElementById('postPath').value;
|
|
774
|
+
if (!path) return;
|
|
775
|
+
|
|
776
|
+
const confirmed = await showModal({
|
|
777
|
+
type: 'danger',
|
|
778
|
+
title: 'Delete Post',
|
|
779
|
+
message: 'Are you sure you want to delete this post? This action cannot be undone.',
|
|
780
|
+
confirmText: 'Delete',
|
|
781
|
+
cancelText: 'Cancel'
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
if (!confirmed) return;
|
|
785
|
+
|
|
786
|
+
await fetch('/api/posts/' + path, { method: 'DELETE' });
|
|
787
|
+
showStatus('Post deleted', 'success');
|
|
788
|
+
await loadPosts();
|
|
789
|
+
newPost();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function previewPost() {
|
|
793
|
+
syncInlineImages();
|
|
794
|
+
const data = {
|
|
795
|
+
title: document.getElementById('title').value,
|
|
796
|
+
synopsis: document.getElementById('synopsis').value,
|
|
797
|
+
tags: document.getElementById('tags').value,
|
|
798
|
+
author: document.getElementById('author').value,
|
|
799
|
+
date: document.getElementById('date').value,
|
|
800
|
+
image: document.getElementById('image').value,
|
|
801
|
+
imageCaption: document.getElementById('imageCaption').value,
|
|
802
|
+
imageCredit: document.getElementById('imageCredit').value,
|
|
803
|
+
body: document.getElementById('body').value,
|
|
804
|
+
gallery: JSON.stringify(gallery),
|
|
805
|
+
images: JSON.stringify(images),
|
|
806
|
+
showGallery: document.getElementById('showGallery').checked
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
try {
|
|
810
|
+
const res = await fetch('/api/preview', {
|
|
811
|
+
method: 'POST',
|
|
812
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
813
|
+
body: new URLSearchParams(data).toString()
|
|
814
|
+
});
|
|
815
|
+
const html = await res.text();
|
|
816
|
+
|
|
817
|
+
const iframe = document.getElementById('previewIframe');
|
|
818
|
+
iframe.srcdoc = html;
|
|
819
|
+
document.getElementById('previewModal').classList.add('active');
|
|
820
|
+
document.body.style.overflow = 'hidden';
|
|
821
|
+
} catch (err) {
|
|
822
|
+
showStatus('Preview error: ' + err.message, 'error');
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function closePreviewModal() {
|
|
827
|
+
document.getElementById('previewModal').classList.remove('active');
|
|
828
|
+
document.getElementById('previewIframe').srcdoc = '';
|
|
829
|
+
document.body.style.overflow = '';
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function publishPost() {
|
|
833
|
+
let path = document.getElementById('postPath').value;
|
|
834
|
+
|
|
835
|
+
const image = document.getElementById('image').value;
|
|
836
|
+
if (!image) {
|
|
837
|
+
showStatus('Lead photo is required to publish', 'error');
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const confirmed = await showModal({
|
|
842
|
+
type: 'success',
|
|
843
|
+
title: 'Publish Post',
|
|
844
|
+
message: 'This will save and commit the post to git and push to main. The changes will be deployed to the live site.',
|
|
845
|
+
confirmText: 'Publish',
|
|
846
|
+
cancelText: 'Cancel'
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
if (!confirmed) return;
|
|
850
|
+
|
|
851
|
+
showStatus('Saving...', 'info');
|
|
852
|
+
document.getElementById('publishBtn').disabled = true;
|
|
853
|
+
syncInlineImages();
|
|
854
|
+
|
|
855
|
+
// Save the post first (whether new or existing)
|
|
856
|
+
const data = {
|
|
857
|
+
title: document.getElementById('title').value,
|
|
858
|
+
synopsis: document.getElementById('synopsis').value,
|
|
859
|
+
tags: document.getElementById('tags').value,
|
|
860
|
+
author: document.getElementById('author').value,
|
|
861
|
+
date: document.getElementById('date').value,
|
|
862
|
+
image: document.getElementById('image').value,
|
|
863
|
+
imageCaption: document.getElementById('imageCaption').value,
|
|
864
|
+
imageCredit: document.getElementById('imageCredit').value,
|
|
865
|
+
folder: document.getElementById('folder').value,
|
|
866
|
+
body: document.getElementById('body').value,
|
|
867
|
+
status: document.getElementById('postStatus').value,
|
|
868
|
+
lede: document.getElementById('postLede').value === 'true',
|
|
869
|
+
gallery: gallery,
|
|
870
|
+
images: images,
|
|
871
|
+
showGallery: document.getElementById('showGallery').checked
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
const saveRes = path
|
|
876
|
+
? await fetch('/api/posts/' + path, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) })
|
|
877
|
+
: await fetch('/api/posts', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) });
|
|
878
|
+
const saveResult = await saveRes.json();
|
|
879
|
+
|
|
880
|
+
if (!saveResult.success) {
|
|
881
|
+
showStatus('Error saving: ' + (saveResult.error || 'Unknown'), 'error');
|
|
882
|
+
document.getElementById('publishBtn').disabled = false;
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// If new post, get the path
|
|
887
|
+
if (saveResult.path) {
|
|
888
|
+
path = saveResult.path;
|
|
889
|
+
document.getElementById('postPath').value = path;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
markFormClean(true);
|
|
893
|
+
} catch (err) {
|
|
894
|
+
showStatus('Error saving: ' + err.message, 'error');
|
|
895
|
+
document.getElementById('publishBtn').disabled = false;
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
showStatus('Publishing...', 'info');
|
|
900
|
+
|
|
901
|
+
try {
|
|
902
|
+
const res = await fetch('/api/publish/' + path, { method: 'POST' });
|
|
903
|
+
const result = await res.json();
|
|
904
|
+
if (result.success) {
|
|
905
|
+
showStatus('Published! Branch: ' + result.branch + ' merged to main and pushed.', 'success');
|
|
906
|
+
document.getElementById('postStatus').value = 'published';
|
|
907
|
+
await loadPosts();
|
|
908
|
+
} else {
|
|
909
|
+
showStatus('Publish error: ' + result.error, 'error');
|
|
910
|
+
}
|
|
911
|
+
} catch (err) {
|
|
912
|
+
showStatus('Publish error: ' + err.message, 'error');
|
|
913
|
+
}
|
|
914
|
+
document.getElementById('publishBtn').disabled = false;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function previewImage() {
|
|
918
|
+
const img = document.getElementById('image').value;
|
|
919
|
+
const preview = document.getElementById('imagePreview');
|
|
920
|
+
const fields = document.getElementById('leadPhotoFields');
|
|
921
|
+
if (img) {
|
|
922
|
+
preview.src = imageUrlPath + '/' + img;
|
|
923
|
+
preview.style.display = 'block';
|
|
924
|
+
fields.style.display = 'block';
|
|
925
|
+
} else {
|
|
926
|
+
preview.style.display = 'none';
|
|
927
|
+
fields.style.display = 'none';
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function setupDropZone() {
|
|
932
|
+
const dropZone = document.getElementById('dropZone');
|
|
933
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
|
|
934
|
+
dropZone.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); });
|
|
935
|
+
});
|
|
936
|
+
['dragenter', 'dragover'].forEach(evt => {
|
|
937
|
+
dropZone.addEventListener(evt, () => dropZone.classList.add('dragover'));
|
|
938
|
+
});
|
|
939
|
+
['dragleave', 'drop'].forEach(evt => {
|
|
940
|
+
dropZone.addEventListener(evt, () => dropZone.classList.remove('dragover'));
|
|
941
|
+
});
|
|
942
|
+
dropZone.addEventListener('drop', e => {
|
|
943
|
+
const file = e.dataTransfer.files[0];
|
|
944
|
+
if (file && file.type.startsWith('image/')) uploadFile(file);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function handleFileSelect(e) {
|
|
949
|
+
const file = e.target.files[0];
|
|
950
|
+
if (file) uploadFile(file);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function uploadFile(file) {
|
|
954
|
+
showStatus('Uploading and generating sizes...', 'info');
|
|
955
|
+
const reader = new FileReader();
|
|
956
|
+
reader.onload = async (e) => {
|
|
957
|
+
try {
|
|
958
|
+
const res = await fetch('/api/images', {
|
|
959
|
+
method: 'POST',
|
|
960
|
+
headers: { 'Content-Type': 'application/json' },
|
|
961
|
+
body: JSON.stringify({ filename: file.name, data: e.target.result })
|
|
962
|
+
});
|
|
963
|
+
const result = await res.json();
|
|
964
|
+
if (result.success) {
|
|
965
|
+
const sizeInfo = result.sizes ? ' (' + result.sizes.length + ' sizes: ' + result.sizes.join(', ') + ')' : '';
|
|
966
|
+
showStatus('Image uploaded: ' + result.filename + sizeInfo, 'success');
|
|
967
|
+
document.getElementById('image').value = result.filename;
|
|
968
|
+
previewImage();
|
|
969
|
+
} else {
|
|
970
|
+
showStatus('Upload error: ' + result.error, 'error');
|
|
971
|
+
}
|
|
972
|
+
} catch (err) {
|
|
973
|
+
showStatus('Upload error: ' + err.message, 'error');
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
reader.readAsDataURL(file);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Gallery functions
|
|
980
|
+
function handleGalleryFileSelect(e) {
|
|
981
|
+
const files = Array.from(e.target.files);
|
|
982
|
+
files.forEach(file => uploadGalleryFile(file));
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function uploadGalleryFile(file) {
|
|
986
|
+
showStatus('Uploading gallery image...', 'info');
|
|
987
|
+
const reader = new FileReader();
|
|
988
|
+
reader.onload = async (e) => {
|
|
989
|
+
try {
|
|
990
|
+
const res = await fetch('/api/images', {
|
|
991
|
+
method: 'POST',
|
|
992
|
+
headers: { 'Content-Type': 'application/json' },
|
|
993
|
+
body: JSON.stringify({ filename: file.name, data: e.target.result })
|
|
994
|
+
});
|
|
995
|
+
const result = await res.json();
|
|
996
|
+
if (result.success) {
|
|
997
|
+
gallery.push({ src: result.filename, caption: '', credit: '' });
|
|
998
|
+
renderGalleryThumbnails();
|
|
999
|
+
markFormDirty();
|
|
1000
|
+
showStatus('Gallery image uploaded: ' + result.filename, 'success');
|
|
1001
|
+
} else {
|
|
1002
|
+
showStatus('Upload error: ' + result.error, 'error');
|
|
1003
|
+
}
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
showStatus('Upload error: ' + err.message, 'error');
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
reader.readAsDataURL(file);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
let draggedIndex = null;
|
|
1012
|
+
|
|
1013
|
+
function renderGalleryThumbnails() {
|
|
1014
|
+
const container = document.getElementById('galleryThumbnails');
|
|
1015
|
+
container.innerHTML = '';
|
|
1016
|
+
gallery.forEach((item, idx) => {
|
|
1017
|
+
// Handle both old string format and new object format
|
|
1018
|
+
const img = typeof item === 'string' ? item : item.src;
|
|
1019
|
+
const hasMetadata = typeof item === 'object' && (item.caption || item.credit);
|
|
1020
|
+
const thumb = document.createElement('div');
|
|
1021
|
+
thumb.className = 'gallery-thumb' + (hasMetadata ? ' has-metadata' : '');
|
|
1022
|
+
thumb.draggable = true;
|
|
1023
|
+
thumb.dataset.index = idx;
|
|
1024
|
+
thumb.innerHTML =
|
|
1025
|
+
'<img src="' + imageUrlPath + '/' + img + '" alt="' + img + '">' +
|
|
1026
|
+
'<div class="gallery-thumb-actions">' +
|
|
1027
|
+
'<button type="button" class="gallery-thumb-btn edit" onclick="openGalleryEditModal(' + idx + ')" title="Edit caption/credit">✎</button>' +
|
|
1028
|
+
'<button type="button" class="gallery-thumb-btn copy" onclick="copyImageMarkdown('' + img + '')" title="Copy markdown">📋</button>' +
|
|
1029
|
+
'<button type="button" class="gallery-thumb-btn remove" onclick="removeFromGallery(' + idx + ')" title="Remove from gallery">✕</button>' +
|
|
1030
|
+
'</div>' +
|
|
1031
|
+
'<div class="gallery-thumb-name">' + img + '</div>';
|
|
1032
|
+
|
|
1033
|
+
thumb.addEventListener('dragstart', (e) => {
|
|
1034
|
+
draggedIndex = idx;
|
|
1035
|
+
thumb.classList.add('dragging');
|
|
1036
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
thumb.addEventListener('dragend', () => {
|
|
1040
|
+
thumb.classList.remove('dragging');
|
|
1041
|
+
draggedIndex = null;
|
|
1042
|
+
document.querySelectorAll('.gallery-thumb').forEach(el => el.classList.remove('drag-over'));
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
thumb.addEventListener('dragover', (e) => {
|
|
1046
|
+
e.preventDefault();
|
|
1047
|
+
e.dataTransfer.dropEffect = 'move';
|
|
1048
|
+
if (draggedIndex !== null && draggedIndex !== idx) {
|
|
1049
|
+
thumb.classList.add('drag-over');
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
thumb.addEventListener('dragleave', () => {
|
|
1054
|
+
thumb.classList.remove('drag-over');
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
thumb.addEventListener('drop', (e) => {
|
|
1058
|
+
e.preventDefault();
|
|
1059
|
+
thumb.classList.remove('drag-over');
|
|
1060
|
+
if (draggedIndex !== null && draggedIndex !== idx) {
|
|
1061
|
+
const item = gallery.splice(draggedIndex, 1)[0];
|
|
1062
|
+
gallery.splice(idx, 0, item);
|
|
1063
|
+
renderGalleryThumbnails();
|
|
1064
|
+
markFormDirty();
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
container.appendChild(thumb);
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function copyImageMarkdown(filename) {
|
|
1073
|
+
const markdown = '';
|
|
1074
|
+
navigator.clipboard.writeText(markdown).then(() => {
|
|
1075
|
+
showStatus('Copied: ' + markdown, 'success');
|
|
1076
|
+
}).catch(err => {
|
|
1077
|
+
showStatus('Failed to copy: ' + err.message, 'error');
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Inline Images functions
|
|
1082
|
+
function syncInlineImages() {
|
|
1083
|
+
const body = document.getElementById('body').value;
|
|
1084
|
+
// Match markdown images:  - extract the src (handles attrs like {.class})
|
|
1085
|
+
const imgRegex = /!\[[^\]]*\]\(([^)\s]+)\)(?:\{[^}]*\})?/g;
|
|
1086
|
+
const foundImages = [];
|
|
1087
|
+
let match;
|
|
1088
|
+
// Strip imageUrlPath prefix to get just the filename
|
|
1089
|
+
const urlPrefix = imageUrlPath.replace(/^\//, '') + '/';
|
|
1090
|
+
while ((match = imgRegex.exec(body)) !== null) {
|
|
1091
|
+
let src = match[1].replace(/^\//, ''); // Remove leading slash
|
|
1092
|
+
// Strip the imageUrlPath prefix if present (e.g., "img/" from "/img/file.jpg")
|
|
1093
|
+
if (src.startsWith(urlPrefix)) {
|
|
1094
|
+
src = src.slice(urlPrefix.length);
|
|
1095
|
+
}
|
|
1096
|
+
// Also handle /uploads/ prefix for backwards compatibility
|
|
1097
|
+
if (src.startsWith('uploads/')) {
|
|
1098
|
+
src = src.slice(8);
|
|
1099
|
+
}
|
|
1100
|
+
// Skip size variants (-400, -600, -800)
|
|
1101
|
+
if (/-(?:400|600|800)\.[^.]+$/.test(src)) continue;
|
|
1102
|
+
foundImages.push(src);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Update images array: keep existing metadata, add new images, remove missing
|
|
1106
|
+
const newImages = [];
|
|
1107
|
+
foundImages.forEach(src => {
|
|
1108
|
+
const existing = images.find(img => img.src === src);
|
|
1109
|
+
if (existing) {
|
|
1110
|
+
newImages.push(existing);
|
|
1111
|
+
} else {
|
|
1112
|
+
newImages.push({ src, caption: '', credit: '' });
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
images = newImages;
|
|
1116
|
+
renderInlineImages();
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function renderInlineImages() {
|
|
1120
|
+
const container = document.getElementById('inlineImagesList');
|
|
1121
|
+
const section = document.getElementById('inlineImagesSection');
|
|
1122
|
+
|
|
1123
|
+
if (images.length === 0) {
|
|
1124
|
+
section.style.display = 'none';
|
|
1125
|
+
container.innerHTML = '';
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
section.style.display = 'block';
|
|
1130
|
+
container.innerHTML = '';
|
|
1131
|
+
|
|
1132
|
+
images.forEach((item, idx) => {
|
|
1133
|
+
const hasMetadata = item.caption || item.credit;
|
|
1134
|
+
const thumb = document.createElement('div');
|
|
1135
|
+
thumb.className = 'gallery-thumb' + (hasMetadata ? ' has-metadata' : '');
|
|
1136
|
+
|
|
1137
|
+
thumb.innerHTML =
|
|
1138
|
+
'<img src="' + imageUrlPath + '/' + item.src.replace(/(\.[^.]+)$/, '-thumb$1') + '" alt="" onerror="this.onerror=null; this.src=\'' + imageUrlPath + '/' + item.src + '\'">' +
|
|
1139
|
+
'<div class="gallery-thumb-actions">' +
|
|
1140
|
+
'<button type="button" class="gallery-thumb-btn edit" onclick="openInlineImageEditModal(' + idx + ')" title="Edit caption/credit">✎</button>' +
|
|
1141
|
+
'</div>' +
|
|
1142
|
+
'<div class="gallery-thumb-name">' + item.src + '</div>';
|
|
1143
|
+
|
|
1144
|
+
container.appendChild(thumb);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function openInlineImageEditModal(idx) {
|
|
1149
|
+
editingImageIndex = idx;
|
|
1150
|
+
const item = images[idx];
|
|
1151
|
+
|
|
1152
|
+
document.getElementById('galleryCaption').value = item.caption || '';
|
|
1153
|
+
document.getElementById('galleryCredit').value = item.credit || '';
|
|
1154
|
+
document.getElementById('captionCount').textContent = (item.caption || '').length;
|
|
1155
|
+
document.getElementById('creditCount').textContent = (item.credit || '').length;
|
|
1156
|
+
|
|
1157
|
+
// Update modal title to indicate inline image
|
|
1158
|
+
document.querySelector('#galleryEditModal .modal-title').textContent = 'Edit Inline Image Details';
|
|
1159
|
+
document.getElementById('galleryEditModal').classList.add('active');
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function closeInlineImageEditModal() {
|
|
1163
|
+
document.getElementById('galleryEditModal').classList.remove('active');
|
|
1164
|
+
editingImageIndex = null;
|
|
1165
|
+
// Restore modal title
|
|
1166
|
+
document.querySelector('#galleryEditModal .modal-title').textContent = 'Edit Photo Details';
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function saveInlineImageEdit() {
|
|
1170
|
+
if (editingImageIndex === null) return;
|
|
1171
|
+
|
|
1172
|
+
const caption = document.getElementById('galleryCaption').value.trim();
|
|
1173
|
+
const credit = document.getElementById('galleryCredit').value.trim();
|
|
1174
|
+
|
|
1175
|
+
images[editingImageIndex] = {
|
|
1176
|
+
src: images[editingImageIndex].src,
|
|
1177
|
+
caption,
|
|
1178
|
+
credit
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
closeInlineImageEditModal();
|
|
1182
|
+
renderInlineImages();
|
|
1183
|
+
markFormDirty();
|
|
1184
|
+
showStatus('Image details saved', 'success');
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Listen for body changes to sync inline images
|
|
1188
|
+
document.getElementById('body').addEventListener('blur', syncInlineImages);
|
|
1189
|
+
|
|
1190
|
+
async function removeFromGallery(idx) {
|
|
1191
|
+
const item = gallery[idx];
|
|
1192
|
+
const imgName = typeof item === 'string' ? item : item.src;
|
|
1193
|
+
|
|
1194
|
+
const confirmed = await showModal({
|
|
1195
|
+
type: 'danger',
|
|
1196
|
+
title: 'Remove Photo',
|
|
1197
|
+
message: 'Remove this photo from the gallery and delete the file? This cannot be undone.',
|
|
1198
|
+
confirmText: 'Delete',
|
|
1199
|
+
cancelText: 'Cancel'
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
if (!confirmed) return;
|
|
1203
|
+
|
|
1204
|
+
// Delete from server
|
|
1205
|
+
try {
|
|
1206
|
+
const res = await fetch('/api/images/' + encodeURIComponent(imgName), { method: 'DELETE' });
|
|
1207
|
+
const result = await res.json();
|
|
1208
|
+
if (result.success) {
|
|
1209
|
+
gallery.splice(idx, 1);
|
|
1210
|
+
renderGalleryThumbnails();
|
|
1211
|
+
markFormDirty();
|
|
1212
|
+
markPendingChanges();
|
|
1213
|
+
const gitMsg = result.gitTracked && result.gitTracked.length > 0
|
|
1214
|
+
? ' Staged for git deletion.'
|
|
1215
|
+
: '';
|
|
1216
|
+
showStatus('Photo deleted.' + gitMsg, 'success');
|
|
1217
|
+
} else {
|
|
1218
|
+
showStatus('Error deleting photo: ' + result.error, 'error');
|
|
1219
|
+
}
|
|
1220
|
+
} catch (err) {
|
|
1221
|
+
showStatus('Error deleting photo: ' + err.message, 'error');
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function openGalleryEditModal(idx) {
|
|
1226
|
+
editingGalleryIndex = idx;
|
|
1227
|
+
const item = gallery[idx];
|
|
1228
|
+
// Handle both string and object format
|
|
1229
|
+
const caption = typeof item === 'object' ? (item.caption || '') : '';
|
|
1230
|
+
const credit = typeof item === 'object' ? (item.credit || '') : '';
|
|
1231
|
+
|
|
1232
|
+
document.getElementById('galleryCaption').value = caption;
|
|
1233
|
+
document.getElementById('galleryCredit').value = credit;
|
|
1234
|
+
document.getElementById('captionCount').textContent = caption.length;
|
|
1235
|
+
document.getElementById('creditCount').textContent = credit.length;
|
|
1236
|
+
document.getElementById('galleryEditModal').classList.add('active');
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function closeGalleryEditModal() {
|
|
1240
|
+
document.getElementById('galleryEditModal').classList.remove('active');
|
|
1241
|
+
editingGalleryIndex = null;
|
|
1242
|
+
editingImageIndex = null;
|
|
1243
|
+
// Restore modal title
|
|
1244
|
+
document.querySelector('#galleryEditModal .modal-title').textContent = 'Edit Photo Details';
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function saveGalleryEdit() {
|
|
1248
|
+
// Check if editing inline image or gallery image
|
|
1249
|
+
if (editingImageIndex !== null) {
|
|
1250
|
+
saveInlineImageEdit();
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (editingGalleryIndex === null) return;
|
|
1255
|
+
|
|
1256
|
+
const caption = document.getElementById('galleryCaption').value.trim();
|
|
1257
|
+
const credit = document.getElementById('galleryCredit').value.trim();
|
|
1258
|
+
const item = gallery[editingGalleryIndex];
|
|
1259
|
+
|
|
1260
|
+
// Convert string to object if needed
|
|
1261
|
+
const src = typeof item === 'string' ? item : item.src;
|
|
1262
|
+
gallery[editingGalleryIndex] = { src, caption, credit };
|
|
1263
|
+
|
|
1264
|
+
closeGalleryEditModal();
|
|
1265
|
+
renderGalleryThumbnails();
|
|
1266
|
+
markFormDirty();
|
|
1267
|
+
showStatus('Photo details saved', 'success');
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Character count listeners for gallery edit modal
|
|
1271
|
+
document.getElementById('galleryCaption').addEventListener('input', (e) => {
|
|
1272
|
+
document.getElementById('captionCount').textContent = e.target.value.length;
|
|
1273
|
+
});
|
|
1274
|
+
document.getElementById('galleryCredit').addEventListener('input', (e) => {
|
|
1275
|
+
document.getElementById('creditCount').textContent = e.target.value.length;
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
function setupGalleryDropZone() {
|
|
1279
|
+
const dropZone = document.getElementById('galleryDropZone');
|
|
1280
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
|
|
1281
|
+
dropZone.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); });
|
|
1282
|
+
});
|
|
1283
|
+
['dragenter', 'dragover'].forEach(evt => {
|
|
1284
|
+
dropZone.addEventListener(evt, () => dropZone.classList.add('dragover'));
|
|
1285
|
+
});
|
|
1286
|
+
['dragleave', 'drop'].forEach(evt => {
|
|
1287
|
+
dropZone.addEventListener(evt, () => dropZone.classList.remove('dragover'));
|
|
1288
|
+
});
|
|
1289
|
+
dropZone.addEventListener('drop', e => {
|
|
1290
|
+
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
|
|
1291
|
+
files.forEach(file => uploadGalleryFile(file));
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function showStatus(msg, type) {
|
|
1296
|
+
const el = document.getElementById('status');
|
|
1297
|
+
el.textContent = msg;
|
|
1298
|
+
el.className = 'status' + (type ? ' ' + type : '');
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
async function publishChanges() {
|
|
1302
|
+
if (!pendingChanges) return;
|
|
1303
|
+
|
|
1304
|
+
const confirmed = await showModal({
|
|
1305
|
+
type: 'success',
|
|
1306
|
+
title: 'Publish All Changes',
|
|
1307
|
+
message: 'This will commit all pending changes and push to the live site.',
|
|
1308
|
+
confirmText: 'Publish',
|
|
1309
|
+
cancelText: 'Cancel'
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
if (!confirmed) return;
|
|
1313
|
+
|
|
1314
|
+
const btn = document.getElementById('publishGlobalBtn');
|
|
1315
|
+
btn.textContent = 'Publishing...';
|
|
1316
|
+
btn.disabled = true;
|
|
1317
|
+
showStatus('Publishing changes...', 'info');
|
|
1318
|
+
|
|
1319
|
+
try {
|
|
1320
|
+
const res = await fetch('/api/publish-changes', { method: 'POST' });
|
|
1321
|
+
const result = await res.json();
|
|
1322
|
+
if (result.success) {
|
|
1323
|
+
showStatus('Changes published! ' + result.message, 'success');
|
|
1324
|
+
clearPendingChanges();
|
|
1325
|
+
} else {
|
|
1326
|
+
showStatus('Publish error: ' + result.error, 'error');
|
|
1327
|
+
btn.disabled = false;
|
|
1328
|
+
btn.textContent = 'Publish Changes';
|
|
1329
|
+
}
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
showStatus('Publish error: ' + err.message, 'error');
|
|
1332
|
+
btn.disabled = false;
|
|
1333
|
+
btn.textContent = 'Publish Changes';
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function generateSynopsis() {
|
|
1338
|
+
const title = document.getElementById('title').value;
|
|
1339
|
+
const body = document.getElementById('body').value;
|
|
1340
|
+
|
|
1341
|
+
if (!title && !body) {
|
|
1342
|
+
showStatus('Add a title or content first to generate a synopsis', 'error');
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const btn = document.getElementById('generateBtn');
|
|
1347
|
+
const originalText = btn.textContent;
|
|
1348
|
+
btn.textContent = '⏳ Generating...';
|
|
1349
|
+
btn.disabled = true;
|
|
1350
|
+
|
|
1351
|
+
try {
|
|
1352
|
+
const res = await fetch('/api/generate-synopsis', {
|
|
1353
|
+
method: 'POST',
|
|
1354
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1355
|
+
body: JSON.stringify({ title, body })
|
|
1356
|
+
});
|
|
1357
|
+
const result = await res.json();
|
|
1358
|
+
|
|
1359
|
+
if (result.success) {
|
|
1360
|
+
document.getElementById('synopsis').value = result.synopsis;
|
|
1361
|
+
markFormDirty();
|
|
1362
|
+
showStatus('Synopsis generated!', 'success');
|
|
1363
|
+
} else {
|
|
1364
|
+
showStatus('Error: ' + result.error, 'error');
|
|
1365
|
+
}
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
showStatus('Error generating synopsis: ' + err.message, 'error');
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
btn.textContent = originalText;
|
|
1371
|
+
btn.disabled = false;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
init();
|
|
1375
|
+
</script>
|
|
1376
|
+
</body>
|
|
1377
|
+
</html>
|