Add media image handling and related endpoints
- Introduced `MediaImage` entity to manage images for markdown in instructions and news. - Updated `IAppDbContext` and `AppDbContext` to include `MediaImages` DbSet. - Implemented `DeleteMediaImageFilesAsync` method in `FactoryResetCommandHandler` to remove media images during factory reset. - Added new API endpoints for uploading and retrieving media images, enhancing markdown support. - Updated frontend components to utilize the new `MarkdownEditor` for image uploads in instructions and news. - Enhanced documentation to reflect the new media handling features and API specifications.
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { MarkdownEditor } from '@/features/admin/media/MarkdownEditor'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { InstructionIntroDto } from '@/shared/api/types'
|
||||
import { updateInstructionIntro } from './api'
|
||||
@@ -14,7 +12,6 @@ export function InstructionIntroEditor({ intro }: { intro: InstructionIntroDto }
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [body, setBody] = useState(intro.body)
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateInstructionIntro(body.trim()),
|
||||
@@ -29,19 +26,8 @@ export function InstructionIntroEditor({ intro }: { intro: InstructionIntroDto }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{t('admin.instructions.introHint')}</p>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
|
||||
{t('admin.news.preview')}
|
||||
</Button>
|
||||
</div>
|
||||
{previewMode ? (
|
||||
<div className="flex min-h-32 flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body || t('admin.instructions.tabBody')}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea value={body} onChange={(e) => setBody(e.target.value)} rows={8} />
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{t('admin.instructions.introHint')}</p>
|
||||
<MarkdownEditor label={t('admin.instructions.tabBody')} value={body} onChange={setBody} rows={8} />
|
||||
<div>
|
||||
<Button disabled={!body.trim() || !isDirty || mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
{t('admin.roles.save')}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { MarkdownEditor } from '@/features/admin/media/MarkdownEditor'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { InstructionTabDto } from '@/shared/api/types'
|
||||
import { createInstructionTab, updateInstructionTab } from './api'
|
||||
@@ -28,7 +26,6 @@ export function InstructionTabFormDialog({
|
||||
const [title, setTitle] = useState(tab?.title ?? '')
|
||||
const [body, setBody] = useState(tab?.body ?? '')
|
||||
const [sortOrder, setSortOrder] = useState(String(tab?.sortOrder ?? 0))
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
|
||||
const isControlled = open !== undefined
|
||||
const dialogOpen = isControlled ? open : internalOpen
|
||||
@@ -56,7 +53,7 @@ export function InstructionTabFormDialog({
|
||||
<Button size="sm">{t('admin.instructions.createTab')}</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tab ? tab.title : t('admin.instructions.createTab')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -71,21 +68,13 @@ export function InstructionTabFormDialog({
|
||||
<Label htmlFor="tabTitle">{t('admin.instructions.tabTitle')}</Label>
|
||||
<Input id="tabTitle" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="tabBody">{t('admin.instructions.tabBody')}</Label>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
|
||||
{t('admin.news.preview')}
|
||||
</Button>
|
||||
</div>
|
||||
{previewMode ? (
|
||||
<div className="flex min-h-32 flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body || t('admin.instructions.tabBody')}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea id="tabBody" value={body} onChange={(e) => setBody(e.target.value)} required />
|
||||
)}
|
||||
</div>
|
||||
<MarkdownEditor
|
||||
id="tabBody"
|
||||
label={t('admin.instructions.tabBody')}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="tabSortOrder">{t('admin.apps.sortOrder')}</Label>
|
||||
<Input id="tabSortOrder" type="number" min={0} value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ImagePlus } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { MARKDOWN_CLASSES } from '@/shared/lib/markdown'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { mediaImageUrl, uploadMediaImage } from './api'
|
||||
|
||||
/** Markdown-поле админки: текст + предпросмотр + загрузка картинок (кнопка, вставка из буфера,
|
||||
* drag&drop). Загруженная картинка сразу вставляется в позицию курсора как ``. */
|
||||
export function MarkdownEditor({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
rows = 12,
|
||||
required,
|
||||
}: {
|
||||
id?: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
rows?: number
|
||||
required?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
const insertAtCursor = (snippet: string) => {
|
||||
const textarea = textareaRef.current
|
||||
const start = textarea?.selectionStart ?? value.length
|
||||
const end = textarea?.selectionEnd ?? value.length
|
||||
const before = value.slice(0, start)
|
||||
const after = value.slice(end)
|
||||
// Картинка — блочный элемент: отбиваем пустой строкой, иначе markdown склеит её с абзацем.
|
||||
const prefix = before && !before.endsWith('\n') ? '\n\n' : ''
|
||||
const suffix = after.startsWith('\n') ? '' : '\n'
|
||||
onChange(`${before}${prefix}${snippet}${suffix}${after}`)
|
||||
|
||||
const caret = before.length + prefix.length + snippet.length
|
||||
requestAnimationFrame(() => {
|
||||
textarea?.focus()
|
||||
textarea?.setSelectionRange(caret, caret)
|
||||
})
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
const images = files.filter((file) => file.type.startsWith('image/'))
|
||||
if (images.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const uploaded = []
|
||||
for (const file of images) uploaded.push(await uploadMediaImage(file))
|
||||
insertAtCursor(uploaded.map((image) => `})`).join('\n\n'))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof HttpError ? error.detail : t('admin.media.uploadFailed'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
{uploading ? t('admin.media.uploading') : t('admin.media.uploadImage')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
|
||||
{t('admin.news.preview')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewMode ? (
|
||||
<div className={cn('flex flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm', MARKDOWN_CLASSES)} style={{ minHeight: `${rows * 1.5}rem` }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value || label}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
rows={rows}
|
||||
required={required}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn('font-mono', dragOver && 'ring-2 ring-primary/50')}
|
||||
onPaste={(e) => {
|
||||
const files = Array.from(e.clipboardData.files)
|
||||
if (files.some((file) => file.type.startsWith('image/'))) {
|
||||
e.preventDefault()
|
||||
void uploadFiles(files)
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
setDragOver(false)
|
||||
if (files.some((file) => file.type.startsWith('image/'))) {
|
||||
e.preventDefault()
|
||||
void uploadFiles(files)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('admin.media.hint')}</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
e.target.value = ''
|
||||
void uploadFiles(files)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Alt по имени файла (без расширения) — иначе пустой alt у картинки в markdown. */
|
||||
function altText(fileName: string) {
|
||||
return fileName.replace(/\.[^.]+$/, '').slice(0, 100)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { apiUpload } from '@/shared/api/client'
|
||||
import type { MediaImageDto } from '@/shared/api/types'
|
||||
|
||||
export function uploadMediaImage(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set('file', file)
|
||||
return apiUpload<MediaImageDto>('/admin/media/images', formData)
|
||||
}
|
||||
|
||||
/** Картинки отдаются анонимно по непрозрачному Id — обычный <img> из markdown не шлёт Authorization,
|
||||
* поэтому в отличие от вложений тикетов blob-обёртка не нужна. */
|
||||
export function mediaImageUrl(id: string) {
|
||||
return `/api/media/images/${id}`
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { MarkdownEditor } from '@/features/admin/media/MarkdownEditor'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { NewsPostDto } from '@/shared/api/types'
|
||||
import { createPost, updatePost } from './api'
|
||||
@@ -27,7 +25,6 @@ export function NewsFormDialog({
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const [title, setTitle] = useState(post?.title ?? '')
|
||||
const [body, setBody] = useState(post?.body ?? '')
|
||||
const [previewMode, setPreviewMode] = useState(false)
|
||||
|
||||
const isControlled = open !== undefined
|
||||
const dialogOpen = isControlled ? open : internalOpen
|
||||
@@ -52,7 +49,7 @@ export function NewsFormDialog({
|
||||
<Button size="sm">{t('admin.news.create')}</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{post ? post.title : t('admin.news.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -67,21 +64,7 @@ export function NewsFormDialog({
|
||||
<Label htmlFor="newsTitle">{t('admin.news.title')}</Label>
|
||||
<Input id="newsTitle" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="newsBody">{t('admin.news.body')}</Label>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
|
||||
{t('admin.news.preview')}
|
||||
</Button>
|
||||
</div>
|
||||
{previewMode ? (
|
||||
<div className="flex min-h-32 flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body || t('admin.news.body')}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea id="newsBody" value={body} onChange={(e) => setBody(e.target.value)} required />
|
||||
)}
|
||||
</div>
|
||||
<MarkdownEditor id="newsBody" label={t('admin.news.body')} value={body} onChange={setBody} required />
|
||||
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
|
||||
{post ? t('admin.roles.save') : t('admin.news.create')}
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { MARKDOWN_CLASSES } from '@/shared/lib/markdown'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { listNews } from './api'
|
||||
@@ -42,7 +44,7 @@ export function NewsFeed() {
|
||||
<CardTitle className="text-base">{post.title}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleString()}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
|
||||
<CardContent className={cn('flex flex-col gap-2 text-sm', MARKDOWN_CLASSES)}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body}</ReactMarkdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -8,11 +8,11 @@ import { useRequireActivated } from '@/features/auth/guards'
|
||||
import { AppsCatalog } from '@/features/apps/AppsCatalog'
|
||||
import { getInstructionIntro, listInstructionTabs } from '@/features/instructions/api'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { MARKDOWN_CLASSES } from '@/shared/lib/markdown'
|
||||
|
||||
export const Route = createFileRoute('/instructions')({ component: InstructionsPage })
|
||||
|
||||
const APPS_TAB_ID = '__apps__'
|
||||
const MARKDOWN_CLASSES = '[&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal'
|
||||
|
||||
function InstructionsPage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -134,6 +134,15 @@ export type InstructionTabDto = {
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/** POST /api/admin/media/images — картинка для вставки в markdown. Ссылку клиент строит сам:
|
||||
* `/api/media/images/{id}` (см. features/admin/media/api.ts). */
|
||||
export type MediaImageDto = {
|
||||
id: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export type LinkTokenResponse = {
|
||||
deepLink: string | null
|
||||
expiresAt: string
|
||||
|
||||
@@ -501,6 +501,12 @@ const resources = {
|
||||
tabDeleted: 'Вкладка удалена.',
|
||||
confirmDeleteTab: 'Удалить вкладку инструкций?',
|
||||
},
|
||||
media: {
|
||||
uploadImage: 'Картинка',
|
||||
uploading: 'Загрузка…',
|
||||
uploadFailed: 'Не удалось загрузить картинку.',
|
||||
hint: 'Картинку можно загрузить кнопкой, вставить из буфера (Ctrl+V) или перетащить в поле — в текст добавится ссылка . До 5 МБ, JPEG/PNG/WEBP/GIF.',
|
||||
},
|
||||
news: {
|
||||
create: 'Добавить новость',
|
||||
title: 'Заголовок',
|
||||
@@ -1095,6 +1101,12 @@ const resources = {
|
||||
tabDeleted: 'Tab deleted.',
|
||||
confirmDeleteTab: 'Delete this instruction tab?',
|
||||
},
|
||||
media: {
|
||||
uploadImage: 'Image',
|
||||
uploading: 'Uploading…',
|
||||
uploadFailed: 'Failed to upload the image.',
|
||||
hint: 'Upload an image with the button, paste it from the clipboard (Ctrl+V) or drop it onto the field — an  link is inserted into the text. Up to 5 MB, JPEG/PNG/WEBP/GIF.',
|
||||
},
|
||||
news: {
|
||||
create: 'Add post',
|
||||
title: 'Title',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Оформление отрендеренного markdown (ссылки, списки, картинки) — одинаковое во всех местах:
|
||||
* страница инструкций, лента новостей и предпросмотр в админке. */
|
||||
export const MARKDOWN_CLASSES =
|
||||
'[&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal ' +
|
||||
'[&_img]:my-2 [&_img]:max-w-full [&_img]:rounded-md [&_img]:border [&_img]:border-border'
|
||||
Reference in New Issue
Block a user