forked from Qortal/q-blog
Add video cover image support to CreatePostBuilder & CreatePostMinimal
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user