@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.
Files changed (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. 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>