updated library and settings page

This commit is contained in:
2026-05-03 21:03:02 -07:00
parent d58686bce9
commit 38e825cf28
2 changed files with 269 additions and 126 deletions
+200 -86
View File
@@ -76,6 +76,14 @@ type SortOption = {
label: string label: string
} }
type TrackNamespacePresentation = {
trackLabel: string
primaryLabel: string
secondaryLabel: string
primaryNamespace: string
secondaryNamespace: string
}
const SECTION_CONFIG: Record<MediaSection, { label: string; systemPredicate?: string; views: Array<{ id: LibraryView; label: string }> }> = { const SECTION_CONFIG: Record<MediaSection, { label: string; systemPredicate?: string; views: Array<{ id: LibraryView; label: string }> }> = {
all: { all: {
label: 'All Files', label: 'All Files',
@@ -96,9 +104,9 @@ const SECTION_CONFIG: Record<MediaSection, { label: string; systemPredicate?: st
label: 'Video', label: 'Video',
systemPredicate: 'system:filetype = video', systemPredicate: 'system:filetype = video',
views: [ views: [
{ id: 'tracks', label: 'Videos' }, { id: 'artists', label: 'Series' },
{ id: 'albums', label: 'Albums' }, { id: 'albums', label: 'Season' },
{ id: 'artists', label: 'Artists' }, { id: 'tracks', label: 'Episode' },
], ],
}, },
image: { image: {
@@ -126,14 +134,6 @@ type AlbumEntry = {
totalCount: number totalCount: number
} }
const TRACK_SORT_OPTIONS: SortOption[] = [
{ id: 'artist', label: 'Artist' },
{ id: 'album', label: 'Album' },
{ id: 'title', label: 'Title' },
{ id: 'server', label: 'Server' },
{ id: 'fileId', label: 'File ID' },
]
const ENTRY_SORT_OPTIONS: SortOption[] = [ const ENTRY_SORT_OPTIONS: SortOption[] = [
{ id: 'name', label: 'Name' }, { id: 'name', label: 'Name' },
{ id: 'count', label: 'Items' }, { id: 'count', label: 'Items' },
@@ -143,25 +143,93 @@ function compareText(left?: string | null, right?: string | null) {
return (left || '').localeCompare((right || ''), undefined, { numeric: true, sensitivity: 'base' }) return (left || '').localeCompare((right || ''), undefined, { numeric: true, sensitivity: 'base' })
} }
function getTrackNamespacePresentation(section: MediaSection): TrackNamespacePresentation {
if (section === 'video') {
return {
trackLabel: 'Episode',
primaryLabel: 'Series',
secondaryLabel: 'Season',
primaryNamespace: 'series',
secondaryNamespace: 'season',
}
}
return {
trackLabel: 'Track',
primaryLabel: 'Artist',
secondaryLabel: 'Album',
primaryNamespace: 'artist',
secondaryNamespace: 'album',
}
}
function extractNamespaceValue(tags: string[] | null | undefined, namespace: string): string | null {
if (!tags || !Array.isArray(tags)) return null
const prefix = `${namespace.toLowerCase()}:`
let bestValue = ''
for (const tag of tags) {
if (typeof tag !== 'string') continue
if (!tag.toLowerCase().startsWith(prefix)) continue
const value = tag.slice(prefix.length).replace(/_/g, ' ').trim()
if (value.length > bestValue.length) bestValue = value
}
return bestValue || null
}
function deriveTrackPrimaryValue(track: Track, section: MediaSection) {
if (section === 'video') return extractNamespaceValue(track.tags, 'series') || track.artist || null
return track.artist || null
}
function deriveTrackSecondaryValue(track: Track, section: MediaSection) {
if (section === 'video') return extractNamespaceValue(track.tags, 'season') || track.album || null
return track.album || null
}
function getTrackSortOptions(section: MediaSection): SortOption[] {
const namespacePresentation = getTrackNamespacePresentation(section)
return [
{ id: 'artist', label: namespacePresentation.primaryLabel },
{ id: 'album', label: namespacePresentation.secondaryLabel },
{ id: 'title', label: namespacePresentation.trackLabel },
{ id: 'server', label: 'Server' },
{ id: 'fileId', label: 'File ID' },
]
}
function getTrackDisplayTitle(track: Track) { function getTrackDisplayTitle(track: Track) {
return track.title?.trim() || (track.fileId != null ? `File ${track.fileId}` : 'Untitled') return track.title?.trim() || (track.fileId != null ? `File ${track.fileId}` : 'Untitled')
} }
function sortTracks(tracks: Track[], sortBy: TrackSortField, sortDirection: SortDirection) { function sortTracks(
tracks: Track[],
sortBy: TrackSortField,
sortDirection: SortDirection,
options?: {
getPrimaryValue?: (track: Track) => string | null | undefined
getSecondaryValue?: (track: Track) => string | null | undefined
}
) {
const direction = sortDirection === 'asc' ? 1 : -1 const direction = sortDirection === 'asc' ? 1 : -1
const getPrimaryValue = options?.getPrimaryValue || ((track: Track) => track.artist)
const getSecondaryValue = options?.getSecondaryValue || ((track: Track) => track.album)
return [...tracks].sort((left, right) => { return [...tracks].sort((left, right) => {
let comparison = 0 let comparison = 0
switch (sortBy) { switch (sortBy) {
case 'artist': case 'artist':
comparison = compareText(left.artist, right.artist) comparison = compareText(getPrimaryValue(left), getPrimaryValue(right))
if (comparison === 0) comparison = compareText(left.album, right.album) if (comparison === 0) comparison = compareText(getSecondaryValue(left), getSecondaryValue(right))
if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right)) if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right))
break break
case 'album': case 'album':
comparison = compareText(left.album, right.album) comparison = compareText(getSecondaryValue(left), getSecondaryValue(right))
if (comparison === 0) comparison = compareText(left.artist, right.artist) if (comparison === 0) comparison = compareText(getPrimaryValue(left), getPrimaryValue(right))
if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right)) if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right))
break break
case 'server': case 'server':
@@ -175,7 +243,7 @@ function sortTracks(tracks: Track[], sortBy: TrackSortField, sortDirection: Sort
case 'title': case 'title':
default: default:
comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right)) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right))
if (comparison === 0) comparison = compareText(left.artist, right.artist) if (comparison === 0) comparison = compareText(getPrimaryValue(left), getPrimaryValue(right))
break break
} }
@@ -325,28 +393,38 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
const [detailsError, setDetailsError] = useState<string | null>(null) const [detailsError, setDetailsError] = useState<string | null>(null)
const syncingSectionViewRef = useRef(false) const syncingSectionViewRef = useRef(false)
const sectionConfig = SECTION_CONFIG[mediaSection] const sectionConfig = SECTION_CONFIG[mediaSection]
const namespacePresentation = getTrackNamespacePresentation(mediaSection)
const isAllSection = mediaSection === 'all' const isAllSection = mediaSection === 'all'
const isTrackLikeView = view === 'tracks' || view === 'text' || view === 'data' const isTrackLikeView = view === 'tracks' || view === 'text' || view === 'data'
const hasAlbumsView = sectionConfig.views.some((item) => item.id === 'albums') const hasAlbumsView = sectionConfig.views.some((item) => item.id === 'albums')
const hasArtistsView = sectionConfig.views.some((item) => item.id === 'artists') const hasArtistsView = sectionConfig.views.some((item) => item.id === 'artists')
const effectiveDisplayMode: DisplayMode = isAllSection ? 'table' : displayModePreference const effectiveDisplayMode: DisplayMode = isAllSection ? 'table' : displayModePreference
const sortOptions = useMemo(() => isTrackLikeView ? TRACK_SORT_OPTIONS : ENTRY_SORT_OPTIONS, [isTrackLikeView]) const sortOptions = useMemo(() => isTrackLikeView ? getTrackSortOptions(mediaSection) : ENTRY_SORT_OPTIONS, [isTrackLikeView, mediaSection])
const detailsTrackDownloading = !!detailsTrack && isTrackDownloading(detailsTrack) const detailsTrackDownloading = !!detailsTrack && isTrackDownloading(detailsTrack)
const trackNamespaceValues = useMemo(() => {
const values = new Map<string, { primary: string | null; secondary: string | null }>()
function extractNamespaceValue(tags: string[] | null | undefined, ns: string): string | null { for (const track of results) {
if (!tags || !Array.isArray(tags)) return null const cacheKey = `${track.serverId || 'local'}:${track.fileId ?? track.id}`
const prefix = `${ns.toLowerCase()}:` values.set(cacheKey, {
let bestValue = '' primary: deriveTrackPrimaryValue(track, mediaSection),
secondary: deriveTrackSecondaryValue(track, mediaSection),
for (const tag of tags) { })
if (typeof tag !== 'string') continue
if (!tag.toLowerCase().startsWith(prefix)) continue
const value = tag.slice(prefix.length).replace(/_/g, ' ').trim()
if (value.length > bestValue.length) bestValue = value
} }
return bestValue || null return values
}, [mediaSection, results])
function getTrackNamespaceCacheKey(track: Track) {
return `${track.serverId || 'local'}:${track.fileId ?? track.id}`
}
function getTrackPrimaryValue(track: Track) {
return trackNamespaceValues.get(getTrackNamespaceCacheKey(track))?.primary || null
}
function getTrackSecondaryValue(track: Track) {
return trackNamespaceValues.get(getTrackNamespaceCacheKey(track))?.secondary || null
} }
function matchesMediaSection(track: Track, section: MediaSection) { function matchesMediaSection(track: Track, section: MediaSection) {
@@ -584,7 +662,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return /[,"]/.test(trimmed) ? `"${escaped}"` : escaped return /[,"]/.test(trimmed) ? `"${escaped}"` : escaped
} }
function getNamespacedQueryValue(rawQuery: string, namespace: 'album' | 'artist') { function getNamespacedQueryValue(rawQuery: string, namespace: string) {
const clauses = splitSearchClauses(rawQuery) const clauses = splitSearchClauses(rawQuery)
for (let index = clauses.length - 1; index >= 0; index -= 1) { for (let index = clauses.length - 1; index >= 0; index -= 1) {
@@ -599,18 +677,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return null return null
} }
function applyLibraryQueryFilter(namespace: 'album' | 'artist', value: string) { function applyLibraryQueryFilters(updates: Record<string, string | null | undefined>) {
const formattedValue = formatNamespacedSearchValue(value)
const clauses = splitSearchClauses(query) const clauses = splitSearchClauses(query)
const targetNamespaces = new Set(Object.keys(updates).map((namespace) => namespace.toLowerCase()))
const retainedClauses = clauses.filter((clause) => { const retainedClauses = clauses.filter((clause) => {
const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN) const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN)
if (!namespaceMatch) return true if (!namespaceMatch) return true
const clauseNamespace = namespaceMatch[1].toLowerCase() const clauseNamespace = namespaceMatch[1].toLowerCase()
return clauseNamespace !== 'album' && clauseNamespace !== 'artist' return !targetNamespaces.has(clauseNamespace)
}) })
const nextQuery = [...retainedClauses, `${namespace}:${formattedValue}`].join(', ') const nextClauses = [...retainedClauses]
for (const [namespace, value] of Object.entries(updates)) {
const trimmed = value?.trim()
if (!trimmed) continue
nextClauses.push(`${namespace}:${formatNamespacedSearchValue(trimmed)}`)
}
const nextQuery = nextClauses.join(', ')
onQueryChange(nextQuery) onQueryChange(nextQuery)
} }
@@ -655,11 +741,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}, 250) }, 250)
} }
function buildNamespaceEntriesFromTracks(tracks: Track[], ns: 'album' | 'artist') { function buildNamespaceEntriesFromTracks(tracks: Track[], getName: (track: Track) => string | null | undefined) {
const entries: Record<string, AlbumEntry> = {} const entries: Record<string, AlbumEntry> = {}
for (const track of tracks) { for (const track of tracks) {
const name = ns === 'album' ? track.album : track.artist const name = getName(track)
if (!name) continue if (!name) continue
if (!entries[name]) { if (!entries[name]) {
@@ -683,7 +769,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return Object.values(entries).sort((a, b) => a.name.localeCompare(b.name)) return Object.values(entries).sort((a, b) => a.name.localeCompare(b.name))
} }
function buildArtistGroupsFromTracks(tracks: Track[]) { function buildTrackGroupsFromTracks(
tracks: Track[],
getGroupName: (track: Track) => string | null | undefined,
getSubgroupName: (track: Track) => string | null | undefined,
) {
const groupsMap: Record<string, Track[]> = {} const groupsMap: Record<string, Track[]> = {}
const seen = new Set<string>() const seen = new Set<string>()
@@ -692,17 +782,17 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (seen.has(trackKey)) continue if (seen.has(trackKey)) continue
seen.add(trackKey) seen.add(trackKey)
const artistName = track.artist || 'Unknown' const groupName = getGroupName(track) || 'Unknown'
if (!groupsMap[artistName]) groupsMap[artistName] = [] if (!groupsMap[groupName]) groupsMap[groupName] = []
groupsMap[artistName].push(track) groupsMap[groupName].push(track)
} }
const groups = Object.entries(groupsMap).map(([name, artistTracks]) => ({ name, tracks: [...artistTracks] })) const groups = Object.entries(groupsMap).map(([name, groupTracks]) => ({ name, tracks: [...groupTracks] }))
for (const group of groups) { for (const group of groups) {
group.tracks.sort((a, b) => { group.tracks.sort((a, b) => {
const aAlbum = (a.album || '').toLowerCase() const aSubgroup = (getSubgroupName(a) || '').toLowerCase()
const bAlbum = (b.album || '').toLowerCase() const bSubgroup = (getSubgroupName(b) || '').toLowerCase()
if (aAlbum && bAlbum && aAlbum !== bAlbum) return aAlbum.localeCompare(bAlbum) if (aSubgroup && bSubgroup && aSubgroup !== bSubgroup) return aSubgroup.localeCompare(bSubgroup)
return (a.title || '').toLowerCase().localeCompare((b.title || '').toLowerCase()) return (a.title || '').toLowerCase().localeCompare((b.title || '').toLowerCase())
}) })
} }
@@ -1029,7 +1119,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
const getDisplayTitle = (track?: Track) => (track ? getTrackDisplayTitle(track) : '') const getDisplayTitle = (track?: Track) => (track ? getTrackDisplayTitle(track) : '')
const getVisibleArtistGroups = (groups: Array<{ name: string; tracks: Track[] }>, limit: number) => { const getVisibleTrackGroups = (groups: Array<{ name: string; tracks: Track[] }>, limit: number) => {
const visibleGroups: Array<{ name: string; tracks: Track[] }> = [] const visibleGroups: Array<{ name: string; tracks: Track[] }> = []
let remaining = limit let remaining = limit
@@ -1047,38 +1137,52 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
} }
const sectionTracks = useMemo(() => filterTracksForSection(results, mediaSection), [mediaSection, results]) const sectionTracks = useMemo(() => filterTracksForSection(results, mediaSection), [mediaSection, results])
const activeAlbumQuery = useMemo(() => getNamespacedQueryValue(query, 'album'), [query]) const activeSecondaryQuery = useMemo(() => getNamespacedQueryValue(query, namespacePresentation.secondaryNamespace), [namespacePresentation.secondaryNamespace, query])
const activeArtistQuery = useMemo(() => getNamespacedQueryValue(query, 'artist'), [query]) const activePrimaryQuery = useMemo(() => getNamespacedQueryValue(query, namespacePresentation.primaryNamespace), [namespacePresentation.primaryNamespace, query])
const queryMatchedSectionTracks = useMemo(() => sectionTracks.filter((track) => matchesTrackSearch(track, query)), [query, sectionTracks]) const queryMatchedSectionTracks = useMemo(() => sectionTracks.filter((track) => matchesTrackSearch(track, query)), [query, sectionTracks])
const currentTrackResults = useMemo(() => filterTracksForView(queryMatchedSectionTracks, view), [queryMatchedSectionTracks, view]) const currentTrackResults = useMemo(() => filterTracksForView(queryMatchedSectionTracks, view), [queryMatchedSectionTracks, view])
const albums = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'album'), [sectionTracks]) const albums = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, (track) => getTrackSecondaryValue(track)), [sectionTracks, trackNamespaceValues])
const artists = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'artist'), [sectionTracks]) const artists = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, (track) => getTrackPrimaryValue(track)), [sectionTracks, trackNamespaceValues])
const baseArtistGroups = useMemo(() => { const baseTrackGroups = useMemo(() => {
if (!hasArtistsView || activeAlbumQuery || activeArtistQuery) return [] if (!hasArtistsView || activeSecondaryQuery || activePrimaryQuery) return []
return buildArtistGroupsFromTracks(currentTrackResults) return buildTrackGroupsFromTracks(
}, [activeAlbumQuery, activeArtistQuery, currentTrackResults, hasArtistsView]) currentTrackResults,
const sortedCurrentTrackResults = useMemo(() => sortTracks(currentTrackResults, sortBy as TrackSortField, sortDirection), [currentTrackResults, sortBy, sortDirection]) (track) => getTrackPrimaryValue(track),
const sortedArtistGroups = useMemo(() => { (track) => getTrackSecondaryValue(track),
)
}, [activePrimaryQuery, activeSecondaryQuery, currentTrackResults, hasArtistsView, trackNamespaceValues])
const sortedCurrentTrackResults = useMemo(() => sortTracks(currentTrackResults, sortBy as TrackSortField, sortDirection, {
getPrimaryValue: (track) => getTrackPrimaryValue(track),
getSecondaryValue: (track) => getTrackSecondaryValue(track),
}), [currentTrackResults, sortBy, sortDirection, trackNamespaceValues])
const sortedTrackGroups = useMemo(() => {
if (!hasArtistsView) return [] if (!hasArtistsView) return []
return [...baseArtistGroups] return [...baseTrackGroups]
.map((group) => ({ name: group.name, tracks: sortTracks(group.tracks, 'title', sortDirection) })) .map((group) => ({
name: group.name,
tracks: sortTracks(group.tracks, 'title', sortDirection, {
getPrimaryValue: (track) => getTrackPrimaryValue(track),
getSecondaryValue: (track) => getTrackSecondaryValue(track),
}),
}))
.sort((left, right) => compareText(left.name, right.name) * (sortDirection === 'asc' ? 1 : -1)) .sort((left, right) => compareText(left.name, right.name) * (sortDirection === 'asc' ? 1 : -1))
}, [baseArtistGroups, hasArtistsView, sortDirection]) }, [baseTrackGroups, hasArtistsView, sortDirection, trackNamespaceValues])
const sortedAlbums = useMemo(() => sortEntries(albums, sortBy as EntrySortField, sortDirection), [albums, sortBy, sortDirection]) const sortedAlbums = useMemo(() => sortEntries(albums, sortBy as EntrySortField, sortDirection), [albums, sortBy, sortDirection])
const sortedArtists = useMemo(() => sortEntries(artists, sortBy as EntrySortField, sortDirection), [artists, sortBy, sortDirection]) const sortedArtists = useMemo(() => sortEntries(artists, sortBy as EntrySortField, sortDirection), [artists, sortBy, sortDirection])
const shouldShowGroupedArtists = effectiveDisplayMode === 'grid' && !activeAlbumQuery && !activeArtistQuery && hasArtistsView && sortedArtistGroups.length > 0 && sortBy === 'artist' const shouldShowGroupedTracks = effectiveDisplayMode === 'grid' && !activeSecondaryQuery && !activePrimaryQuery && hasArtistsView && sortedTrackGroups.length > 0 && sortBy === 'artist'
const totalArtistGroupTracks = useMemo(() => sortedArtistGroups.reduce((count, group) => count + group.tracks.length, 0), [sortedArtistGroups]) const totalGroupedTrackCount = useMemo(() => sortedTrackGroups.reduce((count, group) => count + group.tracks.length, 0), [sortedTrackGroups])
const visibleArtistGroups = useMemo(() => getVisibleArtistGroups(sortedArtistGroups, visibleCount), [sortedArtistGroups, visibleCount]) const visibleTrackGroups = useMemo(() => getVisibleTrackGroups(sortedTrackGroups, visibleCount), [sortedTrackGroups, visibleCount])
const visibleResults = useMemo(() => sortedCurrentTrackResults.slice(0, visibleCount), [sortedCurrentTrackResults, visibleCount]) const visibleResults = useMemo(() => sortedCurrentTrackResults.slice(0, visibleCount), [sortedCurrentTrackResults, visibleCount])
const visibleAlbums = useMemo(() => sortedAlbums.slice(0, visibleCount), [sortedAlbums, visibleCount]) const visibleAlbums = useMemo(() => sortedAlbums.slice(0, visibleCount), [sortedAlbums, visibleCount])
const visibleArtists = useMemo(() => sortedArtists.slice(0, visibleCount), [sortedArtists, visibleCount]) const visibleArtists = useMemo(() => sortedArtists.slice(0, visibleCount), [sortedArtists, visibleCount])
const showToolbarSortControls = effectiveDisplayMode === 'table' && isCompactTableLayout const showToolbarSortControls = effectiveDisplayMode === 'table' && isCompactTableLayout
const currentViewLabel = sectionConfig.views.find((item) => item.id === view)?.label || sectionConfig.label
const visibleRenderedTracks = useMemo(() => { const visibleRenderedTracks = useMemo(() => {
if (shouldShowGroupedArtists) return visibleArtistGroups.flatMap((group) => group.tracks) if (shouldShowGroupedTracks) return visibleTrackGroups.flatMap((group) => group.tracks)
return visibleResults return visibleResults
}, [shouldShowGroupedArtists, visibleArtistGroups, visibleResults]) }, [shouldShowGroupedTracks, visibleTrackGroups, visibleResults])
useEffect(() => { useEffect(() => {
const candidates = visibleRenderedTracks.filter((track) => needsVisibleMediaInfoBackfill(track)) const candidates = visibleRenderedTracks.filter((track) => needsVisibleMediaInfoBackfill(track))
@@ -1131,8 +1235,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
}, [servers, visibleRenderedTracks]) }, [servers, visibleRenderedTracks])
const canLoadMore = isTrackLikeView const canLoadMore = isTrackLikeView
? shouldShowGroupedArtists ? shouldShowGroupedTracks
? visibleCount < totalArtistGroupTracks ? visibleCount < totalGroupedTrackCount
: visibleCount < sortedCurrentTrackResults.length : visibleCount < sortedCurrentTrackResults.length
: view === 'albums' : view === 'albums'
? visibleCount < sortedAlbums.length ? visibleCount < sortedAlbums.length
@@ -1163,7 +1267,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
{getDisplayTitle(track) ? <Typography variant="body2" sx={{ fontSize: 13 }}>{getDisplayTitle(track)}</Typography> : null} {getDisplayTitle(track) ? <Typography variant="body2" sx={{ fontSize: 13 }}>{getDisplayTitle(track)}</Typography> : null}
{options?.showAlbum && track?.album && <Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>{track.album}</Typography>} {options?.showAlbum && track && getTrackSecondaryValue(track) && <Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>{getTrackSecondaryValue(track)}</Typography>}
{options?.showFileIdFallback && track && !getDisplayTitle(track) && <Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>File {track.fileId}</Typography>} {options?.showFileIdFallback && track && !getDisplayTitle(track) && <Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>File {track.fileId}</Typography>}
</Box> </Box>
</Box> </Box>
@@ -1172,14 +1276,24 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
</Box> </Box>
) )
const openAlbumEntry = (name: string) => { const openAlbumEntry = (name: string, options?: { primaryName?: string | null }) => {
setView('tracks') setView('tracks')
applyLibraryQueryFilter('album', name) const primaryName = mediaSection === 'video'
? options?.primaryName?.trim() || activePrimaryQuery
: null
applyLibraryQueryFilters({
[namespacePresentation.secondaryNamespace]: name,
...(primaryName ? { [namespacePresentation.primaryNamespace]: primaryName } : {}),
})
} }
const openArtistEntry = (name: string) => { const openArtistEntry = (name: string) => {
setView('tracks') setView('tracks')
applyLibraryQueryFilter('artist', name) applyLibraryQueryFilters({
[namespacePresentation.primaryNamespace]: name,
[namespacePresentation.secondaryNamespace]: null,
})
} }
const handleSortRequest = (field: SortField) => { const handleSortRequest = (field: SortField) => {
@@ -1234,10 +1348,10 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>{getDisplayTitle(track)}</Typography> <Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>{getDisplayTitle(track)}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }} noWrap> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }} noWrap>
{track.artist || track.album || track.serverName || getTrackKindLabel(track, sectionConfig.label)} {getTrackPrimaryValue(track) || getTrackSecondaryValue(track) || track.serverName || getTrackKindLabel(track, sectionConfig.label)}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }} noWrap> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }} noWrap>
{[options?.showAlbum || track.album ? (track.album || null) : null, track.serverName || null, track.fileId != null ? `#${track.fileId}` : null].filter(Boolean).join(' • ')} {[options?.showAlbum || getTrackSecondaryValue(track) ? (getTrackSecondaryValue(track) || null) : null, track.serverName || null, track.fileId != null ? `#${track.fileId}` : null].filter(Boolean).join(' • ')}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -1249,9 +1363,9 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}> <Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
{renderTrackHeaderCell('Track', 'title', { width: '38%' })} {renderTrackHeaderCell(namespacePresentation.trackLabel, 'title', { width: '38%' })}
{renderTrackHeaderCell('Artist', 'artist', { width: '22%', sx: { display: { xs: 'none', md: 'table-cell' } } })} {renderTrackHeaderCell(namespacePresentation.primaryLabel, 'artist', { width: '22%', sx: { display: { xs: 'none', md: 'table-cell' } } })}
{renderTrackHeaderCell('Album', 'album', { width: '22%', sx: { display: { xs: 'none', sm: 'table-cell' } } })} {renderTrackHeaderCell(namespacePresentation.secondaryLabel, 'album', { width: '22%', sx: { display: { xs: 'none', sm: 'table-cell' } } })}
{renderTrackHeaderCell('Server', 'server', { width: '12%', sx: { display: { xs: 'none', lg: 'table-cell' } } })} {renderTrackHeaderCell('Server', 'server', { width: '12%', sx: { display: { xs: 'none', lg: 'table-cell' } } })}
{renderTrackHeaderCell('ID', 'fileId', { width: '6%', align: 'right' })} {renderTrackHeaderCell('ID', 'fileId', { width: '6%', align: 'right' })}
</TableRow> </TableRow>
@@ -1271,8 +1385,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
</Box> </Box>
</Box> </Box>
</TableCell> </TableCell>
<TableCell sx={{ display: { xs: 'none', md: 'table-cell' } }}><Typography variant="body2" noWrap>{track.artist || '—'}</Typography></TableCell> <TableCell sx={{ display: { xs: 'none', md: 'table-cell' } }}><Typography variant="body2" noWrap>{getTrackPrimaryValue(track) || '—'}</Typography></TableCell>
<TableCell sx={{ display: { xs: 'none', sm: 'table-cell' } }}><Typography variant="body2" noWrap>{options?.showAlbum || track.album ? (track.album || '—') : '—'}</Typography></TableCell> <TableCell sx={{ display: { xs: 'none', sm: 'table-cell' } }}><Typography variant="body2" noWrap>{options?.showAlbum || getTrackSecondaryValue(track) ? (getTrackSecondaryValue(track) || '—') : '—'}</Typography></TableCell>
<TableCell sx={{ display: { xs: 'none', lg: 'table-cell' } }}><Typography variant="body2" noWrap>{track.serverName || '—'}</Typography></TableCell> <TableCell sx={{ display: { xs: 'none', lg: 'table-cell' } }}><Typography variant="body2" noWrap>{track.serverName || '—'}</Typography></TableCell>
<TableCell align="right">{track.fileId ?? '—'}</TableCell> <TableCell align="right">{track.fileId ?? '—'}</TableCell>
</TableRow> </TableRow>
@@ -1293,7 +1407,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box component="img" src={entry.servers[0].thumbnail} alt={entry.name} sx={{ width: 48, height: 48, borderRadius: 1.25, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" /> <Box component="img" src={entry.servers[0].thumbnail} alt={entry.name} sx={{ width: 48, height: 48, borderRadius: 1.25, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" />
) : ( ) : (
<Box sx={{ width: 48, height: 48, borderRadius: 1.25, flexShrink: 0, bgcolor: '#0b0b0b', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'text.secondary', fontSize: 12 }}> <Box sx={{ width: 48, height: 48, borderRadius: 1.25, flexShrink: 0, bgcolor: '#0b0b0b', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'text.secondary', fontSize: 12 }}>
{kind === 'album' ? 'AL' : 'AR'} {(kind === 'album' ? namespacePresentation.secondaryLabel : namespacePresentation.primaryLabel).slice(0, 2).toUpperCase()}
</Box> </Box>
)} )}
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
@@ -1311,7 +1425,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}> <Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
{renderEntryHeaderCell(kind === 'album' ? 'Album' : 'Artist', 'name', { width: '70%' })} {renderEntryHeaderCell(kind === 'album' ? namespacePresentation.secondaryLabel : namespacePresentation.primaryLabel, 'name', { width: '70%' })}
{renderEntryHeaderCell('Items', 'count', { width: '15%', align: 'right' })} {renderEntryHeaderCell('Items', 'count', { width: '15%', align: 'right' })}
<TableCell align="right" sx={{ width: '15%', display: { xs: 'none', sm: 'table-cell' } }}>Servers</TableCell> <TableCell align="right" sx={{ width: '15%', display: { xs: 'none', sm: 'table-cell' } }}>Servers</TableCell>
</TableRow> </TableRow>
@@ -1325,7 +1439,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box component="img" src={entry.servers[0].thumbnail} alt={entry.name} sx={{ width: 44, height: 44, borderRadius: 1, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" /> <Box component="img" src={entry.servers[0].thumbnail} alt={entry.name} sx={{ width: 44, height: 44, borderRadius: 1, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" />
) : ( ) : (
<Box sx={{ width: 44, height: 44, borderRadius: 1, flexShrink: 0, bgcolor: '#0b0b0b', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'text.secondary', fontSize: 12 }}> <Box sx={{ width: 44, height: 44, borderRadius: 1, flexShrink: 0, bgcolor: '#0b0b0b', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'text.secondary', fontSize: 12 }}>
{kind === 'album' ? 'AL' : 'AR'} {(kind === 'album' ? namespacePresentation.secondaryLabel : namespacePresentation.primaryLabel).slice(0, 2).toUpperCase()}
</Box> </Box>
)} )}
<Typography variant="body2" noWrap>{entry.name}</Typography> <Typography variant="body2" noWrap>{entry.name}</Typography>
@@ -1416,9 +1530,9 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<Box> <Box>
{(view === 'tracks' || view === 'text' || view === 'data') && ( {(view === 'tracks' || view === 'text' || view === 'data') && (
<> <>
{shouldShowGroupedArtists ? ( {shouldShowGroupedTracks ? (
visibleArtistGroups.map((g) => { visibleTrackGroups.map((g) => {
const albumNames = Array.from(new Set(g.tracks.map((t: any) => (t.album || '').trim()).filter(Boolean))) const albumNames = Array.from(new Set(g.tracks.map((track) => (getTrackSecondaryValue(track) || '').trim()).filter(Boolean)))
return ( return (
<Box key={g.name} sx={{ mb: 3 }}> <Box key={g.name} sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>{g.name}</Typography> <Typography variant="h6" sx={{ mb: 1 }}>{g.name}</Typography>
@@ -1426,8 +1540,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
{albumNames.length > 0 && ( {albumNames.length > 0 && (
<Box sx={{ mb: 1, display: 'flex', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ mb: 1, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{albumNames.map((a) => { {albumNames.map((a) => {
const count = g.tracks.filter((t: any) => (t.album || '') === a).length const count = g.tracks.filter((track) => getTrackSecondaryValue(track) === a).length
return <Chip key={a} label={`${a} (${count})`} clickable onClick={() => openAlbumEntry(a)} /> return <Chip key={a} label={`${a} (${count})`} clickable onClick={() => openAlbumEntry(a, { primaryName: g.name })} />
})} })}
</Box> </Box>
)} )}
@@ -1523,7 +1637,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
{loading && view !== 'tracks' && ((view === 'albums' && hasAlbumsView && albums.length === 0) || (view === 'artists' && hasArtistsView && artists.length === 0)) && ( {loading && view !== 'tracks' && ((view === 'albums' && hasAlbumsView && albums.length === 0) || (view === 'artists' && hasArtistsView && artists.length === 0)) && (
<Alert severity="info" sx={{ mt: 2 }}> <Alert severity="info" sx={{ mt: 2 }}>
Loading {view}... Loading {currentViewLabel.toLowerCase()}...
</Alert> </Alert>
)} )}
+64 -35
View File
@@ -34,56 +34,84 @@ import { useServers } from '../context/ServersContext'
import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache' import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache'
import { syncLibraryCache } from '../librarySync' import { syncLibraryCache } from '../librarySync'
import { APP_THEME_PRESETS, type AppThemeId } from '../themes' import { APP_THEME_PRESETS, type AppThemeId } from '../themes'
const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false } type ServerForm = Omit<Server, 'id' | 'lastTest' | 'host' | 'port'> & {
endpoint: string
}
function normalizeServerForm(form: Omit<Server, 'id' | 'lastTest'>) { const DEFAULT_SERVER_FORM: ServerForm = { name: '', endpoint: '', apiKey: '', ssl: false, forceApiKeyInQuery: false }
function buildServerEndpointValue(server: Pick<Server, 'host' | 'port' | 'ssl'>) {
const rawHost = (server.host || '').trim()
if (!rawHost) return ''
let endpoint = rawHost
if (!/^https?:\/\//i.test(endpoint)) {
endpoint = `${server.ssl ? 'https' : 'http'}://${endpoint}`
}
try {
const parsed = new URL(endpoint)
if (server.port && !parsed.port) parsed.port = String(server.port)
const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/$/, '') : ''
return `${parsed.origin}${path}`
} catch {
return `${rawHost}${server.port ? `:${server.port}` : ''}`
}
}
function normalizeServerForm(form: ServerForm) {
return { return {
...form, ...form,
name: (form.name || '').trim(), name: (form.name || '').trim(),
host: (form.host || '').trim(), endpoint: (form.endpoint || '').trim(),
port: typeof form.port === 'string' ? form.port.trim() || undefined : form.port,
apiKey: (form.apiKey || '').replace(/\s+/g, ''), apiKey: (form.apiKey || '').replace(/\s+/g, ''),
} }
} }
function validateServerForm(form: Omit<Server, 'id' | 'lastTest'>) { function validateServerForm(form: ServerForm) {
const normalized = normalizeServerForm(form) const normalized = normalizeServerForm(form)
if (!normalized.host) { if (!normalized.endpoint) {
return { normalized, error: 'Host is required.' } return { normalized, config: null, error: 'Server URL is required.' }
} }
try { try {
let candidate = normalized.host let candidate = normalized.endpoint
if (!/^https?:\/\//i.test(candidate)) { if (!/^https?:\/\//i.test(candidate)) {
candidate = `${normalized.ssl ? 'https' : 'http'}://${candidate}` candidate = `${normalized.ssl ? 'https' : 'http'}://${candidate}`
} }
const parsed = new URL(candidate) const parsed = new URL(candidate)
if (!parsed.hostname) { if (!parsed.hostname) {
return { normalized, error: 'Host is invalid. Use an IP, hostname, or full URL.' } return { normalized, config: null, error: 'Server URL is invalid. Use an IP, hostname, or full URL.' }
}
} catch {
return { normalized, error: 'Host is invalid. Use an IP, hostname, or full URL.' }
} }
if (normalized.port !== undefined) { if (parsed.port) {
const portText = String(normalized.port) const portNumber = Number(parsed.port)
if (!/^\d+$/.test(portText)) { if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
return { normalized, error: 'Port must contain digits only.' } return { normalized, config: null, error: 'Port must be between 1 and 65535.' }
}
const portNumber = Number(portText)
if (portNumber < 1 || portNumber > 65535) {
return { normalized, error: 'Port must be between 1 and 65535.' }
} }
} }
if (normalized.apiKey && !/^[a-f0-9]{64}$/i.test(normalized.apiKey)) { if (normalized.apiKey && !/^[a-f0-9]{64}$/i.test(normalized.apiKey)) {
return { normalized, error: 'API key should be a 64-character hexadecimal key. Any pasted spaces or line breaks were removed automatically.' } return { normalized, config: null, error: 'API key should be a 64-character hexadecimal key. Any pasted spaces or line breaks were removed automatically.' }
} }
return { normalized, error: null } const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/$/, '') : ''
const config: Omit<Server, 'id' | 'lastTest'> = {
name: normalized.name,
host: `${parsed.protocol}//${parsed.hostname}${path}`,
port: parsed.port || undefined,
apiKey: normalized.apiKey,
ssl: parsed.protocol === 'https:',
forceApiKeyInQuery: normalized.forceApiKeyInQuery,
syncSummary: form.syncSummary,
}
return { normalized: { ...normalized, ssl: config.ssl }, config, error: null }
} catch {
return { normalized, config: null, error: 'Server URL is invalid. Use an IP, hostname, or full URL.' }
}
} }
type SettingsPageProps = { type SettingsPageProps = {
@@ -102,7 +130,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers() const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers()
const isFirstServerSetup = servers.length === 0 const isFirstServerSetup = servers.length === 0
const [editing, setEditing] = useState<Server | null>(null) const [editing, setEditing] = useState<Server | null>(null)
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM) const [form, setForm] = useState<ServerForm>(DEFAULT_SERVER_FORM)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [syncingServerId, setSyncingServerId] = useState<string | null>(null) const [syncingServerId, setSyncingServerId] = useState<string | null>(null)
const [lastTest, setLastTest] = useState<string | null>(null) const [lastTest, setLastTest] = useState<string | null>(null)
@@ -194,22 +222,23 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const startEdit = (s: Server) => { const startEdit = (s: Server) => {
setEditing(s) setEditing(s)
setForm({ name: s.name || '', host: s.host, port: s.port, apiKey: s.apiKey || '', ssl: !!s.ssl, forceApiKeyInQuery: !!s.forceApiKeyInQuery }) setForm({ name: s.name || '', endpoint: buildServerEndpointValue(s), apiKey: s.apiKey || '', ssl: !!s.ssl, forceApiKeyInQuery: !!s.forceApiKeyInQuery, syncSummary: s.syncSummary })
setLastTest(s.lastTest ? `${s.lastTest.message} (${new Date(s.lastTest.timestamp).toLocaleString()})` : null) setLastTest(s.lastTest ? `${s.lastTest.message} (${new Date(s.lastTest.timestamp).toLocaleString()})` : null)
} }
const handleSave = () => { const handleSave = () => {
const { normalized, error } = validateServerForm(form) const { normalized, config, error } = validateServerForm(form)
if (error) { if (error) {
setLastTest(error) setLastTest(error)
return return
} }
setForm(normalized) setForm(normalized)
if (!config) return
if (editing) { if (editing) {
updateServer(editing.id, normalized) updateServer(editing.id, config)
} else { } else {
const srv = addServer(normalized) const srv = addServer(config)
setActiveServerId(srv.id) setActiveServerId(srv.id)
} }
onClose && onClose() onClose && onClose()
@@ -234,16 +263,17 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
} }
const handleTestForm = async () => { const handleTestForm = async () => {
const { normalized, error } = validateServerForm(form) const { normalized, config, error } = validateServerForm(form)
if (error) { if (error) {
setLastTest(error) setLastTest(error)
return return
} }
setForm(normalized) setForm(normalized)
if (!config) return
setTesting(true) setTesting(true)
try { try {
const res = await testServerConfig(normalized) const res = await testServerConfig(config)
setLastTest(`${res.message}`) setLastTest(`${res.message}`)
} catch (e: any) { } catch (e: any) {
setLastTest(`Error: ${e?.message ?? String(e)}`) setLastTest(`Error: ${e?.message ?? String(e)}`)
@@ -509,7 +539,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 } }}> <Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 } }}>
<Typography variant="subtitle1">{editing ? 'Edit server' : isFirstServerSetup ? 'Add your first server' : 'Add new server'}</Typography> <Typography variant="subtitle1">{editing ? 'Edit server' : isFirstServerSetup ? 'Add your first server' : 'Add new server'}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Use the same host, port, and API key you use for the Hydrus Client API. Paste the same Hydrus Client API address and API key you use elsewhere.
</Typography> </Typography>
{isFirstServerSetup && ( {isFirstServerSetup && (
<Alert severity="info" sx={{ mt: 2 }}> <Alert severity="info" sx={{ mt: 2 }}>
@@ -518,17 +548,16 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
)} )}
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField label="Name (optional)" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} fullWidth /> <TextField label="Name (optional)" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} fullWidth />
<TextField label="Host or URL" placeholder="192.168.1.128, hydrus.local, or http://192.168.1.128" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} /> <TextField label="Server URL" placeholder="http://192.168.1.128:45869 or hydrus.local:45869" value={form.endpoint} onChange={(e) => setForm({ ...form, endpoint: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} helperText="Paste the full Hydrus Client API address here. Port can be included in the same field." />
<TextField label="Port" placeholder="45869" value={form.port as any ?? ''} onChange={(e) => setForm({ ...form, port: e.target.value })} fullWidth inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} />
<TextField label="API Key (optional)" value={form.apiKey} onChange={(e) => setForm({ ...form, apiKey: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} helperText="Hex API key. Pasted spaces or line breaks are ignored." /> <TextField label="API Key (optional)" value={form.apiKey} onChange={(e) => setForm({ ...form, apiKey: e.target.value })} fullWidth autoCapitalize="off" autoCorrect="off" spellCheck={false} helperText="Hex API key. Pasted spaces or line breaks are ignored." />
<FormControlLabel control={<Switch checked={!!form.ssl} onChange={(e) => setForm({ ...form, ssl: e.target.checked })} />} label="Use HTTPS (SSL)" /> <FormControlLabel control={<Switch checked={!!form.ssl} onChange={(e) => setForm({ ...form, ssl: e.target.checked })} />} label="Use HTTPS (SSL)" />
<FormControlLabel control={<Switch checked={!!form.forceApiKeyInQuery} onChange={(e) => setForm({ ...form, forceApiKeyInQuery: e.target.checked })} />} label="Send API key in query parameter" /> <FormControlLabel control={<Switch checked={!!form.forceApiKeyInQuery} onChange={(e) => setForm({ ...form, forceApiKeyInQuery: e.target.checked })} />} label="Send API key in query parameter" />
<Box sx={{ display: 'flex', gap: 1, mt: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 1, mt: 1, flexWrap: 'wrap' }}>
<Button variant="contained" onClick={handleSave} disabled={!form.host} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}> <Button variant="contained" onClick={handleSave} disabled={!form.endpoint} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
Save Save
</Button> </Button>
<Button variant="outlined" onClick={handleTestForm} disabled={!form.host || testing} startIcon={<PlayArrowIcon />} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}> <Button variant="outlined" onClick={handleTestForm} disabled={!form.endpoint || testing} startIcon={<PlayArrowIcon />} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
{testing ? 'Testing...' : 'Test connection'} {testing ? 'Testing...' : 'Test connection'}
</Button> </Button>
<Button onClick={() => { setEditing(null); setForm(DEFAULT_SERVER_FORM); setLastTest(null) }} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}> <Button onClick={() => { setEditing(null); setForm(DEFAULT_SERVER_FORM); setLastTest(null) }} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>