Grid üzerine ImageUploadAndViewer sütun tipi eklendi

This commit is contained in:
Sedat ÖZTÜRK 2026-05-06 13:55:34 +03:00
parent 10404ab63a
commit 3219265c12
16 changed files with 496 additions and 19 deletions

View file

@ -2682,6 +2682,7 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
new EditingFormItemDto { Order = 6, DataField = "PublishDate", ColSpan=1, EditorType2 = EditorTypes.dxDateBox },
new EditingFormItemDto { Order = 7, DataField = "ExpiryDate", ColSpan=1, IsRequired = true, EditorType2 = EditorTypes.dxDateBox },
new EditingFormItemDto { Order = 8, DataField = "IsPinned", ColSpan=1, EditorType2 = EditorTypes.dxCheckBox },
new EditingFormItemDto { Order = 9, DataField = "ImageUrl", ColSpan=1, EditorType2 = EditorTypes.dxImageUpload, EditorOptions = EditorOptionValues.ImageUploadOptions},
]}
}),
FormFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[]
@ -2766,7 +2767,7 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
CaptionName = "App.Listform.ListformField.ImageUrl",
Width = 200,
ListOrderNo = 5,
Visible = false,
Visible = true,
IsActive = true,
AllowSearch = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson,

View file

@ -26,6 +26,7 @@ public static class PlatformConsts
public static string DateFormat = "{ \"format\": \"dd/MM/yyyy\", \"displayFormat\" : \"dd/MM/yyyy\" }";
public static string DateTimeFormat = "{ \"format\": \"dd/MM/yyyy HH:mm\", \"displayFormat\" : \"dd/MM/yyyy HH:mm\" }";
public static string SliderOptions = "{\"tooltip\": { \"enabled\": true }}";
public static string ImageUploadOptions = "{\"width\": 80, \"height\": 80, \"multiple\": true}";
}
public static class EditorScriptValues
@ -738,6 +739,7 @@ public static class PlatformConsts
public const string dxTagBox = "dxTagBox";
public const string dxTextArea = "dxTextArea";
public const string dxTextBox = "dxTextBox";
public const string dxImageUpload = "dxImageUpload";
}
public static class CustomEndpointConsts

View file

@ -1052,7 +1052,6 @@ public class PlatformDbContext :
b.Property(x => x.Title).IsRequired().HasMaxLength(256);
b.Property(x => x.Excerpt).IsRequired().HasMaxLength(512);
b.Property(x => x.Content).IsRequired().HasMaxLength(4096);
b.Property(x => x.ImageUrl).HasMaxLength(512);
b.Property(x => x.Category).IsRequired().HasMaxLength(64);
b.Property(x => x.PublishDate).IsRequired();
b.Property(x => x.Attachments).HasMaxLength(2048);

View file

@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
namespace Sozsoft.Platform.Migrations
{
[DbContext(typeof(PlatformDbContext))]
[Migration("20260505120031_Initial")]
[Migration("20260506093149_Initial")]
partial class Initial
{
/// <inheritdoc />
@ -603,8 +603,7 @@ namespace Sozsoft.Platform.Migrations
.HasColumnType("datetime2");
b.Property<string>("ImageUrl")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()

View file

@ -471,7 +471,7 @@ namespace Sozsoft.Platform.Migrations
Title = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Excerpt = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
Content = table.Column<string>(type: "nvarchar(max)", maxLength: 4096, nullable: false),
ImageUrl = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
ImageUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
Category = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
PublishDate = table.Column<DateTime>(type: "datetime2", nullable: false),

View file

@ -600,8 +600,7 @@ namespace Sozsoft.Platform.Migrations
.HasColumnType("datetime2");
b.Property<string>("ImageUrl")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()

View file

@ -325,6 +325,15 @@ export interface TagBoxOptionsDto {
acceptCustomValue: boolean
}
export interface ImageUploadOptionsDto {
uploadUrl?: string
accept?: string
multiple?: boolean
maxFileSize?: number
width?: number
height?: number
}
export interface RowDto {
rowHeight: string
whiteSpace: string
@ -480,6 +489,7 @@ export interface EditingFormItemDto {
isRequired?: boolean
gridBoxOptions?: GridBoxOptionsDto
tagBoxOptions?: TagBoxOptionsDto
imageUploadOptions?: ImageUploadOptionsDto
editorScript?: string
}
@ -822,6 +832,7 @@ export enum SubFormTabTypeEnum {
export enum PlatformEditorTypes {
dxTagBox = 'dxTagBox',
dxGridBox = 'dxGridBox',
dxImageUpload = 'dxImageUpload',
}
export enum GanttScaleTypeEnum {

View file

@ -189,6 +189,7 @@ export const columnEditorTypeListOptions = [
{ value: 'dxDateRangeBox', label: 'dxDateRangeBox' },
{ value: 'dxDropDownBox', label: 'dxDropDownBox' },
{ value: PlatformEditorTypes.dxGridBox, label: PlatformEditorTypes.dxGridBox },
{ value: PlatformEditorTypes.dxImageUpload, label: PlatformEditorTypes.dxImageUpload },
{ value: 'dxHtmlEditor', label: 'dxHtmlEditor' },
{ value: 'dxLookup', label: 'dxLookup' },
{ value: 'dxNumberBox', label: 'dxNumberBox' },

View file

@ -8,6 +8,7 @@ import {
import { FieldDataChangedEvent, GroupItem } from 'devextreme/ui/form'
import { Dispatch, RefObject, useEffect, useRef } from 'react'
import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent'
import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent'
import { RowMode, SimpleItemWithColData } from './types'
import { PlatformEditorTypes } from '@/proxy/form/models'
@ -211,6 +212,32 @@ const FormDevExpress = (props: {
className: 'font-semibold',
}}
></SimpleItemDx>
) : formItem.editorType2 === PlatformEditorTypes.dxImageUpload ? (
<SimpleItemDx
cssClass="font-semibold"
key={'formItem-' + i}
dataField={formItem.dataField}
name={formItem.name}
colSpan={formItem.colSpan}
isRequired={formItem.isRequired}
render={() => (
<ImageUploadEditorComponent
value={formData[formItem.dataField!]}
options={formItem.imageUploadOptions}
onValueChanged={(val: any) => {
setFormData({ ...formData, [formItem.dataField!]: val })
}}
editorOptions={{
...formItem.editorOptions,
...(mode === 'view' ? { readOnly: true } : {}),
}}
/>
)}
label={{
text: translate('::' + formItem.colData?.captionName),
className: 'font-semibold',
}}
></SimpleItemDx>
) : (
<SimpleItemDx
cssClass="font-semibold"

View file

@ -0,0 +1,177 @@
import { ImageUploadOptionsDto } from '@/proxy/form/models'
import { ReactElement, useRef, useState } from 'react'
import { FaSpinner, FaUpload } from 'react-icons/fa'
const ImageUploadEditorComponent = ({
value,
options,
onValueChanged,
editorOptions,
}: {
value: any
options?: ImageUploadOptionsDto
onValueChanged: (val: any) => void
editorOptions?: any
}): ReactElement => {
const readOnly: boolean = editorOptions?.readOnly ?? false
// options önce prop olarak gelen ImageUploadOptionsDto'dan, sonra editorOptions JSON'undan okunur
const resolvedOptions: ImageUploadOptionsDto = options ?? editorOptions ?? {}
// JSON'dan string olarak gelebilir: "true" / "false"
const isMultiple: boolean =
resolvedOptions.multiple === true || (resolvedOptions.multiple as any) === 'true'
const thumbW: number = resolvedOptions.width ?? 40
const thumbH: number = resolvedOptions.height ?? 40
const initialUrls: string[] = value
? Array.isArray(value)
? (value as string[]).filter(Boolean)
: [value as string].filter(Boolean)
: []
const [urls, setUrls] = useState<string[]>(initialUrls)
const [uploading, setUploading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const removeImage = (index: number) => {
const newUrls = urls.filter((_, i) => i !== index)
setUrls(newUrls)
const newValue = isMultiple
? newUrls.length > 0
? newUrls
: null
: (newUrls[0] ?? null)
onValueChanged(newValue)
}
const toBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
const handleFiles = async (files: FileList | null) => {
if (!files?.length) return
setUploading(true)
try {
// isMultiple=true ise mevcut url'leri koru, üstüne ekle; false ise sıfırla
const uploadedUrls: string[] = isMultiple ? [...urls] : []
for (const file of Array.from(files)) {
if (resolvedOptions.uploadUrl) {
const formData = new FormData()
const fileFieldName: string = (resolvedOptions as any).fileFieldName ?? 'file'
formData.append(fileFieldName, file)
const res = await fetch(resolvedOptions.uploadUrl, { method: 'POST', body: formData })
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json()
const url: string = data?.url ?? data?.fileUrl ?? data?.path ?? data
uploadedUrls.push(url)
} else {
// uploadUrl yoksa base64 olarak sakla
const base64 = await toBase64(file)
uploadedUrls.push(base64)
}
}
const newUrls = uploadedUrls.filter(Boolean)
setUrls(newUrls)
const newValue = isMultiple ? newUrls : (newUrls[newUrls.length - 1] ?? null)
onValueChanged(newValue)
} catch (e) {
console.error('ImageUploadEditorComponent upload error:', e)
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ''
}
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', padding: 4 }}>
{urls.map((url, i) => (
<div key={i} style={{ position: 'relative', display: 'inline-block' }}>
<img
src={url}
alt=""
style={{
width: thumbW,
height: thumbH,
objectFit: 'cover',
borderRadius: 4,
border: '1px solid #ddd',
}}
/>
{!readOnly && (
<button
type="button"
title="Sil"
onClick={() => removeImage(i)}
style={{
position: 'absolute',
top: -6,
right: -6,
background: '#dc3545',
color: '#fff',
border: 'none',
borderRadius: '50%',
width: 18,
height: 18,
cursor: 'pointer',
fontSize: 11,
lineHeight: '18px',
padding: 0,
textAlign: 'center',
}}
>
×
</button>
)}
</div>
))}
{!readOnly && (
<>
<input
ref={inputRef}
type="file"
accept={resolvedOptions.accept ?? 'image/*'}
multiple={isMultiple}
style={{ display: 'none' }}
onChange={(e) => handleFiles(e.target.files)}
/>
<button
type="button"
title="Görsel Yükle"
disabled={uploading}
onClick={() => inputRef.current?.click()}
style={{
padding: '4px 10px',
background: uploading ? '#aaa' : '#0078d4',
color: '#fff',
border: 'none',
borderRadius: 4,
cursor: uploading ? 'not-allowed' : 'pointer',
fontSize: 13,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{uploading ? (
<FaSpinner style={{ fontSize: 14 }} className="spin" />
) : (
<FaUpload style={{ fontSize: 14 }} />
)}
</button>
</>
)}
</div>
)
}
export { ImageUploadEditorComponent }

View file

@ -3,18 +3,20 @@ import { Overwrite } from '../../utils/types'
import {
ColumnFormatDto,
GridBoxOptionsDto,
ImageUploadOptionsDto,
PlatformEditorTypes,
TagBoxOptionsDto,
} from '../../proxy/form/models'
import { Meta } from '@/proxy/routes/routes'
export type EditorType2 = FormItemComponent | PlatformEditorTypes.dxGridBox
export type EditorType2 = FormItemComponent | PlatformEditorTypes.dxGridBox | PlatformEditorTypes.dxImageUpload
export type SimpleItemWithColData = Overwrite<
SimpleItem,
{
colData?: ColumnFormatDto
tagBoxOptions?: TagBoxOptionsDto
gridBoxOptions?: GridBoxOptionsDto
imageUploadOptions?: ImageUploadOptionsDto
canRead: boolean
canCreate: boolean
canUpdate: boolean

View file

@ -110,14 +110,22 @@ const AnnouncementModal: React.FC<AnnouncementModalProps> = ({
{/* Content */}
<div className="p-6 max-h-[60vh] overflow-y-auto">
{/* Image if exists */}
{announcement.imageUrl && (
{/* Images if exist */}
{announcement.imageUrl && (() => {
const images = announcement.imageUrl.split('|').filter(Boolean)
return images.length > 0 ? (
<div className={`mb-6 ${images.length > 1 ? 'grid grid-cols-2 gap-2' : ''}`}>
{images.map((img, idx) => (
<img
src={announcement.imageUrl}
alt={announcement.title}
className="w-full rounded-lg mb-6"
key={idx}
src={img.startsWith('data:') ? img : `data:image/jpeg;base64,${img}`}
alt={`${announcement.title} ${images.length > 1 ? idx + 1 : ''}`.trim()}
className="w-full rounded-lg object-cover"
/>
)}
))}
</div>
) : null
})()}
{/* Full Content */}
<div className="prose prose-sm dark:prose-invert max-w-none">

View file

@ -61,6 +61,7 @@ import {
setGridPanelColor,
} from './Utils'
import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent'
import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent'
import { useFilters } from './useFilters'
import { useToolbar } from './useToolbar'
@ -955,7 +956,11 @@ const Grid = (props: GridProps) => {
name: i.dataField,
editorType2: i.editorType2,
editorType:
i.editorType2 == PlatformEditorTypes.dxGridBox ? 'dxDropDownBox' : i.editorType2,
i.editorType2 == PlatformEditorTypes.dxGridBox
? 'dxDropDownBox'
: i.editorType2 == PlatformEditorTypes.dxImageUpload
? undefined
: i.editorType2,
colSpan: i.colSpan,
isRequired: i.isRequired,
editorOptions,
@ -1357,6 +1362,7 @@ const Grid = (props: GridProps) => {
></Editing>
<Template name={'cellEditTagBox'} render={TagBoxEditorComponent} />
<Template name={'cellEditGridBox'} render={GridBoxEditorComponent} />
<Template name={'cellEditImageUpload'} render={ImageUploadEditorComponent} />
<Template name="extraFilters">
<GridExtraFilterToolbar
filters={gridDto?.gridOptions.extraFilterDto ?? []}

View file

@ -1,5 +1,10 @@
import { DataGridTypes } from 'devextreme-react/data-grid'
import { ColumnFormatDto, GridBoxOptionsDto, TagBoxOptionsDto } from '../../proxy/form/models'
import {
ColumnFormatDto,
GridBoxOptionsDto,
ImageUploadOptionsDto,
TagBoxOptionsDto,
} from '../../proxy/form/models'
interface IGridColumnData extends DataGridTypes.Column {
colData?: ColumnFormatDto
@ -8,6 +13,7 @@ interface IGridColumnData extends DataGridTypes.Column {
editorOptions?: string
tagBoxOptions?: TagBoxOptionsDto
gridBoxOptions?: GridBoxOptionsDto
imageUploadOptions?: ImageUploadOptionsDto
}
caption?: string
isBand?: boolean

View file

@ -0,0 +1,174 @@
import { ReactElement, useRef, useState } from 'react'
import { FaSpinner, FaUpload } from 'react-icons/fa'
const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
const col = cellElement.column
if (!col) {
return <></>
}
// options: önce column.extras.imageUploadOptions, yoksa editorOptions JSON'undan parse et
let options: any = col.extras?.imageUploadOptions
if (!options || Object.keys(options).length === 0) {
try {
const raw = col.extras?.editorOptions ?? col.editorOptions
if (raw) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
options = parsed
}
} catch {
// ignore
}
}
options = options ?? {}
// JSON'dan string olarak gelebilir: "true" / "false"
const isMultiple: boolean = options.multiple === true || options.multiple === 'true'
const [uploading, setUploading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const thumbW: number = options.width ?? 40
const thumbH: number = options.height ?? 40
const currentValue = cellElement.value
const initialUrls: string[] = currentValue
? Array.isArray(currentValue)
? currentValue.filter(Boolean)
: [currentValue].filter(Boolean)
: []
const [urls, setUrls] = useState<string[]>(initialUrls)
const removeImage = (index: number) => {
const newUrls = urls.filter((_, i) => i !== index)
setUrls(newUrls)
if (isMultiple) {
cellElement.setValue(newUrls.length > 0 ? newUrls : null)
} else {
cellElement.setValue(newUrls[0] ?? null)
}
}
const toBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
const handleFiles = async (files: FileList | null) => {
if (!files?.length) return
setUploading(true)
try {
// isMultiple=true ise mevcut url'leri koru, üstüne ekle; false ise sıfırla
const uploadedUrls: string[] = isMultiple ? [...urls] : []
for (const file of Array.from(files)) {
if (options.uploadUrl) {
// HTTP upload
const formData = new FormData()
const fileFieldName: string = options.fileFieldName ?? 'file'
formData.append(fileFieldName, file)
const res = await fetch(options.uploadUrl, { method: 'POST', body: formData })
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json()
const url: string = data?.url ?? data?.fileUrl ?? data?.path ?? data
uploadedUrls.push(url)
} else {
// uploadUrl yapılandırılmamışsa base64 olarak sakla
const base64 = await toBase64(file)
uploadedUrls.push(base64)
}
}
const newUrls = uploadedUrls.filter(Boolean)
setUrls(newUrls)
const newValue = isMultiple ? newUrls : (newUrls[newUrls.length - 1] ?? null)
cellElement.setValue(newValue)
} catch (e) {
console.error('ImageUploadEditorComponent upload error:', e)
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ''
}
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', padding: 4 }}>
{urls.map((url, i) => (
<div key={i} style={{ position: 'relative', display: 'inline-block' }}>
<img
src={url}
alt=""
style={{
width: thumbW,
height: thumbH,
objectFit: 'cover',
borderRadius: 4,
border: '1px solid #ddd',
}}
/>
<button
type="button"
title="Sil"
onClick={() => removeImage(i)}
style={{
position: 'absolute',
top: -6,
right: -6,
background: '#dc3545',
color: '#fff',
border: 'none',
borderRadius: '50%',
width: 18,
height: 18,
cursor: 'pointer',
fontSize: 11,
lineHeight: '18px',
padding: 0,
textAlign: 'center',
}}
>
×
</button>
</div>
))}
<input
ref={inputRef}
type="file"
accept={options.accept ?? 'image/*'}
multiple={isMultiple}
style={{ display: 'none' }}
onChange={(e) => handleFiles(e.target.files)}
/>
<button
type="button"
title="Görsel Yükle"
disabled={uploading}
onClick={() => inputRef.current?.click()}
style={{
padding: '4px 10px',
background: uploading ? '#aaa' : '#0078d4',
color: '#fff',
border: 'none',
borderRadius: 4,
cursor: uploading ? 'not-allowed' : 'pointer',
fontSize: 13,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{uploading ? (
<FaSpinner style={{ fontSize: 14 }} className="spin" />
) : (
<FaUpload style={{ fontSize: 14 }} />
)}
</button>
</div>
)
}
export { ImageUploadEditorComponent }

View file

@ -65,6 +65,33 @@ const cellTemplateMultiValue = (
}
}
const cellTemplateImage = (
cellElement: HTMLElement,
cellInfo: DataGridTypes.ColumnCellTemplateData<any, any>,
) => {
if (cellInfo?.value) {
const urls: string[] = Array.isArray(cellInfo.value)
? cellInfo.value.filter(Boolean)
: [cellInfo.value].filter(Boolean)
const col = cellInfo.column as any
const imgOptions = col?.extras?.imageUploadOptions ?? {}
const w: number = imgOptions.width ?? 40
const h: number = imgOptions.height ?? 40
const imgs = urls
.map(
(url) =>
`<img src="${url}" alt="" style="width:${w}px;height:${h}px;object-fit:cover;border-radius:4px;border:1px solid #ddd;margin:2px;vertical-align:middle;display:inline-block;" />`,
)
.join('')
cellElement.style.cssText += 'display:flex;flex-wrap:wrap;align-items:center;gap:4px;'
cellElement.innerHTML = imgs
cellElement.title = urls.join(', ')
}
}
function calculateFilterExpressionMultiValue(
this: DataGridTypes.Column,
filterValue: any,
@ -641,6 +668,44 @@ const useListFormColumns = ({
}
}
// #endregion
// #region image upload editor
if (!colData.lookupDto?.dataSourceType) {
const allFormItems = gridDto.gridOptions.editingFormDto.flatMap((group) => group.items)
const imageFormItem = allFormItems.find((a) => a?.dataField === colData.fieldName)
if (imageFormItem?.editorType2 === PlatformEditorTypes.dxImageUpload) {
// imageUploadOptions'ı önce imageFormItem.imageUploadOptions'dan al,
// yoksa editorOptions JSON içinden parse et (admin panelinde editorOptions ile yapılandırılır)
let imageUploadOptions = imageFormItem.imageUploadOptions
if (!imageUploadOptions && imageFormItem.editorOptions) {
try {
imageUploadOptions = JSON.parse(imageFormItem.editorOptions)
} catch {
imageUploadOptions = undefined
}
}
// Kolon editorOptions'ından da dene (column-level config)
if (!imageUploadOptions && colData.editorOptions) {
try {
imageUploadOptions =
typeof colData.editorOptions === 'string'
? JSON.parse(colData.editorOptions)
: (colData.editorOptions as any)
} catch {
imageUploadOptions = undefined
}
}
column.extras = {
multiValue: imageUploadOptions?.multiple ?? false,
editorOptions: imageFormItem.editorOptions,
imageUploadOptions: imageUploadOptions,
}
column.editCellTemplate = 'cellEditImageUpload'
column.cellTemplate = cellTemplateImage as any
}
}
// #endregion image upload editor
if (colData.validationRuleDto) {
// for server side validation : https://js.devexpress.com/Demos/WidgetsGallery/Demo/DataGrid/DataValidation/jQuery/Light/
column.validationRules = colData.validationRuleDto as ValidationRule[]