@acms-subir/core 0.0.1
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/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- package/src/worker.ts +28 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { Media } from '../../lib/schema';
|
|
3
|
+
import { mediaUrl, uploadImage } from '../editor/imaging';
|
|
4
|
+
|
|
5
|
+
// Crop/edit modal for the media library. Edits are non-destructive: applying
|
|
6
|
+
// uploads the result as a NEW media item (serve routes cache immutably, so
|
|
7
|
+
// bytes at an existing key are never rewritten).
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
media: Media;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
onSaved: (created: Media) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Rect { x: number; y: number; w: number; h: number }
|
|
16
|
+
|
|
17
|
+
const ASPECTS: { label: string; value: number | null }[] = [
|
|
18
|
+
{ label: 'Free', value: null },
|
|
19
|
+
{ label: '1:1', value: 1 },
|
|
20
|
+
{ label: '4:3', value: 4 / 3 },
|
|
21
|
+
{ label: '3:2', value: 3 / 2 },
|
|
22
|
+
{ label: '16:9', value: 16 / 9 },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const MIN_CROP = 24;
|
|
26
|
+
|
|
27
|
+
export function CropModal({ media, onClose, onSaved }: Props) {
|
|
28
|
+
// rotation is applied first (90° steps); crop coords live in the rotated space
|
|
29
|
+
const [rotation, setRotation] = useState(0);
|
|
30
|
+
const [workingSrc, setWorkingSrc] = useState(() => mediaUrl(media.key));
|
|
31
|
+
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
|
|
32
|
+
const [crop, setCrop] = useState<Rect | null>(null);
|
|
33
|
+
const [aspect, setAspect] = useState<number | null>(null);
|
|
34
|
+
const [busy, setBusy] = useState(false);
|
|
35
|
+
const [error, setError] = useState('');
|
|
36
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
const imgRef = useRef<HTMLImageElement>(null);
|
|
38
|
+
const bitmapRef = useRef<ImageBitmap | null>(null);
|
|
39
|
+
const dragRef = useRef<{ mode: string; startX: number; startY: number; start: Rect } | null>(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
43
|
+
window.addEventListener('keydown', onKey);
|
|
44
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
45
|
+
}, [onClose]);
|
|
46
|
+
|
|
47
|
+
// load the source bitmap once; rotation renders it into a canvas dataURL
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
(async () => {
|
|
51
|
+
try {
|
|
52
|
+
const blob = await (await fetch(mediaUrl(media.key))).blob();
|
|
53
|
+
const bmp = await createImageBitmap(blob);
|
|
54
|
+
if (cancelled) return;
|
|
55
|
+
bitmapRef.current = bmp;
|
|
56
|
+
setImgSize({ w: bmp.width, h: bmp.height });
|
|
57
|
+
setCrop({ x: 0, y: 0, w: bmp.width, h: bmp.height });
|
|
58
|
+
} catch {
|
|
59
|
+
if (!cancelled) setError('Could not load the image for editing.');
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
return () => { cancelled = true; };
|
|
63
|
+
}, [media.key]);
|
|
64
|
+
|
|
65
|
+
const applyRotation = useCallback((deg: number) => {
|
|
66
|
+
const bmp = bitmapRef.current;
|
|
67
|
+
if (!bmp) return;
|
|
68
|
+
const rot = ((deg % 360) + 360) % 360;
|
|
69
|
+
const swapped = rot === 90 || rot === 270;
|
|
70
|
+
const canvas = document.createElement('canvas');
|
|
71
|
+
canvas.width = swapped ? bmp.height : bmp.width;
|
|
72
|
+
canvas.height = swapped ? bmp.width : bmp.height;
|
|
73
|
+
const ctx = canvas.getContext('2d')!;
|
|
74
|
+
ctx.translate(canvas.width / 2, canvas.height / 2);
|
|
75
|
+
ctx.rotate((rot * Math.PI) / 180);
|
|
76
|
+
ctx.drawImage(bmp, -bmp.width / 2, -bmp.height / 2);
|
|
77
|
+
setWorkingSrc(canvas.toDataURL());
|
|
78
|
+
setImgSize({ w: canvas.width, h: canvas.height });
|
|
79
|
+
setCrop({ x: 0, y: 0, w: canvas.width, h: canvas.height });
|
|
80
|
+
setRotation(rot);
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
// displayed image scale (client px per source px)
|
|
84
|
+
const scale = imgRef.current && imgSize ? imgRef.current.clientWidth / imgSize.w : 1;
|
|
85
|
+
|
|
86
|
+
const clampCrop = useCallback((r: Rect): Rect => {
|
|
87
|
+
if (!imgSize) return r;
|
|
88
|
+
const w = Math.min(Math.max(r.w, MIN_CROP), imgSize.w);
|
|
89
|
+
const h = Math.min(Math.max(r.h, MIN_CROP), imgSize.h);
|
|
90
|
+
return {
|
|
91
|
+
w, h,
|
|
92
|
+
x: Math.min(Math.max(r.x, 0), imgSize.w - w),
|
|
93
|
+
y: Math.min(Math.max(r.y, 0), imgSize.h - h),
|
|
94
|
+
};
|
|
95
|
+
}, [imgSize]);
|
|
96
|
+
|
|
97
|
+
const setAspectAndReset = (a: number | null) => {
|
|
98
|
+
setAspect(a);
|
|
99
|
+
if (!imgSize) return;
|
|
100
|
+
if (a === null) { setCrop({ x: 0, y: 0, w: imgSize.w, h: imgSize.h }); return; }
|
|
101
|
+
let w = imgSize.w;
|
|
102
|
+
let h = w / a;
|
|
103
|
+
if (h > imgSize.h) { h = imgSize.h; w = h * a; }
|
|
104
|
+
setCrop(clampCrop({ x: (imgSize.w - w) / 2, y: (imgSize.h - h) / 2, w, h }));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const onPointerDown = (mode: string) => (e: React.PointerEvent) => {
|
|
108
|
+
if (!crop) return;
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
(e.target as Element).setPointerCapture(e.pointerId);
|
|
112
|
+
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: { ...crop } };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const onPointerMove = (e: React.PointerEvent) => {
|
|
116
|
+
const drag = dragRef.current;
|
|
117
|
+
if (!drag || !imgSize) return;
|
|
118
|
+
const dx = (e.clientX - drag.startX) / scale;
|
|
119
|
+
const dy = (e.clientY - drag.startY) / scale;
|
|
120
|
+
const s = drag.start;
|
|
121
|
+
if (drag.mode === 'move') {
|
|
122
|
+
setCrop(clampCrop({ ...s, x: s.x + dx, y: s.y + dy }));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// corner resize: nw/ne/sw/se
|
|
126
|
+
let { x, y, w, h } = s;
|
|
127
|
+
if (drag.mode.includes('e')) w = s.w + dx;
|
|
128
|
+
if (drag.mode.includes('s')) h = s.h + dy;
|
|
129
|
+
if (drag.mode.includes('w')) { x = s.x + dx; w = s.w - dx; }
|
|
130
|
+
if (drag.mode.includes('n')) { y = s.y + dy; h = s.h - dy; }
|
|
131
|
+
if (aspect) {
|
|
132
|
+
h = w / aspect;
|
|
133
|
+
if (drag.mode.includes('n')) y = s.y + s.h - h;
|
|
134
|
+
}
|
|
135
|
+
if (w >= MIN_CROP && h >= MIN_CROP && x >= 0 && y >= 0 && x + w <= imgSize.w && y + h <= imgSize.h) {
|
|
136
|
+
setCrop({ x, y, w, h });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const onPointerUp = () => { dragRef.current = null; };
|
|
141
|
+
|
|
142
|
+
async function apply() {
|
|
143
|
+
if (!crop || !imgSize || busy) return;
|
|
144
|
+
setBusy(true);
|
|
145
|
+
setError('');
|
|
146
|
+
try {
|
|
147
|
+
const img = new Image();
|
|
148
|
+
img.src = workingSrc;
|
|
149
|
+
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
|
|
150
|
+
const canvas = document.createElement('canvas');
|
|
151
|
+
canvas.width = Math.round(crop.w);
|
|
152
|
+
canvas.height = Math.round(crop.h);
|
|
153
|
+
canvas.getContext('2d')!.drawImage(img, crop.x, crop.y, crop.w, crop.h, 0, 0, canvas.width, canvas.height);
|
|
154
|
+
const blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, 'image/webp', 0.9));
|
|
155
|
+
if (!blob) throw new Error('Crop failed');
|
|
156
|
+
const base = media.filename.replace(/\.[a-z0-9]+$/i, '');
|
|
157
|
+
const file = new File([blob], `${base}-edited.webp`, { type: 'image/webp' });
|
|
158
|
+
const created = await uploadImage(file, media.alt);
|
|
159
|
+
onSaved(created);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
setError(e instanceof Error ? e.message : 'Something went wrong saving the edit.');
|
|
162
|
+
setBusy(false);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const unchanged = rotation === 0 && crop && imgSize && crop.x === 0 && crop.y === 0 && crop.w === imgSize.w && crop.h === imgSize.h;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div className="pe-modal-backdrop" onClick={onClose}>
|
|
170
|
+
<div className="pe-modal mc-modal" onClick={(e) => e.stopPropagation()}>
|
|
171
|
+
<div className="pe-modal-head">
|
|
172
|
+
<strong>Edit image</strong>
|
|
173
|
+
<span className="mc-dims">
|
|
174
|
+
{crop ? `${Math.round(crop.w)}×${Math.round(crop.h)}` : ''}
|
|
175
|
+
</span>
|
|
176
|
+
<button type="button" className="pe-modal-close" onClick={onClose}>✕ Close</button>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{error && <div className="pe-modal-error">{error}</div>}
|
|
180
|
+
|
|
181
|
+
<div className="mc-stage" ref={stageRef}>
|
|
182
|
+
{imgSize ? (
|
|
183
|
+
<div className="mc-canvas" onPointerMove={onPointerMove} onPointerUp={onPointerUp}>
|
|
184
|
+
<img ref={imgRef} src={workingSrc} alt="" draggable={false} />
|
|
185
|
+
{crop && (
|
|
186
|
+
<div
|
|
187
|
+
className="mc-crop"
|
|
188
|
+
style={{
|
|
189
|
+
left: crop.x * scale, top: crop.y * scale,
|
|
190
|
+
width: crop.w * scale, height: crop.h * scale,
|
|
191
|
+
}}
|
|
192
|
+
onPointerDown={onPointerDown('move')}
|
|
193
|
+
>
|
|
194
|
+
{['nw', 'ne', 'sw', 'se'].map((c) => (
|
|
195
|
+
<span key={c} className={`mc-handle mc-${c}`} onPointerDown={onPointerDown(c)} />
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
) : (
|
|
201
|
+
<div className="mc-loading">Loading image…</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div className="mc-toolbar">
|
|
206
|
+
<div className="mc-aspects">
|
|
207
|
+
{ASPECTS.map((a) => (
|
|
208
|
+
<button
|
|
209
|
+
key={a.label}
|
|
210
|
+
type="button"
|
|
211
|
+
className={`pe-btn sm ${aspect === a.value ? 'primary' : ''}`}
|
|
212
|
+
onClick={() => setAspectAndReset(a.value)}
|
|
213
|
+
>
|
|
214
|
+
{a.label}
|
|
215
|
+
</button>
|
|
216
|
+
))}
|
|
217
|
+
<button type="button" className="pe-btn sm" onClick={() => applyRotation(rotation + 90)}>⟳ Rotate</button>
|
|
218
|
+
</div>
|
|
219
|
+
<div className="mc-actions">
|
|
220
|
+
<button type="button" className="pe-btn sm ghost" onClick={onClose}>Cancel</button>
|
|
221
|
+
<button type="button" className="pe-btn sm primary" disabled={busy || !crop || Boolean(unchanged)} onClick={apply}>
|
|
222
|
+
{busy ? 'Saving…' : 'Save as new image'}
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { actions } from 'astro:actions';
|
|
3
|
+
import type { Media } from '../../lib/schema';
|
|
4
|
+
import { mediaThumb, mediaUrl, uploadFile, uploadImage } from '../editor/imaging';
|
|
5
|
+
import { CropModal } from './CropModal';
|
|
6
|
+
|
|
7
|
+
// The media library: live-filtered tile grid, a detail drawer for the
|
|
8
|
+
// selected asset (alt text auto-saves on blur), and upload via button or
|
|
9
|
+
// drag-and-drop anywhere on the page.
|
|
10
|
+
|
|
11
|
+
const fmtSize = (b: number) => (b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`);
|
|
12
|
+
const fmtFormat = (mime: string) => (mime.split('/')[1] ?? mime).replace('+xml', '');
|
|
13
|
+
const fmtVariants = (n: number) => (n === 0 ? '—' : n === 1 ? '1 size' : `${n} sizes`);
|
|
14
|
+
const fmtDate = (ts: number) =>
|
|
15
|
+
new Date(ts).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
16
|
+
const isCroppable = (m: Media) => m.mime !== 'image/svg+xml' && m.mime !== 'image/gif';
|
|
17
|
+
|
|
18
|
+
export default function MediaLibrary({ media }: { media: Media[] }) {
|
|
19
|
+
const [items, setItems] = useState(media);
|
|
20
|
+
const [query, setQuery] = useState('');
|
|
21
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
22
|
+
const [editing, setEditing] = useState<Media | null>(null);
|
|
23
|
+
const [altDraft, setAltDraft] = useState('');
|
|
24
|
+
const [altSaved, setAltSaved] = useState(false);
|
|
25
|
+
const [copied, setCopied] = useState(false);
|
|
26
|
+
const [uploading, setUploading] = useState(false);
|
|
27
|
+
const [dragOver, setDragOver] = useState(false);
|
|
28
|
+
const [error, setError] = useState('');
|
|
29
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
31
|
+
const dragDepth = useRef(0);
|
|
32
|
+
|
|
33
|
+
const filtered = useMemo(() => {
|
|
34
|
+
const q = query.trim().toLowerCase();
|
|
35
|
+
if (!q) return items;
|
|
36
|
+
return items.filter((m) => m.filename.toLowerCase().includes(q) || m.alt.toLowerCase().includes(q));
|
|
37
|
+
}, [items, query]);
|
|
38
|
+
|
|
39
|
+
const selected = useMemo(() => items.find((m) => m.id === selectedId) ?? null, [items, selectedId]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setAltDraft(selected?.alt ?? '');
|
|
43
|
+
setAltSaved(false);
|
|
44
|
+
setCopied(false);
|
|
45
|
+
}, [selectedId]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const onKey = (e: KeyboardEvent) => {
|
|
49
|
+
if (editing) return;
|
|
50
|
+
const el = document.activeElement;
|
|
51
|
+
const typing =
|
|
52
|
+
el instanceof HTMLElement &&
|
|
53
|
+
(el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable);
|
|
54
|
+
if (e.key === '/' && !typing) {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
searchRef.current?.focus();
|
|
57
|
+
}
|
|
58
|
+
if (e.key === 'Escape') {
|
|
59
|
+
// Esc in a non-empty search clears it (native for type=search); only
|
|
60
|
+
// an Esc beyond that closes the drawer.
|
|
61
|
+
if (el === searchRef.current && searchRef.current?.value) return;
|
|
62
|
+
setSelectedId(null);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
document.addEventListener('keydown', onKey);
|
|
66
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
67
|
+
}, [editing]);
|
|
68
|
+
|
|
69
|
+
async function handleFiles(files: FileList | File[] | null) {
|
|
70
|
+
const list = [...(files ?? [])];
|
|
71
|
+
if (!list.length) return;
|
|
72
|
+
setUploading(true);
|
|
73
|
+
setError('');
|
|
74
|
+
try {
|
|
75
|
+
for (const f of list) {
|
|
76
|
+
// images go through the client-side optimize pipeline; documents
|
|
77
|
+
// upload as-is (the server rejects unsupported types)
|
|
78
|
+
const created = f.type.startsWith('image/') ? await uploadImage(f) : await uploadFile(f);
|
|
79
|
+
setItems((prev) => [created, ...prev]);
|
|
80
|
+
setSelectedId(created.id);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
setError(err instanceof Error ? err.message : 'Upload failed.');
|
|
84
|
+
}
|
|
85
|
+
setUploading(false);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function saveAlt() {
|
|
89
|
+
if (!selected || altDraft === selected.alt) return;
|
|
90
|
+
const id = selected.id;
|
|
91
|
+
const alt = altDraft;
|
|
92
|
+
const r = await actions.media.updateAlt({ id, alt });
|
|
93
|
+
if (r.error) {
|
|
94
|
+
setError('Could not save alt text.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setItems((prev) => prev.map((m) => (m.id === id ? { ...m, alt } : m)));
|
|
98
|
+
setAltSaved(true);
|
|
99
|
+
setTimeout(() => setAltSaved(false), 1600);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function remove(m: Media) {
|
|
103
|
+
if (!confirm('Delete this image? Pages using it will show a broken image.')) return;
|
|
104
|
+
const r = await actions.media.delete({ id: m.id });
|
|
105
|
+
if (r.error) {
|
|
106
|
+
setError('Could not delete the image.');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
setItems((prev) => prev.filter((x) => x.id !== m.id));
|
|
110
|
+
setSelectedId((cur) => (cur === m.id ? null : cur));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function copyUrl(m: Media) {
|
|
114
|
+
await navigator.clipboard.writeText(new URL(mediaUrl(m.key), location.origin).href);
|
|
115
|
+
setCopied(true);
|
|
116
|
+
setTimeout(() => setCopied(false), 1600);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const hasFiles = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes('Files');
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
className="ml"
|
|
124
|
+
onDragEnter={(e) => {
|
|
125
|
+
if (!hasFiles(e)) return;
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
dragDepth.current += 1;
|
|
128
|
+
setDragOver(true);
|
|
129
|
+
}}
|
|
130
|
+
onDragOver={(e) => {
|
|
131
|
+
if (hasFiles(e)) e.preventDefault();
|
|
132
|
+
}}
|
|
133
|
+
onDragLeave={(e) => {
|
|
134
|
+
if (!hasFiles(e)) return;
|
|
135
|
+
dragDepth.current -= 1;
|
|
136
|
+
if (dragDepth.current <= 0) {
|
|
137
|
+
dragDepth.current = 0;
|
|
138
|
+
setDragOver(false);
|
|
139
|
+
}
|
|
140
|
+
}}
|
|
141
|
+
onDrop={(e) => {
|
|
142
|
+
if (!hasFiles(e)) return;
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
dragDepth.current = 0;
|
|
145
|
+
setDragOver(false);
|
|
146
|
+
void handleFiles(e.dataTransfer.files);
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<div className="page-head">
|
|
150
|
+
<h1>
|
|
151
|
+
Media
|
|
152
|
+
<span className="ml-count">
|
|
153
|
+
{query.trim() ? `${filtered.length} of ${items.length}` : `${items.length} ${items.length === 1 ? 'asset' : 'assets'}`}
|
|
154
|
+
</span>
|
|
155
|
+
</h1>
|
|
156
|
+
<div className="ml-tools">
|
|
157
|
+
<div className="ml-search">
|
|
158
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
|
159
|
+
<circle cx="11" cy="11" r="7" />
|
|
160
|
+
<path d="m20 20-3.5-3.5" />
|
|
161
|
+
</svg>
|
|
162
|
+
<input
|
|
163
|
+
ref={searchRef}
|
|
164
|
+
type="search"
|
|
165
|
+
placeholder="Filter by name or alt…"
|
|
166
|
+
value={query}
|
|
167
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
168
|
+
/>
|
|
169
|
+
<kbd>/</kbd>
|
|
170
|
+
</div>
|
|
171
|
+
<button className="btn primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
|
172
|
+
{uploading ? 'Uploading…' : 'Upload'}
|
|
173
|
+
</button>
|
|
174
|
+
<input
|
|
175
|
+
ref={fileRef}
|
|
176
|
+
type="file"
|
|
177
|
+
accept="image/*,.pdf,.zip,.txt,.csv,.md,.json,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.epub"
|
|
178
|
+
multiple
|
|
179
|
+
hidden
|
|
180
|
+
onChange={(e) => {
|
|
181
|
+
void handleFiles(e.target.files);
|
|
182
|
+
e.target.value = '';
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{error && <div className="flash error">{error}</div>}
|
|
189
|
+
|
|
190
|
+
{filtered.length === 0 ? (
|
|
191
|
+
<div className="card card-pad">
|
|
192
|
+
<p className="hint" style={{ margin: 0 }}>
|
|
193
|
+
{items.length === 0
|
|
194
|
+
? 'No uploads yet. Drop images anywhere on this page, or use the Upload button.'
|
|
195
|
+
: 'Nothing matches that filter.'}
|
|
196
|
+
</p>
|
|
197
|
+
</div>
|
|
198
|
+
) : (
|
|
199
|
+
<div className="ml-layout">
|
|
200
|
+
<div className="ml-grid">
|
|
201
|
+
{filtered.map((m) => (
|
|
202
|
+
<button
|
|
203
|
+
key={m.id}
|
|
204
|
+
type="button"
|
|
205
|
+
className={`ml-tile${m.id === selectedId ? ' sel' : ''}`}
|
|
206
|
+
onClick={() => setSelectedId((cur) => (cur === m.id ? null : m.id))}
|
|
207
|
+
>
|
|
208
|
+
<span className="ml-thumb">
|
|
209
|
+
{m.mime.startsWith('image/') ? (
|
|
210
|
+
<img src={mediaThumb(m)} alt={m.alt || m.filename} loading="lazy" />
|
|
211
|
+
) : (
|
|
212
|
+
<span className="ml-filetile" aria-hidden="true">
|
|
213
|
+
{(m.filename.split('.').pop() || 'file').toUpperCase().slice(0, 4)}
|
|
214
|
+
</span>
|
|
215
|
+
)}
|
|
216
|
+
</span>
|
|
217
|
+
{!m.alt && m.mime.startsWith('image/') && <span className="ml-noalt">No alt</span>}
|
|
218
|
+
<span className="ml-cap">
|
|
219
|
+
<span className="ml-name" title={m.filename}>{m.filename}</span>
|
|
220
|
+
<span className="ml-sub">{fmtSize(m.size)}</span>
|
|
221
|
+
</span>
|
|
222
|
+
</button>
|
|
223
|
+
))}
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{selected && (
|
|
227
|
+
<aside className="ml-drawer">
|
|
228
|
+
<a className="ml-big" href={mediaUrl(selected.key)} target="_blank" rel="noopener">
|
|
229
|
+
{selected.mime.startsWith('image/') ? (
|
|
230
|
+
<img src={mediaUrl(selected.key)} alt={selected.alt || selected.filename} />
|
|
231
|
+
) : (
|
|
232
|
+
<span className="ml-filetile big" aria-hidden="true">
|
|
233
|
+
{(selected.filename.split('.').pop() || 'file').toUpperCase().slice(0, 4)}
|
|
234
|
+
</span>
|
|
235
|
+
)}
|
|
236
|
+
</a>
|
|
237
|
+
<div className="ml-dname" title={selected.filename}>{selected.filename}</div>
|
|
238
|
+
<label className="field">
|
|
239
|
+
<span>
|
|
240
|
+
Alt text
|
|
241
|
+
{altSaved && <em className="ml-saved">Saved ✓</em>}
|
|
242
|
+
</span>
|
|
243
|
+
<input
|
|
244
|
+
type="text"
|
|
245
|
+
placeholder="Describe the image…"
|
|
246
|
+
value={altDraft}
|
|
247
|
+
onChange={(e) => setAltDraft(e.target.value)}
|
|
248
|
+
onBlur={() => void saveAlt()}
|
|
249
|
+
onKeyDown={(e) => {
|
|
250
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
251
|
+
}}
|
|
252
|
+
/>
|
|
253
|
+
</label>
|
|
254
|
+
<div className="ml-kv"><span>Dimensions</span><b>{selected.width && selected.height ? `${selected.width}×${selected.height}` : '—'}</b></div>
|
|
255
|
+
<div className="ml-kv"><span>Size</span><b>{fmtSize(selected.size)}</b></div>
|
|
256
|
+
<div className="ml-kv"><span>Format</span><b>{fmtFormat(selected.mime)}</b></div>
|
|
257
|
+
<div className="ml-kv"><span>Variants</span><b>{fmtVariants(selected.variants.length)}</b></div>
|
|
258
|
+
<div className="ml-kv"><span>Uploaded</span><b>{fmtDate(selected.createdAt)}</b></div>
|
|
259
|
+
<div className="ml-actions">
|
|
260
|
+
{isCroppable(selected) && (
|
|
261
|
+
<button className="btn sm" type="button" onClick={() => setEditing(selected)}>Edit</button>
|
|
262
|
+
)}
|
|
263
|
+
<button className="btn sm" type="button" onClick={() => void copyUrl(selected)}>
|
|
264
|
+
{copied ? 'Copied ✓' : 'Copy URL'}
|
|
265
|
+
</button>
|
|
266
|
+
<button
|
|
267
|
+
className="btn sm ghost danger"
|
|
268
|
+
type="button"
|
|
269
|
+
style={{ marginLeft: 'auto' }}
|
|
270
|
+
onClick={() => void remove(selected)}
|
|
271
|
+
>
|
|
272
|
+
Delete
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
</aside>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
{dragOver && <div className="ml-dropveil">Drop images to upload</div>}
|
|
281
|
+
|
|
282
|
+
{editing && (
|
|
283
|
+
<CropModal
|
|
284
|
+
media={editing}
|
|
285
|
+
onClose={() => setEditing(null)}
|
|
286
|
+
onSaved={(created) => {
|
|
287
|
+
setItems((prev) => [created, ...prev]);
|
|
288
|
+
setEditing(null);
|
|
289
|
+
setSelectedId(created.id);
|
|
290
|
+
}}
|
|
291
|
+
/>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getRepo } from '../lib/repo';
|
|
3
|
+
import { BRAND } from '../lib/brand';
|
|
4
|
+
import { CONTENT } from '../lib/content';
|
|
5
|
+
import '../styles/admin.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
title: string;
|
|
9
|
+
// fixed sections plus one `c:<key>` entry per configured collection
|
|
10
|
+
section: string;
|
|
11
|
+
/** lift the content max-width — for screens with a live preview that should fill large displays */
|
|
12
|
+
wide?: boolean;
|
|
13
|
+
}
|
|
14
|
+
const { title, section, wide = false } = Astro.props;
|
|
15
|
+
|
|
16
|
+
const repo = await getRepo(Astro.locals);
|
|
17
|
+
const [pages, posts, links, home] = await Promise.all([
|
|
18
|
+
repo.listPages(),
|
|
19
|
+
repo.listPosts(),
|
|
20
|
+
repo.listLinks(),
|
|
21
|
+
repo.getHomePage(),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const icons: Record<string, string> = {
|
|
25
|
+
dashboard: 'M3 3h7v9H3zM14 3h7v5h-7zM14 12h7v9h-7zM3 16h7v5H3z',
|
|
26
|
+
home: 'M3 10.5 12 3l9 7.5M5 9.5V21h5v-6h4v6h5V9.5',
|
|
27
|
+
pages: 'M7 3h8l4 4v14H7zM15 3v4h4M10 12h6M10 16h6',
|
|
28
|
+
posts: 'M4 5h16M4 10h16M4 15h10M4 20h7',
|
|
29
|
+
media: 'M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6M15.5 9.5h0',
|
|
30
|
+
links: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5',
|
|
31
|
+
tags: 'M3 12V3h9l9 9-9 9zM7.5 7.5h0',
|
|
32
|
+
users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M22 21v-2a4 4 0 0 0-3-3.87M15 3.13a4 4 0 0 1 0 7.75',
|
|
33
|
+
settings: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 0 0 5 12a7 7 0 0 0 .1 1.2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6A7 7 0 0 0 19 12z',
|
|
34
|
+
design: 'M12 3c3.2 3.7 6 7 6 10.2a6 6 0 1 1-12 0C6 10 8.8 6.7 12 3zM9.5 14.5a2.5 2.5 0 0 0 5 0',
|
|
35
|
+
navigation: 'M4 6h16M4 12h10M4 18h13M19 11l3 3-3 3',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// one sidebar entry per configured collection, in config order
|
|
39
|
+
const byCollection = new Map<string, number>();
|
|
40
|
+
for (const p of posts) byCollection.set(p.collection, (byCollection.get(p.collection) ?? 0) + 1);
|
|
41
|
+
const collectionItems = Object.entries(CONTENT.collections).map(([key, c]) => ({
|
|
42
|
+
key: `c:${key}`,
|
|
43
|
+
label: c.plural,
|
|
44
|
+
href: `/admin/c/${key}`,
|
|
45
|
+
icon: icons.posts,
|
|
46
|
+
count: byCollection.get(key) ?? 0,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const items = [
|
|
50
|
+
{ key: 'dashboard', label: 'Dashboard', href: '/admin', icon: icons.dashboard, count: null },
|
|
51
|
+
{ key: 'home', label: 'Home Page', href: home ? `/admin/pages/${home.id}` : '/admin/pages', icon: icons.home, count: null },
|
|
52
|
+
{ key: 'pages', label: 'Pages', href: '/admin/pages', icon: icons.pages, count: pages.length },
|
|
53
|
+
...collectionItems,
|
|
54
|
+
{ key: 'tags', label: 'Tags', href: '/admin/tags', icon: icons.tags, count: null },
|
|
55
|
+
{ key: 'media', label: 'Media', href: '/admin/media', icon: icons.media, count: null },
|
|
56
|
+
{ key: 'links', label: 'Links', href: '/admin/links', icon: icons.links, count: links.length },
|
|
57
|
+
{ key: 'users', label: 'Users', href: '/admin/users', icon: icons.users, count: null },
|
|
58
|
+
{ key: 'design', label: 'Design', href: '/admin/design', icon: icons.design, count: null },
|
|
59
|
+
{ key: 'navigation', label: 'Navigation', href: '/admin/navigation', icon: icons.navigation, count: null },
|
|
60
|
+
{ key: 'settings', label: 'Settings', href: '/admin/settings', icon: icons.settings, count: null },
|
|
61
|
+
];
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
<!doctype html>
|
|
65
|
+
<html lang="en">
|
|
66
|
+
<head>
|
|
67
|
+
<meta charset="utf-8" />
|
|
68
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
69
|
+
<meta name="robots" content="noindex" />
|
|
70
|
+
<title>{title} · {BRAND.name} admin</title>
|
|
71
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
72
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<div class="shell">
|
|
76
|
+
<aside class="sidebar">
|
|
77
|
+
<div class="brand">
|
|
78
|
+
<svg width="22" height="22" viewBox="0 0 24 24" role="img" aria-label={BRAND.name}>
|
|
79
|
+
<rect x="1" y="3" width="22" height="4.4" rx="1.9" fill="#e7e8ec" />
|
|
80
|
+
<rect x="1" y="10" width="12.5" height="4.4" rx="1.9" fill="#ff8a4c" />
|
|
81
|
+
<rect x="1" y="17" width="17.5" height="4.4" rx="1.9" fill="#e7e8ec" opacity="0.48" />
|
|
82
|
+
</svg>
|
|
83
|
+
<div>
|
|
84
|
+
<div class="brand-name">{BRAND.name}</div>
|
|
85
|
+
<div class="brand-sub">v{BRAND.version} · {BRAND.stack}</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
{items.map((it) => (
|
|
89
|
+
<a class={`nav-item ${section === it.key ? 'active' : ''}`} href={it.href}>
|
|
90
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
91
|
+
<path d={it.icon} />
|
|
92
|
+
</svg>
|
|
93
|
+
{it.label}
|
|
94
|
+
{it.count !== null && <span class="count">{it.count}</span>}
|
|
95
|
+
</a>
|
|
96
|
+
))}
|
|
97
|
+
<div class="nav-sep" />
|
|
98
|
+
<a class="nav-item" href="/" target="_blank" rel="noopener">
|
|
99
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
100
|
+
<path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c2.5 2.4 3.8 5.6 3.8 9S14.5 18.6 12 21c-2.5-2.4-3.8-5.6-3.8-9S9.5 5.4 12 3z" />
|
|
101
|
+
</svg>
|
|
102
|
+
View site
|
|
103
|
+
</a>
|
|
104
|
+
<div class="sidebar-foot">
|
|
105
|
+
Signed in · <a href="/admin/logout">Sign out</a>
|
|
106
|
+
</div>
|
|
107
|
+
</aside>
|
|
108
|
+
<div class="main">
|
|
109
|
+
<div class="topbar">
|
|
110
|
+
<div class="crumb">{BRAND.name} / <b>{title}</b></div>
|
|
111
|
+
<a class="btn sm ghost" href="/" target="_blank" rel="noopener">Open site ↗</a>
|
|
112
|
+
</div>
|
|
113
|
+
<div class={`content ${wide ? 'content-wide' : ''}`}>
|
|
114
|
+
<slot />
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</body>
|
|
119
|
+
</html>
|