Add mdx support for Events content

This commit is contained in:
ArtemBozhko
2026-10-09 12:51:43 +03:00
parent e2d097f918
commit 52326657d3
10 changed files with 5065 additions and 68 deletions
+4886 -47
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -15,15 +15,24 @@
"dependencies": {
"@astrojs/netlify": "^8.2.1",
"@astrojs/react": "^6.0.2",
"@mdxeditor/editor": "^4.3.2",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.55.0",
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@types/markdown-it": "^14.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"astro": "^7.2.1",
"autoprefixer": "^10.6.1",
"isomorphic-dompurify": "^4.5.0",
"leaflet": "^1.9.4",
"markdown-it": "^15.0.2",
"postcss": "^8.5.29",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-leaflet": "^5.0.0"
"react-leaflet": "^5.0.0",
"tailwindcss": "^4.3.3"
},
"overrides": {
"sharp": "0.35.3"
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};
+77 -14
View File
@@ -1,6 +1,23 @@
import { useRef, useState } from 'react';
import type { FormEvent, ChangeEvent } from 'react';
import { createBrowserSupabase } from '../../lib/supabase';
import {
MDXEditor,
headingsPlugin,
listsPlugin,
linkPlugin,
imagePlugin,
quotePlugin,
thematicBreakPlugin,
markdownShortcutPlugin,
toolbarPlugin,
UndoRedo,
BoldItalicUnderlineToggles,
ListsToggle,
BlockTypeSelect,
InsertImage,
} from '@mdxeditor/editor';
import '@mdxeditor/editor/style.css';
import type { Dict } from '../../lib/i18n';
import type { EventRow } from '../../lib/db';
@@ -9,6 +26,11 @@ interface Props {
event?: EventRow | null;
}
interface ContentEditorProps {
content: string;
onChange: (content: string) => void;
}
function pad(n: number): string {
return String(n).padStart(2, '0');
}
@@ -30,6 +52,55 @@ function slugify(value: string): string {
.slice(0, 80);
}
async function imageUploadHandler(file: File): Promise<string> {
try {
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
const path = `content-images/${crypto.randomUUID()}.${ext}`;
const supabase = createBrowserSupabase();
const { data, error: uploadError } = await supabase.storage
.from('event-content-images')
.upload(path, file, { upsert: false, contentType: file.type });
if (uploadError) throw uploadError;
if (!data) throw new Error('No upload path');
const { data: publicUrl } = supabase.storage.from('event-content-images').getPublicUrl(data.path);
return publicUrl.publicUrl;
} catch {
throw new Error('Image upload failed');
}
}
function ContentEditor({ content, onChange }: ContentEditorProps) {
return (
<MDXEditor
markdown={content}
onChange={onChange}
plugins={[
headingsPlugin(),
listsPlugin(),
linkPlugin(),
imagePlugin({ imageUploadHandler: imageUploadHandler }),
quotePlugin(),
thematicBreakPlugin(),
markdownShortcutPlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<BlockTypeSelect />
<ListsToggle />
<InsertImage />
</>
),
}),
]}
contentEditableClassName="prose prose-sm max-w-none textarea"
/>
);
}
export default function EventEditor({ dict: t, event }: Props) {
const [titleUa, setTitleUa] = useState(event?.title?.ua ?? '');
const [titleEn, setTitleEn] = useState(event?.title?.en ?? '');
@@ -94,6 +165,10 @@ export default function EventEditor({ dict: t, event }: Props) {
setError(t.common.error);
return;
}
if (descriptionUa.trim().length > 20000 || descriptionEn.trim().length > 20000) {
setError(t.common.error);
return;
}
const payload = {
title: { ua: titleUa.trim(), en: titleEn.trim() },
@@ -158,25 +233,13 @@ export default function EventEditor({ dict: t, event }: Props) {
</div>
</div>
<div className="grid-2">
<div className="form-field">
<label htmlFor="desc-ua">{t.admin.eventDescUa}</label>
<textarea
className="textarea"
id="desc-ua"
value={descriptionUa}
onChange={(e) => setDescriptionUa(e.target.value)}
/>
<ContentEditor content={descriptionUa} onChange={setDescriptionUa} />
</div>
<div className="form-field">
<label htmlFor="desc-en">{t.admin.eventDescEn}</label>
<textarea
className="textarea"
id="desc-en"
value={descriptionEn}
onChange={(e) => setDescriptionEn(e.target.value)}
/>
</div>
<ContentEditor content={descriptionEn} onChange={setDescriptionEn} />
</div>
<div className="grid-2">
+15
View File
@@ -0,0 +1,15 @@
import markdownIt from 'markdown-it';
import DOMPurify from 'isomorphic-dompurify';
const md = markdownIt({
html: false,
linkify: true,
typographer: true,
breaks: true,
});
export async function markdownToHtml(markdown: string): Promise<string> {
if (!markdown) return '';
const rendered = md.render(markdown);
return DOMPurify.sanitize(rendered);
}
+3 -1
View File
@@ -7,6 +7,7 @@ import { localizedPath, getDict, isLocale } from '../../../lib/i18n';
import type { Locale } from '../../../lib/i18n';
import { getEventBySlug, getEventReviews, titleFor, descriptionFor } from '../../../lib/db';
import { formatDate, formatTime } from '../../../lib/format';
import { markdownToHtml } from '../../../lib/markdown';
const { lang, slug } = Astro.params;
const locale: Locale = isLocale(lang) ? lang : 'ua';
@@ -22,6 +23,7 @@ if (!event) {
const title = event ? titleFor(locale, event) : '404';
const description = event ? descriptionFor(locale, event) : 'Not found';
const descriptionHtml = event ? await markdownToHtml(description) : '';
const reviews = event ? await getEventReviews(supabase, event.id) : [];
const canDelete = (reviewUserId: string) => user !== null && (user.isAdmin || reviewUserId === user.id);
---
@@ -75,7 +77,7 @@ const canDelete = (reviewUserId: string) => user !== null && (user.isAdmin || re
</span>
)}
</div>
<div class="prose">{description && <p>{description}</p>}</div>
<div class="prose" set:html={descriptionHtml}></div>
</div>
<aside class="card" style="padding:24px;">
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+2
View File
@@ -1,4 +1,6 @@
// ============================================================
@use 'tailwind';
// Devodytyji — global styles entry
// ============================================================
@@ -0,0 +1,48 @@
-- Create storage bucket for rich text content images
insert into storage.buckets (id, name, public)
values ('event-content-images', 'event-content-images', true)
on conflict (id) do nothing;
-- Allow public read access to content images
create policy "content_images_select_public" on storage.objects
for select
using (bucket_id = 'event-content-images');
-- Allow authenticated admins to upload content images
create policy "content_images_insert_admin" on storage.objects
for insert
with check (
bucket_id = 'event-content-images'
and auth.role() = 'authenticated'
and exists (
select 1 from public.profiles p
where p.id = auth.uid()
and p.is_admin
)
);
-- Allow authenticated admins to update content images
create policy "content_images_update_admin" on storage.objects
for update
using (
bucket_id = 'event-content-images'
and auth.role() = 'authenticated'
and exists (
select 1 from public.profiles p
where p.id = auth.uid()
and p.is_admin
)
);
-- Allow authenticated admins to delete content images
create policy "content_images_delete_admin" on storage.objects
for delete
using (
bucket_id = 'event-content-images'
and auth.role() = 'authenticated'
and exists (
select 1 from public.profiles p
where p.id = auth.uid()
and p.is_admin
)
);
+10
View File
@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/typography')],
};