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": {
|
"dependencies": {
|
||||||
"@astrojs/netlify": "^8.2.1",
|
"@astrojs/netlify": "^8.2.1",
|
||||||
"@astrojs/react": "^6.0.2",
|
"@astrojs/react": "^6.0.2",
|
||||||
|
"@mdxeditor/editor": "^4.3.2",
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "^2.55.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": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"astro": "^7.2.1",
|
"astro": "^7.2.1",
|
||||||
|
"autoprefixer": "^10.6.1",
|
||||||
|
"isomorphic-dompurify": "^4.5.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"markdown-it": "^15.0.2",
|
||||||
|
"postcss": "^8.5.29",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-leaflet": "^5.0.0"
|
"react-leaflet": "^5.0.0",
|
||||||
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"sharp": "0.35.3"
|
"sharp": "0.35.3"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,23 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import type { FormEvent, ChangeEvent } from 'react';
|
import type { FormEvent, ChangeEvent } from 'react';
|
||||||
import { createBrowserSupabase } from '../../lib/supabase';
|
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 { Dict } from '../../lib/i18n';
|
||||||
import type { EventRow } from '../../lib/db';
|
import type { EventRow } from '../../lib/db';
|
||||||
|
|
||||||
@@ -9,6 +26,11 @@ interface Props {
|
|||||||
event?: EventRow | null;
|
event?: EventRow | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContentEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function pad(n: number): string {
|
function pad(n: number): string {
|
||||||
return String(n).padStart(2, '0');
|
return String(n).padStart(2, '0');
|
||||||
}
|
}
|
||||||
@@ -30,6 +52,55 @@ function slugify(value: string): string {
|
|||||||
.slice(0, 80);
|
.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) {
|
export default function EventEditor({ dict: t, event }: Props) {
|
||||||
const [titleUa, setTitleUa] = useState(event?.title?.ua ?? '');
|
const [titleUa, setTitleUa] = useState(event?.title?.ua ?? '');
|
||||||
const [titleEn, setTitleEn] = useState(event?.title?.en ?? '');
|
const [titleEn, setTitleEn] = useState(event?.title?.en ?? '');
|
||||||
@@ -94,6 +165,10 @@ export default function EventEditor({ dict: t, event }: Props) {
|
|||||||
setError(t.common.error);
|
setError(t.common.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (descriptionUa.trim().length > 20000 || descriptionEn.trim().length > 20000) {
|
||||||
|
setError(t.common.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: { ua: titleUa.trim(), en: titleEn.trim() },
|
title: { ua: titleUa.trim(), en: titleEn.trim() },
|
||||||
@@ -158,25 +233,13 @@ export default function EventEditor({ dict: t, event }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid-2">
|
<div className="form-field">
|
||||||
<div className="form-field">
|
<label htmlFor="desc-ua">{t.admin.eventDescUa}</label>
|
||||||
<label htmlFor="desc-ua">{t.admin.eventDescUa}</label>
|
<ContentEditor content={descriptionUa} onChange={setDescriptionUa} />
|
||||||
<textarea
|
</div>
|
||||||
className="textarea"
|
<div className="form-field">
|
||||||
id="desc-ua"
|
<label htmlFor="desc-en">{t.admin.eventDescEn}</label>
|
||||||
value={descriptionUa}
|
<ContentEditor content={descriptionEn} onChange={setDescriptionEn} />
|
||||||
onChange={(e) => setDescriptionUa(e.target.value)}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid-2">
|
<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 type { Locale } from '../../../lib/i18n';
|
||||||
import { getEventBySlug, getEventReviews, titleFor, descriptionFor } from '../../../lib/db';
|
import { getEventBySlug, getEventReviews, titleFor, descriptionFor } from '../../../lib/db';
|
||||||
import { formatDate, formatTime } from '../../../lib/format';
|
import { formatDate, formatTime } from '../../../lib/format';
|
||||||
|
import { markdownToHtml } from '../../../lib/markdown';
|
||||||
|
|
||||||
const { lang, slug } = Astro.params;
|
const { lang, slug } = Astro.params;
|
||||||
const locale: Locale = isLocale(lang) ? lang : 'ua';
|
const locale: Locale = isLocale(lang) ? lang : 'ua';
|
||||||
@@ -22,6 +23,7 @@ if (!event) {
|
|||||||
|
|
||||||
const title = event ? titleFor(locale, event) : '404';
|
const title = event ? titleFor(locale, event) : '404';
|
||||||
const description = event ? descriptionFor(locale, event) : 'Not found';
|
const description = event ? descriptionFor(locale, event) : 'Not found';
|
||||||
|
const descriptionHtml = event ? await markdownToHtml(description) : '';
|
||||||
const reviews = event ? await getEventReviews(supabase, event.id) : [];
|
const reviews = event ? await getEventReviews(supabase, event.id) : [];
|
||||||
const canDelete = (reviewUserId: string) => user !== null && (user.isAdmin || reviewUserId === user.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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="prose">{description && <p>{description}</p>}</div>
|
<div class="prose" set:html={descriptionHtml}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="card" style="padding:24px;">
|
<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
|
// 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