Add mdx support for Events content
This commit is contained in:
Generated
+4886
-47
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -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"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
@@ -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')],
|
||||
};
|
||||
Reference in New Issue
Block a user