Add video cover image support to CreatePostBuilder & CreatePostMinimal

This commit is contained in:
2025-07-27 07:32:32 +00:00
parent 3bda69f396
commit c25472466d
2 changed files with 390 additions and 38 deletions

View File

@@ -10,7 +10,7 @@ import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'
import ImageUploader from '../../components/common/ImageUploader'
import AudiotrackIcon from '@mui/icons-material/Audiotrack'
import DeleteIcon from '@mui/icons-material/Delete'
import { Button, Box, useTheme } from '@mui/material'
import { Button, Box, useTheme, Dialog, DialogTitle, DialogContent, Tabs, Tab, Typography } from '@mui/material'
import { styled } from '@mui/system'
import { Descendant } from 'slate'
import EditIcon from '@mui/icons-material/Edit'
@@ -129,6 +129,42 @@ export const CreatePostBuilder = ({
const [isOpenAddTextModal, setIsOpenAddTextModal] =
React.useState<boolean>(false)
const [paddingValue, onChangePadding] = React.useState(5)
const [coverPickerOpen, setCoverPickerOpen] = React.useState(false)
const [coverTab, setCoverTab] = React.useState<'upload' | 'existing'>('upload')
const [pendingVideo, setPendingVideo] = React.useState<any | null>(null)
const [selectedPoster, setSelectedPoster] = React.useState<string>('')
const [existingImages, setExistingImages] = React.useState<any[]>([])
const [existingImagesLoading, setExistingImagesLoading] = React.useState(false)
const [coverPickerMode, setCoverPickerMode] = React.useState<'add' | 'edit'>('add')
const [coverPickerTarget, setCoverPickerTarget] = React.useState<any | null>(null)
const qdnResourceUrl = React.useCallback(
(service: string, name: string, identifier: string) => `/arbitrary/${service}/${name}/${identifier}`,
[]
)
const fetchExistingImages = React.useCallback(async () => {
if (!user?.name) return
setExistingImagesLoading(true)
try {
const SERVICES = ['IMAGE', 'THUMBNAIL', 'QCHAT_IMAGE']
const lists = await Promise.all(
SERVICES.map(async (svc) => {
const res = await fetch(
`/arbitrary/resources?service=${svc}&name=${user.name}&includemetadata=true&limit=100&offset=0&reverse=true`
)
const data = await res.json()
return Array.isArray(data)
? data.map((it: any) => ({ ...it, service: it.service || svc }))
: []
})
)
const merged = lists.flat()
setExistingImages(merged)
} catch (e) {
setExistingImages([])
} finally {
setExistingImagesLoading(false)
}
}, [user])
const [isEditNavOpen, setIsEditNavOpen] = React.useState<boolean>(false)
const dispatch = useDispatch()
const [navbarConfig, setNavbarConfig] = React.useState<any>(null)
@@ -723,6 +759,7 @@ export const CreatePostBuilder = ({
title: string
description: string
mimeType?: string
poster?: string
}
const addVideo = ({
@@ -730,7 +767,8 @@ export const CreatePostBuilder = ({
identifier,
service,
title,
description
description,
poster
}: IaddVideo) => {
const section = {
type: 'video',
@@ -740,7 +778,8 @@ export const CreatePostBuilder = ({
identifier: identifier,
service: service,
title,
description
description,
...(poster ? { poster } : {})
},
id: uid()
}
@@ -823,18 +862,26 @@ export const CreatePostBuilder = ({
}
}
const editVideo = (
{ name, identifier, service, description, title }: IaddVideo,
{ name, identifier, service, description, title, poster }: IaddVideo,
section: any
) => {
const nextContent: any = {
name,
identifier,
service,
description,
title
}
if (poster === undefined) {
if (typeof section.content?.poster !== 'undefined') {
nextContent.poster = section.content.poster
}
} else if (poster) {
nextContent.poster = poster
}
const newSection = {
...section,
content: {
name: name,
identifier: identifier,
service: service,
description,
title
}
content: nextContent
}
const findSectionIndex = newPostContent.findIndex(
(s) => s.id === section.id
@@ -897,14 +944,14 @@ export const CreatePostBuilder = ({
)
const onSelectVideo = React.useCallback((video: any) => {
addVideo({
name: video.name,
identifier: video.identifier,
service: video.service,
title: video?.metadata?.title,
description: video?.metadata?.description
})
}, [])
setCoverPickerMode('add')
setCoverPickerTarget(null)
setPendingVideo(video)
setSelectedPoster('')
setCoverTab('upload')
setCoverPickerOpen(true)
fetchExistingImages()
}, [fetchExistingImages])
const onSelectAudio = React.useCallback((video: any) => {
addAudio({
@@ -1156,6 +1203,7 @@ export const CreatePostBuilder = ({
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
poster={section.content.poster}
from="create"
/>
<EditButtons>
@@ -1183,6 +1231,18 @@ export const CreatePostBuilder = ({
)
}
/>
<AddPhotoAlternateIcon
onClick={() => {
setCoverPickerMode('edit')
setCoverPickerTarget(section)
setPendingVideo(null)
setSelectedPoster(section.content?.poster || '')
setCoverTab('upload')
setCoverPickerOpen(true)
fetchExistingImages()
}}
sx={{ cursor: 'pointer', height: '18px', width: 'auto' }}
/>
</EditButtons>
</Box>
</DynamicHeightItem>
@@ -1380,6 +1440,123 @@ export const CreatePostBuilder = ({
/>
)}
</Box>
<Dialog open={coverPickerOpen} onClose={() => setCoverPickerOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Select a cover image</DialogTitle>
<DialogContent>
<Tabs value={coverTab} onChange={(_, v) => setCoverTab(v)}>
<Tab label="Upload" value="upload" />
<Tab label="Existing" value="existing" />
</Tabs>
{coverTab === 'upload' && (
<Box sx={{ mt: 2 }}>
<ImageUploader onPick={(base64) => setSelectedPoster(base64)}>
<Button variant="outlined">Choose image</Button>
</ImageUploader>
{selectedPoster && (
<Box sx={{ mt: 2 }}>
<img src={selectedPoster} style={{ maxWidth: '100%' }} />
</Box>
)}
</Box>
)}
{coverTab === 'existing' && (
<Box sx={{ mt: 2, display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1 }}>
{existingImages.map((img: any) => {
const service = img.service || 'IMAGE'
const url = qdnResourceUrl(service, img.name, img.identifier)
const isActive = selectedPoster === url
return (
<Box
key={`${service}:${img.name}:${img.identifier}`}
sx={{
border: isActive ? '2px solid #1976d2' : '1px solid rgba(255,255,255,0.12)',
borderRadius: 1,
overflow: 'hidden',
cursor: 'pointer'
}}
onClick={() => setSelectedPoster(url)}
>
<img src={url} style={{ display: 'block', width: '100%' }} />
<Typography variant="caption" sx={{ display: 'block', p: 0.5 }} noWrap>
{img?.metadata?.title || img.identifier}
</Typography>
</Box>
)
})}
{existingImagesLoading && (
<Typography variant="body2">Loading...</Typography>
)}
{!existingImagesLoading && !existingImages.length && (
<Typography variant="body2">No published images found.</Typography>
)}
</Box>
)}
<Box sx={{ mt: 3, display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button onClick={() => setCoverPickerOpen(false)}>Cancel</Button>
<Button
onClick={() => {
if (coverPickerMode === 'edit' && coverPickerTarget) {
editVideo(
{
name: coverPickerTarget.content.name,
identifier: coverPickerTarget.content.identifier,
service: coverPickerTarget.content.service,
title: coverPickerTarget.content.title,
description: coverPickerTarget.content.description,
poster: ''
},
coverPickerTarget
)
setSelectedPoster('')
setCoverPickerOpen(false)
} else {
setSelectedPoster('')
}
}}
>
Clear cover
</Button>
<Button
variant="contained"
disabled={coverPickerMode === 'add' && !pendingVideo}
onClick={() => {
const poster = selectedPoster || undefined
if (coverPickerMode === 'add' && pendingVideo) {
addVideo({
name: pendingVideo.name,
identifier: pendingVideo.identifier,
service: pendingVideo.service,
title: pendingVideo?.metadata?.title,
description: pendingVideo?.metadata?.description,
poster
})
} else if (coverPickerMode === 'edit' && coverPickerTarget) {
editVideo(
{
name: coverPickerTarget.content.name,
identifier: coverPickerTarget.content.identifier,
service: coverPickerTarget.content.service,
title: coverPickerTarget.content.title,
description: coverPickerTarget.content.description,
poster
},
coverPickerTarget
)
}
setPendingVideo(null)
setCoverPickerTarget(null)
setSelectedPoster('')
setCoverPickerOpen(false)
}}
>
{coverPickerMode === 'add' ? 'Add video' : 'Update cover'}
</Button>
</Box>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -9,7 +9,7 @@ import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'
import ImageUploader from '../../components/common/ImageUploader'
import AudiotrackIcon from '@mui/icons-material/Audiotrack'
import AddCircleRoundedIcon from '@mui/icons-material/AddCircleRounded'
import { Button, Box, useTheme } from '@mui/material'
import { Button, Box, useTheme, Dialog, DialogTitle, DialogContent, Tabs, Tab, Typography } from '@mui/material'
import { styled } from '@mui/system'
import { Descendant } from 'slate'
import EditIcon from '@mui/icons-material/Edit'
@@ -128,7 +128,43 @@ export const CreatePostMinimal = ({
React.useState<boolean>(false)
const [paddingValue, onChangePadding] = React.useState(5)
const [coverPickerOpen, setCoverPickerOpen] = React.useState(false)
const [coverTab, setCoverTab] = React.useState<'upload' | 'existing'>('upload')
const [pendingVideo, setPendingVideo] = React.useState<any | null>(null)
const [selectedPoster, setSelectedPoster] = React.useState<string>('')
const [existingImages, setExistingImages] = React.useState<any[]>([])
const [existingImagesLoading, setExistingImagesLoading] = React.useState(false)
const [coverPickerMode, setCoverPickerMode] = React.useState<'add' | 'edit'>('add')
const [coverPickerTarget, setCoverPickerTarget] = React.useState<any | null>(null)
const dispatch = useDispatch()
const qdnResourceUrl = React.useCallback(
(service: string, name: string, identifier: string) => `/arbitrary/${service}/${name}/${identifier}`,
[]
)
const fetchExistingImages = React.useCallback(async () => {
if (!user?.name) return
setExistingImagesLoading(true)
try {
const SERVICES = ['IMAGE', 'THUMBNAIL', 'QCHAT_IMAGE']
const lists = await Promise.all(
SERVICES.map(async (svc) => {
const res = await fetch(
`/arbitrary/resources?service=${svc}&name=${user.name}&includemetadata=true&limit=100&offset=0&reverse=true`
)
const data = await res.json()
return Array.isArray(data)
? data.map((it: any) => ({ ...it, service: it.service || svc }))
: []
})
)
const merged = lists.flat()
setExistingImages(merged)
} catch (e) {
setExistingImages([])
} finally {
setExistingImagesLoading(false)
}
}, [user])
const addPostSection = React.useCallback((content: any) => {
const id = uid()
const type = 'editor'
@@ -606,13 +642,15 @@ export const CreatePostMinimal = ({
title: string
description: string
mimeType?: string
poster?: string
}
const addVideo = ({
name,
identifier,
service,
title,
description
description,
poster
}: IaddVideo) => {
const id = uid()
const type = 'video'
@@ -624,7 +662,8 @@ export const CreatePostMinimal = ({
identifier: identifier,
service: service,
title,
description
description,
...(poster ? { poster } : {})
},
id
}
@@ -767,18 +806,26 @@ export const CreatePostMinimal = ({
}
}
const editVideo = (
{ name, identifier, service, description, title }: IaddVideo,
{ name, identifier, service, description, title, poster }: IaddVideo,
section: any
) => {
const nextContent: any = {
name,
identifier,
service,
description,
title
}
if (poster === undefined) {
if (typeof section.content?.poster !== 'undefined') {
nextContent.poster = section.content.poster
}
} else if (poster) {
nextContent.poster = poster
}
const newSection = {
...section,
content: {
name: name,
identifier: identifier,
service: service,
description,
title
}
content: nextContent
}
const findSectionIndex = newPostContent.findIndex(
(s) => s.id === section.id
@@ -845,14 +892,14 @@ export const CreatePostMinimal = ({
)
const onSelectVideo = React.useCallback((video: any) => {
addVideo({
name: video.name,
identifier: video.identifier,
service: video.service,
title: video?.metadata?.title,
description: video?.metadata?.description
})
}, [])
setCoverPickerMode('add')
setCoverPickerTarget(null)
setPendingVideo(video)
setSelectedPoster('')
setCoverTab('upload')
setCoverPickerOpen(true)
fetchExistingImages()
}, [fetchExistingImages])
const onSelectAudio = React.useCallback((video: any) => {
addAudio({
@@ -1149,6 +1196,7 @@ export const CreatePostMinimal = ({
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
poster={section.content.poster}
from="create"
customStyle={{
height: '50vh'
@@ -1179,6 +1227,18 @@ export const CreatePostMinimal = ({
)
}
/>
<AddPhotoAlternateIcon
onClick={() => {
setCoverPickerMode('edit')
setCoverPickerTarget(section)
setPendingVideo(null)
setSelectedPoster(section.content?.poster || '')
setCoverTab('upload')
setCoverPickerOpen(true)
fetchExistingImages()
}}
sx={{ cursor: 'pointer', height: '18px', width: 'auto' }}
/>
</EditButtons>
</Box>
</DynamicHeightItemMinimal>
@@ -1385,6 +1445,121 @@ export const CreatePostMinimal = ({
/>
)}
</Box>
<Dialog open={coverPickerOpen} onClose={() => setCoverPickerOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Select a cover image</DialogTitle>
<DialogContent>
<Tabs value={coverTab} onChange={(_, v) => setCoverTab(v)}>
<Tab value="upload" label="Upload new" />
<Tab value="existing" label="Choose from published" />
</Tabs>
{coverTab === 'upload' ? (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
Pick an image file. It will be embedded in this post as a data URL.
</Typography>
<ImageUploader onPick={(base64) => setSelectedPoster(base64)}>
<Button variant="contained">Upload image</Button>
</ImageUploader>
{selectedPoster && (
<Box sx={{ mt: 2 }}>
<img src={selectedPoster} style={{ maxWidth: '100%', maxHeight: 240, objectFit: 'contain' }} />
</Box>
)}
</Box>
) : (
<Box sx={{ mt: 2, display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1 }}>
{existingImages.map((img: any) => {
const service = img.service || 'IMAGE'
const url = qdnResourceUrl(service, img.name, img.identifier)
const isSelected = selectedPoster === url
return (
<Box
key={`${service}:${img.name}:${img.identifier}`}
onClick={() => setSelectedPoster(url)}
sx={{
border: isSelected ? '2px solid #1976d2' : '1px solid rgba(0,0,0,0.2)',
borderRadius: 1,
p: 0.5,
cursor: 'pointer'
}}
>
<img src={url} style={{ width: '100%', height: 100, objectFit: 'cover' }} />
<Typography variant="caption" noWrap title={img?.metadata?.title || img.identifier}>
{img?.metadata?.title || img.identifier}
</Typography>
</Box>
)
})}
{existingImagesLoading && (
<Typography variant="body2">Loading...</Typography>
)}
{!existingImagesLoading && !existingImages.length && (
<Typography variant="body2">No published images found.</Typography>
)}
</Box>
)}
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button onClick={() => setCoverPickerOpen(false)}>Cancel</Button>
<Button
onClick={() => {
if (coverPickerMode === 'edit' && coverPickerTarget) {
editVideo(
{
name: coverPickerTarget.content.name,
identifier: coverPickerTarget.content.identifier,
service: coverPickerTarget.content.service,
title: coverPickerTarget.content.title,
description: coverPickerTarget.content.description,
poster: ''
},
coverPickerTarget
)
setSelectedPoster('')
setCoverPickerOpen(false)
} else {
setSelectedPoster('')
}
}}
>
Clear cover
</Button>
<Button
variant="contained"
disabled={coverPickerMode === 'add' && !pendingVideo}
onClick={() => {
const poster = selectedPoster || undefined
if (coverPickerMode === 'add' && pendingVideo) {
addVideo({
name: pendingVideo.name,
identifier: pendingVideo.identifier,
service: pendingVideo.service,
title: pendingVideo?.metadata?.title,
description: pendingVideo?.metadata?.description,
poster
})
} else if (coverPickerMode === 'edit' && coverPickerTarget) {
editVideo(
{
name: coverPickerTarget.content.name,
identifier: coverPickerTarget.content.identifier,
service: coverPickerTarget.content.service,
title: coverPickerTarget.content.title,
description: coverPickerTarget.content.description,
poster
},
coverPickerTarget
)
}
setPendingVideo(null)
setCoverPickerTarget(null)
setCoverPickerOpen(false)
}}
>
{coverPickerMode === 'add' ? 'Add video' : 'Update cover'}
</Button>
</Box>
</DialogContent>
</Dialog>
</>
)
}