@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,130 @@
1
+ import { Node } from '@tiptap/core';
2
+ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
3
+ import type { NodeViewProps } from '@tiptap/react';
4
+ import { CONTENT } from '../../../lib/content';
5
+
6
+ // Post-list card: a *dynamic* block. It serializes into bodyHtml as a
7
+ // placeholder div (data-dyn="post-list") that public routes expand at request
8
+ // time via src/lib/dynamicBlocks.ts — the list stays fresh without re-saving
9
+ // the page. The editor shows a config panel + schematic preview; the split
10
+ // preview iframe shows the real, expanded result.
11
+
12
+ export const PostListCard = Node.create({
13
+ name: 'postListCard',
14
+ group: 'block',
15
+ atom: true,
16
+ draggable: true,
17
+
18
+ addAttributes() {
19
+ return {
20
+ source: { default: 'latest' }, // latest | tag | featured
21
+ collection: { default: 'posts' }, // acms content.collections key
22
+ tag: { default: '' },
23
+ layout: { default: 'list' }, // list | grid | feature
24
+ limit: { default: 3 },
25
+ showImage: { default: true },
26
+ showExcerpt: { default: true },
27
+ };
28
+ },
29
+
30
+ parseHTML() {
31
+ return [
32
+ {
33
+ tag: 'div[data-dyn="post-list"]',
34
+ getAttrs: (el) => {
35
+ const e = el as HTMLElement;
36
+ return {
37
+ source: e.getAttribute('data-source') ?? 'latest',
38
+ collection: e.getAttribute('data-collection') || 'posts',
39
+ tag: e.getAttribute('data-tag') ?? '',
40
+ layout: e.getAttribute('data-layout') ?? 'list',
41
+ limit: Number(e.getAttribute('data-limit') ?? 3),
42
+ showImage: e.getAttribute('data-show-image') !== '0',
43
+ showExcerpt: e.getAttribute('data-show-excerpt') !== '0',
44
+ };
45
+ },
46
+ },
47
+ ];
48
+ },
49
+
50
+ renderHTML({ node }) {
51
+ const a = node.attrs;
52
+ return [
53
+ 'div',
54
+ {
55
+ 'data-dyn': 'post-list',
56
+ 'data-source': a.source,
57
+ 'data-collection': a.collection,
58
+ 'data-tag': a.tag,
59
+ 'data-layout': a.layout,
60
+ 'data-limit': String(a.limit),
61
+ 'data-show-image': a.showImage ? '1' : '0',
62
+ 'data-show-excerpt': a.showExcerpt ? '1' : '0',
63
+ },
64
+ ];
65
+ },
66
+
67
+ addNodeView() {
68
+ return ReactNodeViewRenderer(PostListView);
69
+ },
70
+ });
71
+
72
+ function PostListView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
73
+ const a = node.attrs;
74
+ const count = Math.min(12, Math.max(1, Number(a.limit) || 3));
75
+ const plural = (CONTENT.collections[a.collection] ?? CONTENT.posts).plural;
76
+ const multi = Object.keys(CONTENT.collections).length > 1;
77
+ const label =
78
+ a.source === 'featured' ? `Featured ${plural.toLowerCase()}` : a.source === 'tag' ? `${plural} tagged “${a.tag || '…'}”` : `Latest ${plural.toLowerCase()}`;
79
+ return (
80
+ <NodeViewWrapper className={`pe-card pe-postlist ${selected ? 'is-selected' : ''}`} data-drag-handle>
81
+ <div className="pe-card-tools" contentEditable={false}>
82
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
83
+ </div>
84
+ <div className="pe-postlist-config" contentEditable={false}>
85
+ <span className="pe-postlist-badge">{(CONTENT.collections[a.collection] ?? CONTENT.posts).singular} list · dynamic</span>
86
+ {multi && (
87
+ <select value={a.collection} onChange={(e) => updateAttributes({ collection: e.target.value })}>
88
+ {Object.entries(CONTENT.collections).map(([k, c]) => (
89
+ <option key={k} value={k}>{c.plural}</option>
90
+ ))}
91
+ </select>
92
+ )}
93
+ <select value={a.source} onChange={(e) => updateAttributes({ source: e.target.value })}>
94
+ <option value="latest">Latest</option>
95
+ <option value="tag">By tag</option>
96
+ <option value="featured">Featured</option>
97
+ </select>
98
+ {a.source === 'tag' && (
99
+ <input
100
+ type="text"
101
+ placeholder="Tag name"
102
+ value={a.tag}
103
+ onChange={(e) => updateAttributes({ tag: e.target.value })}
104
+ />
105
+ )}
106
+ <select value={a.layout} onChange={(e) => updateAttributes({ layout: e.target.value })}>
107
+ <option value="list">List</option>
108
+ <option value="grid">Grid</option>
109
+ <option value="feature">Feature</option>
110
+ </select>
111
+ <select value={count} onChange={(e) => updateAttributes({ limit: Number(e.target.value) })}>
112
+ {[1, 2, 3, 4, 6, 8, 10, 12].map((n) => <option key={n} value={n}>{n} post{n > 1 ? 's' : ''}</option>)}
113
+ </select>
114
+ <label><input type="checkbox" checked={a.showImage} onChange={(e) => updateAttributes({ showImage: e.target.checked })} /> Images</label>
115
+ <label><input type="checkbox" checked={a.showExcerpt} onChange={(e) => updateAttributes({ showExcerpt: e.target.checked })} /> Excerpts</label>
116
+ </div>
117
+ <div className={`pe-postlist-ghost layout-${a.layout}`} contentEditable={false}>
118
+ {Array.from({ length: Math.min(count, a.layout === 'feature' ? 2 : 4) }, (_, i) => (
119
+ <div className="pe-postlist-ghostcard" key={i}>
120
+ {a.showImage && <div className="g-img" />}
121
+ <div className="g-line w60" />
122
+ <div className="g-line w90" />
123
+ {a.showExcerpt && <div className="g-line w75" />}
124
+ </div>
125
+ ))}
126
+ <p className="pe-postlist-note">{label} — renders live on the site (see preview)</p>
127
+ </div>
128
+ </NodeViewWrapper>
129
+ );
130
+ }
@@ -0,0 +1,250 @@
1
+ import { Extension, ReactRenderer } from '@tiptap/react';
2
+ import type { Editor, Range } from '@tiptap/react';
3
+ import Suggestion from '@tiptap/suggestion';
4
+ import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
5
+ import { CONTENT } from '../../../lib/content';
6
+
7
+ // "/" command menu, Ghost-style: type / on a line to insert cards and blocks.
8
+
9
+ export interface SlashContext {
10
+ openImagePicker: (insertAt: Range) => void;
11
+ }
12
+
13
+ interface SlashItem {
14
+ title: string;
15
+ description: string;
16
+ keywords: string;
17
+ group: 'Basic' | 'Cards';
18
+ icon: string; // svg path (24 viewBox, stroked)
19
+ run: (editor: Editor, range: Range, ctx: SlashContext) => void;
20
+ }
21
+
22
+ const ITEMS: SlashItem[] = [
23
+ {
24
+ title: 'Image', description: 'Upload, library or Unsplash', keywords: 'image photo picture unsplash upload', group: 'Cards',
25
+ icon: 'M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6M15.5 9.5h0',
26
+ run: (editor, range, ctx) => {
27
+ editor.chain().focus().deleteRange(range).run();
28
+ ctx.openImagePicker({ from: editor.state.selection.from, to: editor.state.selection.from });
29
+ },
30
+ },
31
+ {
32
+ title: 'Gallery', description: 'Grid of 2–9 images', keywords: 'gallery grid images photos', group: 'Cards',
33
+ icon: 'M3 4h8v7H3zM13 4h8v7h-8zM3 13h8v7H3zM13 13h8v7h-8z',
34
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'galleryCard' }).run(),
35
+ },
36
+ {
37
+ title: 'Embed', description: 'YouTube, Vimeo, Loom, iframe…', keywords: 'embed video youtube vimeo iframe audio',
38
+ group: 'Cards', icon: 'M4 5h16v14H4zM10 9.5l5 2.5-5 2.5z',
39
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'embedCard' }).run(),
40
+ },
41
+ {
42
+ title: 'Bookmark', description: 'Rich link preview card', keywords: 'bookmark link preview url card', group: 'Cards',
43
+ icon: 'M6 3h12v18l-6-4-6 4z',
44
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'bookmarkCard' }).run(),
45
+ },
46
+ {
47
+ title: 'File', description: 'Downloadable attachment', keywords: 'file download attachment pdf zip document', group: 'Cards',
48
+ icon: 'M13 3H6v18h12V8zM13 3v5h5M12 11v6M9.5 14.5L12 17l2.5-2.5',
49
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'fileCard' }).run(),
50
+ },
51
+ {
52
+ title: 'Button', description: 'Call-to-action link', keywords: 'button cta link', group: 'Cards',
53
+ icon: 'M4 8h16v8H4zM8 12h8',
54
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'buttonCard' }).run(),
55
+ },
56
+ {
57
+ title: 'Callout', description: 'Emphasized note with emoji', keywords: 'callout note info warning', group: 'Cards',
58
+ icon: 'M12 3l9 18H3zM12 10v4M12 17.5h0',
59
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'callout' }).run(),
60
+ },
61
+ {
62
+ title: 'HTML', description: 'Raw HTML block', keywords: 'html code embed custom', group: 'Cards',
63
+ icon: 'M8 6l-5 6 5 6M16 6l5 6-5 6',
64
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'htmlCard' }).run(),
65
+ },
66
+ {
67
+ title: 'Calendar', description: 'Google Calendar or ICS feed (dynamic)', keywords: 'calendar events google ics schedule month week upcoming dynamic', group: 'Cards',
68
+ icon: 'M3 5h18v16H3zM3 9h18M8 3v4M16 3v4M7 13h2M12 13h2M7 17h2M12 17h2',
69
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'calendarCard' }).run(),
70
+ },
71
+ {
72
+ title: 'Info cards', description: 'Grid of cards: title, badge, details', keywords: 'info cards grid services hours schedule team features locations badge', group: 'Cards',
73
+ icon: 'M3 5h18v14H3zM7 9h6M15.5 9h2M7 13h7M7 16h5',
74
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'infoCards' }).run(),
75
+ },
76
+ {
77
+ title: `${CONTENT.posts.singular} list`, description: `Latest, featured or by-tag ${CONTENT.posts.plural.toLowerCase()} (dynamic)`, keywords: `posts list latest featured tag dynamic blog ${CONTENT.posts.plural.toLowerCase()}`, group: 'Cards',
78
+ icon: 'M4 5h7v6H4zM4 14h7v6H4zM14 6h6M14 9h4M14 15h6M14 18h4',
79
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'postListCard' }).run(),
80
+ },
81
+ {
82
+ title: 'Table', description: '3×3 table with header row', keywords: 'table grid rows columns cells', group: 'Basic',
83
+ icon: 'M3 5h18v14H3zM3 10h18M9 5v14M15 5v14',
84
+ run: (editor, range) =>
85
+ editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
86
+ },
87
+ {
88
+ title: 'Divider', description: 'Horizontal rule', keywords: 'divider hr rule separator', group: 'Basic',
89
+ icon: 'M3 12h18',
90
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
91
+ },
92
+ {
93
+ title: 'Heading 2', description: 'Section heading', keywords: 'heading h2 title section', group: 'Basic',
94
+ icon: 'M4 5v14M14 5v14M4 12h10M18 15c0-1 2-1.6 2-3a1.8 1.8 0 0 0-3.4-.8',
95
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run(),
96
+ },
97
+ {
98
+ title: 'Heading 3', description: 'Subsection heading', keywords: 'heading h3 subtitle', group: 'Basic',
99
+ icon: 'M4 5v14M12 5v14M4 12h8M17 9.5a1.8 1.8 0 1 1 1.5 2.7 1.9 1.9 0 1 1-1.6 2.9',
100
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run(),
101
+ },
102
+ {
103
+ title: 'Quote', description: 'Blockquote', keywords: 'quote blockquote citation', group: 'Basic',
104
+ icon: 'M6 15c-1.5 0-2.5-1.2-2.5-2.8C3.5 9.5 5.5 7.5 8 7M6 15h2v3H5v-3zM15 15c-1.5 0-2.5-1.2-2.5-2.8 0-2.7 2-4.7 4.5-5.2M15 15h2v3h-3v-3z',
105
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setBlockquote().run(),
106
+ },
107
+ {
108
+ title: 'Code block', description: 'Preformatted code', keywords: 'code pre snippet', group: 'Basic',
109
+ icon: 'M9 6l-5 6 5 6M15 6l5 6-5 6',
110
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setCodeBlock().run(),
111
+ },
112
+ {
113
+ title: 'Bullet list', description: 'Unordered list', keywords: 'list bullet ul', group: 'Basic',
114
+ icon: 'M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01',
115
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleBulletList().run(),
116
+ },
117
+ {
118
+ title: 'Numbered list', description: 'Ordered list', keywords: 'list numbered ol ordered', group: 'Basic',
119
+ icon: 'M10 6h11M10 12h11M10 18h11M4 5l1.5-1v5M3.8 11.5a1.3 1.3 0 0 1 2.4.7c0 1-2.4 1.4-2.4 2.8h2.6',
120
+ run: (editor, range) => editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
121
+ },
122
+ ];
123
+
124
+ interface ListProps {
125
+ items: SlashItem[];
126
+ command: (item: SlashItem) => void;
127
+ }
128
+
129
+ export interface SlashListHandle {
130
+ onKeyDown: (e: KeyboardEvent) => boolean;
131
+ }
132
+
133
+ const SlashList = forwardRef<SlashListHandle, ListProps>(function SlashList({ items, command }, ref) {
134
+ const [selected, setSelected] = useState(0);
135
+ useEffect(() => setSelected(0), [items]);
136
+
137
+ useImperativeHandle(ref, () => ({
138
+ onKeyDown(e) {
139
+ if (e.key === 'ArrowDown') { setSelected((s) => (s + 1) % items.length); return true; }
140
+ if (e.key === 'ArrowUp') { setSelected((s) => (s - 1 + items.length) % items.length); return true; }
141
+ if (e.key === 'Enter') { if (items[selected]) command(items[selected]); return true; }
142
+ return false;
143
+ },
144
+ }));
145
+
146
+ if (!items.length) return <div className="pe-slash"><div className="pe-slash-empty">No matches</div></div>;
147
+
148
+ let lastGroup = '';
149
+ return (
150
+ <div className="pe-slash">
151
+ {items.map((item, i) => {
152
+ const header = item.group !== lastGroup ? item.group : null;
153
+ lastGroup = item.group;
154
+ return (
155
+ <div key={item.title}>
156
+ {header && <div className="pe-slash-group">{header}</div>}
157
+ <button
158
+ type="button"
159
+ className={`pe-slash-item ${i === selected ? 'is-selected' : ''}`}
160
+ onMouseEnter={() => setSelected(i)}
161
+ onMouseDown={(e) => { e.preventDefault(); command(item); }}
162
+ >
163
+ <span className="pe-slash-icon">
164
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
165
+ <path d={item.icon} />
166
+ </svg>
167
+ </span>
168
+ <span>
169
+ <span className="pe-slash-title">{item.title}</span>
170
+ <span className="pe-slash-desc">{item.description}</span>
171
+ </span>
172
+ </button>
173
+ </div>
174
+ );
175
+ })}
176
+ </div>
177
+ );
178
+ });
179
+
180
+ export const SlashMenu = Extension.create<{ context: SlashContext }>({
181
+ name: 'slashMenu',
182
+
183
+ addOptions() {
184
+ return { context: { openImagePicker: () => {} } };
185
+ },
186
+
187
+ addProseMirrorPlugins() {
188
+ const context = this.options.context;
189
+ return [
190
+ Suggestion({
191
+ editor: this.editor,
192
+ char: '/',
193
+ startOfLine: false,
194
+ command: ({ editor, range, props }: { editor: Editor; range: Range; props: SlashItem }) => {
195
+ props.run(editor, range, context);
196
+ },
197
+ items: ({ query }: { query: string }) => {
198
+ const q = query.toLowerCase();
199
+ return ITEMS.filter(
200
+ (i) => i.title.toLowerCase().includes(q) || i.keywords.includes(q)
201
+ );
202
+ },
203
+ render: () => {
204
+ let renderer: ReactRenderer<SlashListHandle, ListProps> | null = null;
205
+
206
+ const position = (clientRect?: (() => DOMRect | null) | null) => {
207
+ const rect = clientRect?.();
208
+ if (!rect || !renderer) return;
209
+ const el = renderer.element as HTMLElement;
210
+ el.style.position = 'fixed';
211
+ el.style.zIndex = '80';
212
+ el.style.left = `${Math.min(rect.left, window.innerWidth - 320)}px`;
213
+ const below = rect.bottom + 8;
214
+ if (below + 340 > window.innerHeight) {
215
+ el.style.top = 'auto';
216
+ el.style.bottom = `${window.innerHeight - rect.top + 8}px`;
217
+ } else {
218
+ el.style.top = `${below}px`;
219
+ el.style.bottom = 'auto';
220
+ }
221
+ };
222
+
223
+ return {
224
+ onStart: (props: any) => {
225
+ renderer = new ReactRenderer(SlashList, {
226
+ props: { items: props.items, command: props.command },
227
+ editor: props.editor,
228
+ });
229
+ document.body.appendChild(renderer.element);
230
+ position(props.clientRect);
231
+ },
232
+ onUpdate: (props: any) => {
233
+ renderer?.updateProps({ items: props.items, command: props.command });
234
+ position(props.clientRect);
235
+ },
236
+ onKeyDown: (props: any) => {
237
+ if (props.event.key === 'Escape') return true;
238
+ return renderer?.ref?.onKeyDown(props.event) ?? false;
239
+ },
240
+ onExit: () => {
241
+ renderer?.element.remove();
242
+ renderer?.destroy();
243
+ renderer = null;
244
+ },
245
+ };
246
+ },
247
+ }),
248
+ ];
249
+ },
250
+ });
@@ -0,0 +1,63 @@
1
+ import { Extension } from '@tiptap/react';
2
+
3
+ // Typed markdown tables, Typora-style: write a header row, then a
4
+ // | --- | --- | delimiter row, and press Enter — the two paragraphs convert
5
+ // into a real table (header + one empty body row) with the cursor in the
6
+ // first cell. Pasted markdown tables are handled separately in PostEditor.
7
+
8
+ const ROW = /^\s*\|.+\|\s*$/;
9
+ const DELIM = /^\s*\|(\s*:?-+:?\s*\|)+\s*$/;
10
+
11
+ const splitRow = (line: string) =>
12
+ line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
13
+
14
+ export const TableTyping = Extension.create({
15
+ name: 'tableTyping',
16
+ priority: 1000, // ahead of StarterKit's Enter (split block)
17
+
18
+ addKeyboardShortcuts() {
19
+ return {
20
+ Enter: () => {
21
+ const { state } = this.editor;
22
+ const { $from, empty } = state.selection;
23
+ if (!empty || $from.depth !== 1 || $from.parent.type.name !== 'paragraph') return false;
24
+ if ($from.parentOffset !== $from.parent.content.size) return false;
25
+ if (!DELIM.test($from.parent.textContent)) return false;
26
+
27
+ const index = $from.index(0);
28
+ if (index === 0) return false;
29
+ const header = state.doc.child(index - 1);
30
+ if (header.type.name !== 'paragraph' || !ROW.test(header.textContent)) return false;
31
+
32
+ const cells = splitRow(header.textContent);
33
+ if (cells.length < 2 || cells.length !== splitRow($from.parent.textContent).length) return false;
34
+
35
+ const to = $from.after(1);
36
+ const from = to - $from.parent.nodeSize - header.nodeSize;
37
+ // table(1) + header row(2 + Σ th(2 + p(2 + text))) + row(1) + cell(1) + p(1)
38
+ const firstBodyCellPos = from + cells.reduce((s, c) => s + 4 + c.length, 2) + 4;
39
+ this.editor
40
+ .chain()
41
+ .insertContentAt({ from, to }, {
42
+ type: 'table',
43
+ content: [
44
+ {
45
+ type: 'tableRow',
46
+ content: cells.map((text) => ({
47
+ type: 'tableHeader',
48
+ content: [{ type: 'paragraph', content: text ? [{ type: 'text', text }] : undefined }],
49
+ })),
50
+ },
51
+ {
52
+ type: 'tableRow',
53
+ content: cells.map(() => ({ type: 'tableCell', content: [{ type: 'paragraph' }] })),
54
+ },
55
+ ],
56
+ })
57
+ .setTextSelection(firstBodyCellPos)
58
+ .run();
59
+ return true;
60
+ },
61
+ };
62
+ },
63
+ });
@@ -0,0 +1,146 @@
1
+ // Client-side image pipeline: decode (EXIF-corrected), downscale to the
2
+ // web cap, re-encode as WebP, generate smaller variants, upload everything
3
+ // to /api/media/upload. Keeps image optimization free and backend-portable.
4
+
5
+ import type { Media } from '../../lib/schema';
6
+
7
+ // Everything stored is web-sized: longest edge capped at MAX_DIMENSION.
8
+ export const MAX_DIMENSION = 1080;
9
+ export const VARIANT_WIDTHS = [480, 720];
10
+ export const DEFAULT_SIZES = '(max-width: 760px) 100vw, 720px';
11
+
12
+ const WEBP_QUALITY = 0.82;
13
+ const RETRY_QUALITY = 0.7;
14
+ const TARGET_BYTES = 400 * 1024;
15
+
16
+ export interface ProcessedImage {
17
+ file: File;
18
+ width: number;
19
+ height: number;
20
+ variants: { w: number; blob: Blob }[];
21
+ }
22
+
23
+ export async function processImage(file: File): Promise<ProcessedImage> {
24
+ // SVG/GIF pass through untouched (resizing would rasterize/break them)
25
+ if (file.type === 'image/svg+xml' || file.type === 'image/gif') {
26
+ const dims = await imageDims(file);
27
+ return { file, ...dims, variants: [] };
28
+ }
29
+ const bmp = await createImageBitmap(file, { imageOrientation: 'from-image' });
30
+
31
+ // Downscale so the longest edge is at most MAX_DIMENSION.
32
+ const scale = Math.min(1, MAX_DIMENSION / Math.max(bmp.width, bmp.height));
33
+ const width = Math.round(bmp.width * scale);
34
+ const height = Math.round(bmp.height * scale);
35
+
36
+ // Re-encode at the capped size; step quality down once if still heavy.
37
+ let blob = await encodeWebp(bmp, width, height, WEBP_QUALITY);
38
+ if (blob && blob.size > TARGET_BYTES) {
39
+ const retry = await encodeWebp(bmp, width, height, RETRY_QUALITY);
40
+ if (retry && retry.size < blob.size) blob = retry;
41
+ }
42
+
43
+ // Use the re-encode unless the untouched original is already smaller
44
+ // (e.g. a tiny, well-compressed image that needed no resizing).
45
+ let out = file;
46
+ if (blob && (scale < 1 || blob.size < file.size)) {
47
+ const base = file.name.replace(/\.[^.]*$/, '') || 'image';
48
+ out = new File([blob], `${base}.webp`, { type: 'image/webp' });
49
+ }
50
+
51
+ const variants: { w: number; blob: Blob }[] = [];
52
+ for (const w of VARIANT_WIDTHS) {
53
+ if (w >= width) continue;
54
+ const h = Math.round((height / width) * w);
55
+ const vblob = await encodeWebp(bmp, w, h, WEBP_QUALITY);
56
+ if (vblob) variants.push({ w, blob: vblob });
57
+ }
58
+ bmp.close();
59
+ return { file: out, width, height, variants };
60
+ }
61
+
62
+ function encodeWebp(bmp: ImageBitmap, w: number, h: number, quality: number): Promise<Blob | null> {
63
+ const canvas = document.createElement('canvas');
64
+ canvas.width = w;
65
+ canvas.height = h;
66
+ const ctx = canvas.getContext('2d')!;
67
+ ctx.imageSmoothingQuality = 'high';
68
+ ctx.drawImage(bmp, 0, 0, w, h);
69
+ return new Promise<Blob | null>((res) => canvas.toBlob(res, 'image/webp', quality));
70
+ }
71
+
72
+ function imageDims(file: File): Promise<{ width: number; height: number }> {
73
+ return new Promise((resolve, reject) => {
74
+ const url = URL.createObjectURL(file);
75
+ const img = new Image();
76
+ img.onload = () => {
77
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
78
+ URL.revokeObjectURL(url);
79
+ };
80
+ img.onerror = reject;
81
+ img.src = url;
82
+ });
83
+ }
84
+
85
+ export async function uploadImage(file: File, alt = ''): Promise<Media> {
86
+ const processed = await processImage(file);
87
+ const form = new FormData();
88
+ form.set('file', processed.file);
89
+ form.set('width', String(processed.width));
90
+ form.set('height', String(processed.height));
91
+ form.set('alt', alt);
92
+ for (const v of processed.variants) {
93
+ form.set(`variant_${v.w}`, new File([v.blob], `w${v.w}.webp`, { type: 'image/webp' }));
94
+ }
95
+ const res = await fetch('/api/media/upload', { method: 'POST', body: form });
96
+ if (!res.ok) {
97
+ const data = (await res.json().catch(() => null)) as { error?: string } | null;
98
+ throw new Error(data?.error ?? `Upload failed (${res.status})`);
99
+ }
100
+ return ((await res.json()) as { media: Media }).media;
101
+ }
102
+
103
+ // Non-image upload (documents for the file card): no client-side processing,
104
+ // the file goes up as-is and the server validates the type.
105
+ export async function uploadFile(file: File): Promise<Media> {
106
+ const form = new FormData();
107
+ form.set('file', file);
108
+ form.set('width', '0');
109
+ form.set('height', '0');
110
+ const res = await fetch('/api/media/upload', { method: 'POST', body: form });
111
+ if (!res.ok) {
112
+ const data = (await res.json().catch(() => null)) as { error?: string } | null;
113
+ throw new Error(data?.error ?? `Upload failed (${res.status})`);
114
+ }
115
+ return ((await res.json()) as { media: Media }).media;
116
+ }
117
+
118
+ export function mediaUrl(key: string): string {
119
+ return `/media/${key}`;
120
+ }
121
+
122
+ export function mediaSrc(media: Media): string {
123
+ const largest = media.variants[media.variants.length - 1];
124
+ return mediaUrl(largest ? largest.key : media.key);
125
+ }
126
+
127
+ export function mediaSrcset(media: Media): string {
128
+ if (!media.variants.length) return '';
129
+ const parts = media.variants.map((v) => `${mediaUrl(v.key)} ${v.w}w`);
130
+ if (media.width > media.variants[media.variants.length - 1].w) {
131
+ parts.push(`${mediaUrl(media.key)} ${media.width}w`);
132
+ }
133
+ return parts.join(', ');
134
+ }
135
+
136
+ export function mediaThumb(media: Media): string {
137
+ return mediaUrl(media.variants[0]?.key ?? media.key);
138
+ }
139
+
140
+ // Unsplash photos are hotlinked (per API guidelines) with dynamic resizing.
141
+ export function unsplashSrcset(regular: string): string {
142
+ const base = regular.replace(/([&?])w=\d+/, `$1w=${MAX_DIMENSION}`);
143
+ return [...VARIANT_WIDTHS, MAX_DIMENSION]
144
+ .map((w) => `${base.replace(/([&?])w=\d+/, `$1w=${w}`)} ${w}w`)
145
+ .join(', ');
146
+ }