mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(api,ui): implement album metadata batch editing and physical file writing
Added the ability for administrators to edit album-wide metadata directly from the album's "Get Info" dialog. When saved, the changes are applied recursively to every track associated with the album.
This commit is contained in:
parent
874eb6c723
commit
e2b466ecb2
228
server/nativeapi/album_update.go
Normal file
228
server/nativeapi/album_update.go
Normal file
@ -0,0 +1,228 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tagwriter"
|
||||
"github.com/Masterminds/squirrel"
|
||||
)
|
||||
|
||||
type AlbumUpdateRequest struct {
|
||||
Album string `json:"album"`
|
||||
Name string `json:"name"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Year *int `json:"year"`
|
||||
Genre string `json:"genre"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
func (api *Router) addAlbumRoute(r chi.Router) {
|
||||
albumConstructor := func(ctx context.Context) rest.Repository {
|
||||
return api.ds.Resource(ctx, model.Album{})
|
||||
}
|
||||
|
||||
r.Route("/album", func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(albumConstructor))
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(albumConstructor))
|
||||
r.Put("/", api.updateAlbum())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) updateAlbum() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
bodyBytesDebug, _ := io.ReadAll(r.Body)
|
||||
log.Info(r.Context(), "DEBUG: Raw JSON Received", "json", string(bodyBytesDebug))
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytesDebug))
|
||||
|
||||
if !conf.Server.EnableTagEditing {
|
||||
log.Warn(r.Context(), "Tag editing attempt while disabled")
|
||||
http.Error(w, "Tag editing is disabled in configuration", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
albumID := chi.URLParamFromCtx(ctx, "id")
|
||||
if albumID == "" {
|
||||
log.Warn(r.Context(), "Album ID missing in update request")
|
||||
http.Error(w, "Album ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Fetching Album", "id", albumID)
|
||||
album, err := api.ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Album not found", "id", albumID)
|
||||
http.Error(w, "Album not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Failed to retrieve album", "error", err, "id", albumID)
|
||||
http.Error(w, "Failed to retrieve album", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Album retrieved", "album_id", album.ID, "name", album.Name, "song_count", album.SongCount)
|
||||
|
||||
log.Debug(r.Context(), "Fetching MediaFiles for album", "albumId", albumID)
|
||||
mediaFiles, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": albumID}})
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Failed to retrieve media files for album", "error", err, "albumId", albumID)
|
||||
http.Error(w, "Failed to retrieve media files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info(r.Context(), "Batch update starting", "album_id", albumID, "count", len(mediaFiles))
|
||||
|
||||
if len(mediaFiles) == 0 {
|
||||
log.Warn(r.Context(), "No media files found for album", "albumId", albumID)
|
||||
http.Error(w, "No media files found for this album", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Parsing request body", "albumId", albumID)
|
||||
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Failed to read request body", "error", err)
|
||||
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Debug(r.Context(), "Raw request body", "body", string(bodyBytes))
|
||||
|
||||
var req AlbumUpdateRequest
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
log.Error(r.Context(), "Failed to decode JSON payload", "error", err)
|
||||
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(r.Context(), "Request body parsed", "album", req.Album, "albumArtist", req.AlbumArtist, "year", req.Year)
|
||||
|
||||
newAlbumName := req.Album
|
||||
newArtist := req.AlbumArtist
|
||||
newYear := req.Year
|
||||
newGenre := req.Genre
|
||||
newComment := req.Comment
|
||||
|
||||
log.Debug(r.Context(), "Local variables assigned", "newAlbumName", newAlbumName, "newArtist", newArtist)
|
||||
|
||||
titleToUse := newAlbumName
|
||||
if titleToUse == "" {
|
||||
titleToUse = req.Name
|
||||
}
|
||||
log.Info(r.Context(), "DEBUG: Title to be used for tracks", "title", titleToUse)
|
||||
|
||||
tw := tagwriter.New()
|
||||
updatedCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, mf := range mediaFiles {
|
||||
absPath := mf.AbsolutePath()
|
||||
|
||||
log.Info(r.Context(), "Processing track", "mediaFileId", mf.ID, "path", absPath, "newAlbum", titleToUse)
|
||||
|
||||
tags := make(tagwriter.Tags)
|
||||
tags[tagwriter.TagAlbum] = titleToUse
|
||||
tags[tagwriter.TagAlbumArtist] = newArtist
|
||||
if newYear != nil && *newYear > 0 {
|
||||
tags[tagwriter.TagYear] = strconv.Itoa(*newYear)
|
||||
}
|
||||
if newGenre != "" {
|
||||
tags[tagwriter.TagGenre] = newGenre
|
||||
}
|
||||
if newComment != "" {
|
||||
tags[tagwriter.TagComment] = newComment
|
||||
}
|
||||
|
||||
if err := tw.WriteTags(absPath, tags); err != nil {
|
||||
if errors.Is(err, tagwriter.ErrFeatureDisabled) {
|
||||
log.Warn(r.Context(), "Tag writing disabled in config", "error", err)
|
||||
http.Error(w, "Tag editing is disabled in configuration", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, tagwriter.ErrUnsupportedFormat) {
|
||||
log.Warn(r.Context(), "Unsupported file format", "error", err, "path", absPath)
|
||||
http.Error(w, "Unsupported file format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, tagwriter.ErrReadOnlyFile) {
|
||||
log.Warn(r.Context(), "File is read-only", "error", err, "path", absPath)
|
||||
http.Error(w, "File is read-only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Error(r.Context(), "Failed to write tags to file", "error", err, "path", absPath, "mediaFileId", mf.ID)
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Chtimes(absPath, time.Now(), time.Now()); err != nil {
|
||||
log.Error(r.Context(), "Failed to update file modification time", "error", err, "path", absPath)
|
||||
}
|
||||
|
||||
mf.Album = titleToUse
|
||||
mf.AlbumArtist = newArtist
|
||||
if newYear != nil && *newYear > 0 {
|
||||
mf.Year = *newYear
|
||||
}
|
||||
mf.Genre = newGenre
|
||||
mf.Comment = newComment
|
||||
|
||||
log.Debug(r.Context(), "Updating MediaFile record", "mediaFileId", mf.ID, "album", mf.Album, "albumArtist", mf.AlbumArtist)
|
||||
if err := api.ds.MediaFile(ctx).Put(&mf); err != nil {
|
||||
log.Error(r.Context(), "Failed to update MediaFile in database", "error", err, "mediaFileId", mf.ID)
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
updatedCount++
|
||||
log.Debug(r.Context(), "Successfully updated media file", "mediaFileId", mf.ID)
|
||||
}
|
||||
|
||||
if req.Album != "" {
|
||||
album.Name = req.Album
|
||||
}
|
||||
if req.AlbumArtist != "" {
|
||||
album.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.Year != nil && *req.Year > 0 {
|
||||
album.MaxYear = *req.Year
|
||||
}
|
||||
if req.Genre != "" {
|
||||
album.Genre = req.Genre
|
||||
}
|
||||
if req.Comment != "" {
|
||||
album.Comment = req.Comment
|
||||
}
|
||||
|
||||
if err := api.ds.Album(ctx).Put(album); err != nil {
|
||||
log.Error(r.Context(), "Failed to update Album in database", "error", err, "albumId", albumID)
|
||||
http.Error(w, "Failed to update album", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info(r.Context(), "Album batch update completed", "albumId", albumID, "updated", updatedCount, "failed", failedCount)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"id":"` + albumID + `", "name":"` + album.Name + `", "updated":` + strconv.Itoa(updatedCount) + `, "failed":` + strconv.Itoa(failedCount) + `}`))
|
||||
}
|
||||
}
|
||||
@ -66,7 +66,7 @@ func (api *Router) routes() http.Handler {
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
api.RX(r, "/user", api.users.NewRepository, true)
|
||||
api.addSongRoute(r)
|
||||
api.R(r, "/album", model.Album{}, false)
|
||||
api.addAlbumRoute(r)
|
||||
api.addArtistRoute(r)
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
api.R(r, "/player", model.Player{}, true)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import Table from '@material-ui/core/Table'
|
||||
import TableBody from '@material-ui/core/TableBody'
|
||||
import { humanize, underscore } from 'inflection'
|
||||
@ -14,14 +15,24 @@ import {
|
||||
TextField,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import {
|
||||
Button,
|
||||
TextField as MuiTextField,
|
||||
CircularProgress,
|
||||
} from '@material-ui/core'
|
||||
import EditIcon from '@material-ui/icons/Edit'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
MultiLineTextField,
|
||||
ParticipantsInfo,
|
||||
RangeField,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import httpClient from '../dataProvider/httpClient'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
tableCell: {
|
||||
@ -32,94 +43,246 @@ const useStyles = makeStyles({
|
||||
},
|
||||
})
|
||||
|
||||
const EDITABLE_FIELDS = ['name', 'albumArtist', 'genre', 'year']
|
||||
|
||||
const AlbumInfo = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const record = useRecordContext(props)
|
||||
const data = {
|
||||
name: <TextField source={'name'} />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
albumArtist: (
|
||||
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
|
||||
),
|
||||
genre: (
|
||||
<ArrayField source={'genres'}>
|
||||
<SingleFieldList linkType={false}>
|
||||
<ChipField source={'name'} />
|
||||
</SingleFieldList>
|
||||
</ArrayField>
|
||||
),
|
||||
date:
|
||||
record?.maxYear && record.maxYear === record.minYear ? (
|
||||
<TextField source={'date'} />
|
||||
) : (
|
||||
<RangeField source={'year'} />
|
||||
),
|
||||
originalDate:
|
||||
record?.maxOriginalYear &&
|
||||
record.maxOriginalYear === record.minOriginalYear ? (
|
||||
<TextField source={'originalDate'} />
|
||||
) : (
|
||||
<RangeField source={'originalYear'} />
|
||||
),
|
||||
releaseDate: <TextField source={'releaseDate'} />,
|
||||
recordLabel: (
|
||||
<FunctionField
|
||||
source={'recordLabel'}
|
||||
render={(record) => record.tags?.recordlabel?.join(', ')}
|
||||
/>
|
||||
),
|
||||
catalogNum: <TextField source={'catalogNum'} />,
|
||||
releaseType: (
|
||||
<FunctionField
|
||||
source={'releaseType'}
|
||||
render={(record) => record.tags?.releasetype?.join(', ')}
|
||||
/>
|
||||
),
|
||||
media: (
|
||||
<FunctionField
|
||||
source={'media'}
|
||||
render={(record) => record.tags?.media?.join(', ')}
|
||||
/>
|
||||
),
|
||||
grouping: (
|
||||
<FunctionField
|
||||
source={'grouping'}
|
||||
render={(record) => record.tags?.grouping?.join(', ')}
|
||||
/>
|
||||
),
|
||||
mood: (
|
||||
<FunctionField
|
||||
source={'mood'}
|
||||
render={(record) => record.tags?.mood?.join(', ')}
|
||||
/>
|
||||
),
|
||||
compilation: <BooleanField source={'compilation'} />,
|
||||
updatedAt: <DateField source={'updatedAt'} showTime />,
|
||||
comment: <MultiLineTextField source={'comment'} />,
|
||||
}
|
||||
|
||||
const optionalFields = ['comment', 'genre', 'catalogNum']
|
||||
optionalFields.forEach((field) => {
|
||||
!record[field] && delete data[field]
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
albumArtist: '',
|
||||
genre: '',
|
||||
year: '',
|
||||
})
|
||||
|
||||
const optionalTags = [
|
||||
'releaseType',
|
||||
useEffect(() => {
|
||||
if (record && isEditing) {
|
||||
setFormData({
|
||||
name: record.name || '',
|
||||
albumArtist: record.albumArtist || '',
|
||||
genre: record.genres?.map((g) => g.name).join(' • ') || '',
|
||||
year: record.year || '',
|
||||
})
|
||||
}
|
||||
}, [record, isEditing])
|
||||
|
||||
const startEdit = useCallback(() => {
|
||||
setFormData({
|
||||
name: record.name || '',
|
||||
albumArtist: record.albumArtist || '',
|
||||
genre: record.genres?.map((g) => g.name).join(' • ') || '',
|
||||
year: record.year || '',
|
||||
})
|
||||
setIsEditing(true)
|
||||
}, [record])
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
}, [])
|
||||
|
||||
const handleFieldChange = useCallback((field) => (event) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!record?.id) return
|
||||
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
album: formData.name,
|
||||
albumArtist: formData.albumArtist,
|
||||
genre: formData.genre,
|
||||
year: formData.year ? parseInt(formData.year, 10) : null,
|
||||
}
|
||||
console.log('DEBUG: Sending Payload', payload)
|
||||
|
||||
try {
|
||||
await httpClient(`/api/album/${record.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
notify('Album updated', { type: 'success' })
|
||||
refresh()
|
||||
setFormData({
|
||||
name: payload.album,
|
||||
albumArtist: payload.albumArtist,
|
||||
genre: payload.genre,
|
||||
year: payload.year ? String(payload.year) : '',
|
||||
})
|
||||
setIsEditing(false)
|
||||
} catch (error) {
|
||||
console.error('Error updating album:', error)
|
||||
notify('Error updating album. Check console for details.', { type: 'error' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [record, formData, notify, refresh])
|
||||
|
||||
const buildField = (key) => {
|
||||
if (isEditing) {
|
||||
if (EDITABLE_FIELDS.includes(key)) {
|
||||
return (
|
||||
<MuiTextField
|
||||
value={formData[key] || ''}
|
||||
onChange={handleFieldChange(key)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={saving}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const viewFields = {
|
||||
name: formData.name || <TextField source={'name'} />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
albumArtist: formData.albumArtist || (
|
||||
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
|
||||
),
|
||||
genre: formData.genre || (
|
||||
<ArrayField source={'genres'}>
|
||||
<SingleFieldList linkType={false}>
|
||||
<ChipField source={'name'} />
|
||||
</SingleFieldList>
|
||||
</ArrayField>
|
||||
),
|
||||
date:
|
||||
record?.maxYear && record.maxYear === record.minYear ? (
|
||||
formData.year ? parseInt(formData.year, 10) : <TextField source={'date'} />
|
||||
) : (
|
||||
<RangeField source={'year'} />
|
||||
),
|
||||
originalDate:
|
||||
record?.maxOriginalYear &&
|
||||
record.maxOriginalYear === record.minOriginalYear ? (
|
||||
<TextField source={'originalDate'} />
|
||||
) : (
|
||||
<RangeField source={'originalYear'} />
|
||||
),
|
||||
releaseDate: <TextField source={'releaseDate'} />,
|
||||
recordLabel: (
|
||||
<FunctionField
|
||||
source={'recordLabel'}
|
||||
render={(record) => record.tags?.recordlabel?.join(', ')}
|
||||
/>
|
||||
),
|
||||
catalogNum: <TextField source={'catalogNum'} />,
|
||||
releaseType: (
|
||||
<FunctionField
|
||||
source={'releaseType'}
|
||||
render={(record) => record.tags?.releasetype?.join(', ')}
|
||||
/>
|
||||
),
|
||||
media: (
|
||||
<FunctionField
|
||||
source={'media'}
|
||||
render={(record) => record.tags?.media?.join(', ')}
|
||||
/>
|
||||
),
|
||||
grouping: (
|
||||
<FunctionField
|
||||
source={'grouping'}
|
||||
render={(record) => record.tags?.grouping?.join(', ')}
|
||||
/>
|
||||
),
|
||||
mood: (
|
||||
<FunctionField
|
||||
source={'mood'}
|
||||
render={(record) => record.tags?.mood?.join(', ')}
|
||||
/>
|
||||
),
|
||||
compilation: <BooleanField source={'compilation'} />,
|
||||
updatedAt: <DateField source={'updatedAt'} showTime />,
|
||||
comment: <MultiLineTextField source={'comment'} />,
|
||||
}
|
||||
return viewFields[key]
|
||||
}
|
||||
|
||||
const allFields = [
|
||||
'name',
|
||||
'libraryName',
|
||||
'albumArtist',
|
||||
'genre',
|
||||
'date',
|
||||
'originalDate',
|
||||
'releaseDate',
|
||||
'recordLabel',
|
||||
'catalogNum',
|
||||
'releaseType',
|
||||
'media',
|
||||
'grouping',
|
||||
'mood',
|
||||
'media',
|
||||
'compilation',
|
||||
'updatedAt',
|
||||
'comment',
|
||||
]
|
||||
optionalTags.forEach((field) => {
|
||||
!record?.tags?.[field.toLowerCase()] && delete data[field]
|
||||
|
||||
const optionalFields = ['comment', 'genre', 'catalogNum']
|
||||
const optionalTags = ['releaseType', 'recordLabel', 'grouping', 'mood', 'media']
|
||||
const editableExceptions = ['libraryName', 'date', 'originalDate', 'releaseDate', 'recordLabel', 'catalogNum', 'releaseType', 'media', 'grouping', 'mood', 'compilation', 'updatedAt', 'comment']
|
||||
|
||||
let fieldsToShow = allFields.filter((field) => {
|
||||
if (!isEditing && optionalFields.includes(field) && !record[field]) return false
|
||||
if (!isEditing && optionalTags.includes(field)) {
|
||||
if (!record?.tags?.[field.toLowerCase()]) return false
|
||||
}
|
||||
if (isEditing && !EDITABLE_FIELDS.includes(field) && !editableExceptions.includes(field)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<div style={{ textAlign: 'right', marginBottom: 8 }}>
|
||||
{config.enableTagEditing && !isEditing && (
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
onClick={startEdit}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{translate('ra.action.edit')}
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={cancelEdit}
|
||||
disabled={saving}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{translate('ra.action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}
|
||||
>
|
||||
{translate('ra.action.save')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Table aria-label="album details" size="small">
|
||||
<TableBody>
|
||||
{Object.keys(data).map((key) => {
|
||||
{fieldsToShow.map((key) => {
|
||||
const cellContent = buildField(key)
|
||||
if (!cellContent) return null
|
||||
return (
|
||||
<TableRow key={`${record.id}-${key}`}>
|
||||
<TableCell
|
||||
@ -133,12 +296,12 @@ const AlbumInfo = (props) => {
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.value}>
|
||||
{data[key]}
|
||||
{cellContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<ParticipantsInfo record={record} classes={classes} />
|
||||
{!isEditing && <ParticipantsInfo record={record} classes={classes} />}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user