M1/#292: core library schema + sqlc + admin bootstrap #5

Merged
bvandeusen merged 262 commits from dev into main 2026-04-18 22:36:26 -04:00
5 changed files with 78 additions and 37 deletions
Showing only changes of commit 1b243347cb - Show all commits
@@ -45,7 +45,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import com.composables.icons.lucide.ArrowLeft import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Disc3 import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
@@ -60,6 +59,7 @@ import com.fabledsword.minstrel.shared.widgets.CachedDot
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
@@ -290,8 +290,8 @@ private fun AlbumCover(album: AlbumRef) {
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) )
} else { } else {
AsyncImage( ServerImage(
model = album.displayCoverUrl, url = album.displayCoverUrl,
contentDescription = album.title, contentDescription = album.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@@ -36,13 +36,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import com.composables.icons.lucide.ArrowLeft import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play import com.composables.icons.lucide.Play
@@ -54,6 +52,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -206,8 +205,7 @@ private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) {
// _ArtistAvatar). Bare Lucide.User only when neither exists. // _ArtistAvatar). Bare Lucide.User only when neither exists.
val model = when { val model = when {
artist.coverUrl.isNotEmpty() -> artist.coverUrl artist.coverUrl.isNotEmpty() -> artist.coverUrl
fallbackAlbumId != null -> fallbackAlbumId != null -> "/api/albums/$fallbackAlbumId/cover"
"http://placeholder.invalid/api/albums/$fallbackAlbumId/cover"
else -> null else -> null
} }
Box( Box(
@@ -217,20 +215,17 @@ private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) {
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (model == null) { ServerImage(
url = model,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
) {
Icon( Icon(
imageVector = Lucide.User, imageVector = Lucide.User,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
) )
} else {
AsyncImage(
model = model,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} }
} }
} }
@@ -19,15 +19,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Disc3 import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/** /**
@@ -69,18 +68,13 @@ fun AlbumCard(
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) )
} else { } else {
// Use displayCoverUrl so cached-only albums (which // displayCoverUrl yields /api/albums/{id}/cover —
// drop coverUrl in the entity→domain mapper) still // relative on fresh fetches, placeholder-absolute from
// hit /api/albums/{id}/cover via the BaseUrlInterceptor // cache. ServerImage resolves both to a loadable URL.
// placeholder rewrite. On load failure (album with ServerImage(
// no cover server-side) the Box's surfaceVariant url = album.displayCoverUrl,
// background shows through — a Disc3 error-slot
// fallback is a future refinement.
AsyncImage(
model = album.displayCoverUrl,
contentDescription = album.title, contentDescription = album.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
) )
} }
} }
@@ -19,14 +19,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/** /**
@@ -61,20 +60,17 @@ fun PlaylistCard(
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)), .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (playlist.coverUrl.isEmpty()) { ServerImage(
url = playlist.coverUrl,
contentDescription = playlist.name,
modifier = Modifier.fillMaxSize(),
) {
Icon( Icon(
imageVector = Lucide.ListMusic, imageVector = Lucide.ListMusic,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) )
} else {
AsyncImage(
model = playlist.coverUrl,
contentDescription = playlist.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} }
if (playlist.isSystem) { if (playlist.isSystem) {
VariantPill( VariantPill(
@@ -0,0 +1,56 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
/**
* Resolves a server image/cover URL into something Coil can load.
*
* The server returns **relative** cover URLs (e.g.
* `/api/albums/{id}/cover`, `/api/playlists/{id}/cover`). Coil can't
* load a host-less path, so we prefix the `placeholder.invalid` host
* that `BaseUrlInterceptor` rewrites to the live server (carrying the
* session cookie). Absolute `http(s)` URLs — including ones already
* built against the placeholder host — pass through unchanged; blank
* input returns null so the caller renders its own fallback.
*
* This is the single place a server URL becomes loadable, mirroring
* Flutter's `ServerImage._resolve` (which prepends the base URL).
* Cover surfaces must route through [ServerImage] rather than handing
* a raw `coverUrl` to `AsyncImage` — that was the bug where relative
* URLs from fresh fetches silently failed to load.
*/
fun resolveServerImageUrl(raw: String?): String? = when {
raw.isNullOrBlank() -> null
raw.startsWith("http://") || raw.startsWith("https://") -> raw
raw.startsWith("/") -> "http://placeholder.invalid$raw"
else -> "http://placeholder.invalid/$raw"
}
/**
* Renders a server-hosted image, resolving relative URLs centrally so
* every cover surface loads consistently. Shows [fallback] when the URL
* is blank or unresolvable.
*/
@Composable
fun ServerImage(
url: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
fallback: @Composable () -> Unit = {},
) {
val resolved = resolveServerImageUrl(url)
if (resolved == null) {
fallback()
} else {
AsyncImage(
model = resolved,
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
}
}