feat: M10 — native SSO fixes + staff operations #21
@@ -4,11 +4,22 @@
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* The M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md §1, §6.4). On
|
||||
@@ -28,4 +39,30 @@ interface AdminApi {
|
||||
/** `PUT /admin/site-mode` — switch live/maintenance (admin only; 403 otherwise). */
|
||||
@PUT("api/v1/admin/site-mode")
|
||||
suspend fun setSiteMode(@Body body: SiteModeRequest): SiteModeStateDto
|
||||
|
||||
// ── Content: news posts (any staff role) ──────────────────────────────
|
||||
@GET("api/v1/admin/posts")
|
||||
suspend fun posts(): List<AdminPostDto>
|
||||
|
||||
@POST("api/v1/admin/posts")
|
||||
suspend fun createPost(@Body body: PostCreateRequest): AdminPostDto
|
||||
|
||||
@PATCH("api/v1/admin/posts/{id}/publish")
|
||||
suspend fun publishPost(@Path("id") id: Long, @Body body: PublishRequest): AdminPostDto
|
||||
|
||||
@DELETE("api/v1/admin/posts/{id}")
|
||||
suspend fun deletePost(@Path("id") id: Long): Response<Unit>
|
||||
|
||||
// ── Content: wiki taxonomy (any staff role) ───────────────────────────
|
||||
@GET("api/v1/admin/wiki/categories")
|
||||
suspend fun wikiCategories(): List<AdminWikiCategoryDto>
|
||||
|
||||
@POST("api/v1/admin/wiki/categories")
|
||||
suspend fun createWikiCategory(@Body body: WikiCategoryRequest): AdminWikiCategoryDto
|
||||
|
||||
@DELETE("api/v1/admin/wiki/categories/{id}")
|
||||
suspend fun deleteWikiCategory(@Path("id") id: Long): Response<Unit>
|
||||
|
||||
@GET("api/v1/admin/wiki/tags")
|
||||
suspend fun wikiTags(): List<AdminWikiTagDto>
|
||||
}
|
||||
|
||||
@@ -56,3 +56,73 @@ data class SiteModeStateDto(
|
||||
@SerialName("changed_at") val changedAt: String? = null,
|
||||
@SerialName("changed_by") val changedBy: String? = null,
|
||||
)
|
||||
|
||||
// ── Content: news posts ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A post row from `GET /admin/posts` (all posts, incl. unpublished — unlike the
|
||||
* public feed). `published` is a 0/1 flag (MariaDB tinyint), exposed as [isPublished].
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminPostDto(
|
||||
val id: Long,
|
||||
val category: String = "",
|
||||
val title: String = "",
|
||||
val slug: String? = null,
|
||||
val excerpt: String? = null,
|
||||
val body: String? = null,
|
||||
@SerialName("image_url") val imageUrl: String? = null,
|
||||
val published: Int = 0,
|
||||
@SerialName("published_at") val publishedAt: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
) {
|
||||
val isPublished: Boolean get() = published != 0
|
||||
}
|
||||
|
||||
/** `POST/PUT /admin/posts` body. `category` is a URL category the backend maps
|
||||
* (news | five-on-friday | newsletter | screenshots). */
|
||||
@Serializable
|
||||
data class PostCreateRequest(
|
||||
val category: String,
|
||||
val title: String,
|
||||
val excerpt: String? = null,
|
||||
val body: String? = null,
|
||||
@SerialName("image_url") val imageUrl: String? = null,
|
||||
val published: Boolean = false,
|
||||
)
|
||||
|
||||
/** `PATCH /admin/posts/:id/publish` body. */
|
||||
@Serializable
|
||||
data class PublishRequest(val published: Boolean)
|
||||
|
||||
// ── Content: wiki taxonomy ────────────────────────────────────────────────
|
||||
|
||||
/** A wiki category from `GET /admin/wiki/categories` (with page counts). */
|
||||
@Serializable
|
||||
data class AdminWikiCategoryDto(
|
||||
val id: Long,
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
val description: String? = null,
|
||||
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||
@SerialName("page_count") val pageCount: Int? = null,
|
||||
@SerialName("published_count") val publishedCount: Int? = null,
|
||||
)
|
||||
|
||||
/** `POST /admin/wiki/categories` body. */
|
||||
@Serializable
|
||||
data class WikiCategoryRequest(
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||
)
|
||||
|
||||
/** A wiki tag from `GET /admin/wiki/tags` (tags derive from pages; read-only here). */
|
||||
@Serializable
|
||||
data class AdminWikiTagDto(
|
||||
val id: Long,
|
||||
val slug: String = "",
|
||||
val label: String = "",
|
||||
@SerialName("published_count") val publishedCount: Int? = null,
|
||||
)
|
||||
|
||||
@@ -7,8 +7,16 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.AdminApi
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -27,4 +35,32 @@ class AdminRepository @Inject constructor(
|
||||
|
||||
suspend fun setSiteMode(mode: String): ApiResult<SiteModeStateDto> =
|
||||
safeApiCall { api.setSiteMode(SiteModeRequest(mode)) }
|
||||
|
||||
// ── Content: news posts ───────────────────────────────────────────────
|
||||
suspend fun posts(): ApiResult<List<AdminPostDto>> = safeApiCall { api.posts() }
|
||||
|
||||
suspend fun createPost(body: PostCreateRequest): ApiResult<AdminPostDto> =
|
||||
safeApiCall { api.createPost(body) }
|
||||
|
||||
suspend fun setPostPublished(id: Long, published: Boolean): ApiResult<AdminPostDto> =
|
||||
safeApiCall { api.publishPost(id, PublishRequest(published)) }
|
||||
|
||||
suspend fun deletePost(id: Long): ApiResult<Unit> = safeApiCall { api.deletePost(id).requireOk() }
|
||||
|
||||
// ── Content: wiki taxonomy ────────────────────────────────────────────
|
||||
suspend fun wikiCategories(): ApiResult<List<AdminWikiCategoryDto>> = safeApiCall { api.wikiCategories() }
|
||||
|
||||
suspend fun createWikiCategory(body: WikiCategoryRequest): ApiResult<AdminWikiCategoryDto> =
|
||||
safeApiCall { api.createWikiCategory(body) }
|
||||
|
||||
suspend fun deleteWikiCategory(id: Long): ApiResult<Unit> =
|
||||
safeApiCall { api.deleteWikiCategory(id).requireOk() }
|
||||
|
||||
suspend fun wikiTags(): ApiResult<List<AdminWikiTagDto>> = safeApiCall { api.wikiTags() }
|
||||
|
||||
/** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so
|
||||
* [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */
|
||||
private fun Response<Unit>.requireOk() {
|
||||
if (!isSuccessful) throw HttpException(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
||||
import com.runicgateway.app.ui.news.NewsScreen
|
||||
import com.runicgateway.app.ui.news.PostScreen
|
||||
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||
import com.runicgateway.app.ui.admin.AdminDashboardScreen
|
||||
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
||||
import com.runicgateway.app.ui.page.PageScreen
|
||||
@@ -82,7 +83,7 @@ private val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||
Routes.NOTIFICATIONS,
|
||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||
Routes.ADMIN_DASHBOARD,
|
||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -372,6 +373,9 @@ private fun RunicNavHost(
|
||||
AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true)
|
||||
}
|
||||
}
|
||||
composable(Routes.ADMIN_CONTENT) {
|
||||
StaffGate(session, navController) { AdminContentScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The staff content screen (PLAN.md §1, M10): news posts and wiki taxonomy, in two
|
||||
* tabs. Create/publish/delete over the existing `/admin/posts` + `/admin/wiki/…`
|
||||
* routes; the CMS block/hero editor stays out of scope. Any staff role; the server
|
||||
* re-checks on every call.
|
||||
*/
|
||||
@Composable
|
||||
fun AdminContentScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AdminContentViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
var showNewPost by rememberSaveable { mutableStateOf(false) }
|
||||
var showNewCategory by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Column(modifier.fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = tab) {
|
||||
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(stringResource(R.string.admin_content_tab_posts)) })
|
||||
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(stringResource(R.string.admin_content_tab_wiki)) })
|
||||
}
|
||||
|
||||
state.feedback?.let {
|
||||
Text(
|
||||
text = stringResource(it.messageRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (tab) {
|
||||
0 -> PostsTab(
|
||||
state = state.posts,
|
||||
busy = state.busy,
|
||||
onNew = { showNewPost = true },
|
||||
onToggle = viewModel::togglePublish,
|
||||
onDelete = viewModel::deletePost,
|
||||
onRetry = viewModel::loadPosts,
|
||||
)
|
||||
else -> WikiTab(
|
||||
state = state.categories,
|
||||
tags = state.tags,
|
||||
busy = state.busy,
|
||||
onNew = { showNewCategory = true },
|
||||
onDelete = viewModel::deleteCategory,
|
||||
onRetry = viewModel::loadWiki,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showNewPost) {
|
||||
NewPostDialog(
|
||||
categories = viewModel.postCategories,
|
||||
onDismiss = { showNewPost = false },
|
||||
onCreate = { cat, title, excerpt, body, published ->
|
||||
viewModel.createPost(cat, title, excerpt, body, published)
|
||||
showNewPost = false
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showNewCategory) {
|
||||
NewCategoryDialog(
|
||||
onDismiss = { showNewCategory = false },
|
||||
onCreate = { slug, title, desc, sort ->
|
||||
viewModel.createCategory(slug, title, desc, sort)
|
||||
showNewCategory = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PostsTab(
|
||||
state: UiState<List<AdminPostDto>>,
|
||||
busy: Boolean,
|
||||
onNew: () -> Unit,
|
||||
onToggle: (AdminPostDto) -> Unit,
|
||||
onDelete: (Long) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
item {
|
||||
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(stringResource(R.string.admin_content_new_post))
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { post ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(post.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
StatusPill(
|
||||
text = if (post.isPublished) stringResource(R.string.admin_content_published)
|
||||
else stringResource(R.string.admin_content_draft),
|
||||
tone = if (post.isPublished) PillTone.Success else PillTone.Neutral,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(post.category, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = { onToggle(post) }, enabled = !busy) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (post.isPublished) R.string.admin_content_unpublish else R.string.admin_content_publish,
|
||||
),
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { onDelete(post.id) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WikiTab(
|
||||
state: UiState<List<AdminWikiCategoryDto>>,
|
||||
tags: List<AdminWikiTagDto>,
|
||||
busy: Boolean,
|
||||
onNew: () -> Unit,
|
||||
onDelete: (Long) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
item {
|
||||
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(stringResource(R.string.admin_content_new_category))
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { cat ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = stringResource(R.string.admin_content_cat_meta, cat.slug, cat.pageCount ?: 0),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = { onDelete(cat.id) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
item {
|
||||
HorizontalDivider(Modifier.padding(vertical = 12.dp))
|
||||
Text(
|
||||
stringResource(R.string.admin_content_tags, tags.joinToString(", ") { it.label }),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewPostDialog(
|
||||
categories: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (category: String, title: String, excerpt: String, body: String, published: Boolean) -> Unit,
|
||||
) {
|
||||
var category by rememberSaveable { mutableStateOf(categories.first()) }
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var excerpt by rememberSaveable { mutableStateOf("") }
|
||||
var body by rememberSaveable { mutableStateOf("") }
|
||||
var published by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onCreate(category, title, excerpt, body, published) }) {
|
||||
Text(stringResource(R.string.admin_content_create))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||
title = { Text(stringResource(R.string.admin_content_new_post)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
categories.forEach { c ->
|
||||
FilterChip(selected = category == c, onClick = { category = c }, label = { Text(c) })
|
||||
}
|
||||
}
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = excerpt, onValueChange = { excerpt = it }, label = { Text(stringResource(R.string.admin_content_field_excerpt)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = body, onValueChange = { body = it }, label = { Text(stringResource(R.string.admin_content_field_body)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.admin_content_publish_now), modifier = Modifier.weight(1f))
|
||||
Switch(checked = published, onCheckedChange = { published = it })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewCategoryDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (slug: String, title: String, description: String, sortOrder: Int?) -> Unit,
|
||||
) {
|
||||
var slug by rememberSaveable { mutableStateOf("") }
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var description by rememberSaveable { mutableStateOf("") }
|
||||
var sort by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onCreate(slug, title, description, sort.toIntOrNull()) }) {
|
||||
Text(stringResource(R.string.admin_content_create))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||
title = { Text(stringResource(R.string.admin_content_new_category)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(value = slug, onValueChange = { slug = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_slug)) }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text(stringResource(R.string.admin_content_field_description)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = sort, onValueChange = { sort = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_sort)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the staff content screen (PLAN.md §1, M10): news posts (list, create,
|
||||
* publish/unpublish, delete) and wiki taxonomy (list categories/tags, create/delete
|
||||
* category). Any staff role reaches these (`staffOnly`); the full CMS block/hero
|
||||
* editor stays out of scope. Reads go through the typed [AdminRepository] (§7).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AdminContentViewModel @Inject constructor(
|
||||
private val admin: AdminRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The valid URL categories the backend maps (posts.model CATEGORY_MAP keys). */
|
||||
val postCategories = listOf("news", "five-on-friday", "newsletter", "screenshots")
|
||||
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val posts: UiState<List<AdminPostDto>> = UiState.Loading,
|
||||
val categories: UiState<List<AdminWikiCategoryDto>> = UiState.Loading,
|
||||
val tags: List<AdminWikiTagDto> = emptyList(),
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadPosts()
|
||||
loadWiki()
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
fun loadPosts() {
|
||||
_state.update { it.copy(posts = UiState.Loading) }
|
||||
viewModelScope.launch { _state.update { it.copy(posts = admin.posts().toUiState()) } }
|
||||
}
|
||||
|
||||
fun loadWiki() {
|
||||
_state.update { it.copy(categories = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(categories = admin.wikiCategories().toUiState()) }
|
||||
when (val tags = admin.wikiTags()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(tags = tags.data) }
|
||||
else -> Unit // tags are secondary; leave the last list on a failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePublish(post: AdminPostDto) = mutate(onSuccess = ::loadPosts) {
|
||||
admin.setPostPublished(post.id, !post.isPublished).asFeedback(R.string.admin_content_post_updated)
|
||||
}
|
||||
|
||||
fun deletePost(id: Long) = mutate(onSuccess = ::loadPosts) {
|
||||
admin.deletePost(id).asFeedback(R.string.admin_content_post_deleted)
|
||||
}
|
||||
|
||||
fun createPost(category: String, title: String, excerpt: String, body: String, published: Boolean) {
|
||||
if (title.isBlank()) {
|
||||
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_title_required)) }
|
||||
return
|
||||
}
|
||||
mutate(onSuccess = ::loadPosts) {
|
||||
admin.createPost(
|
||||
PostCreateRequest(
|
||||
category = category,
|
||||
title = title.trim(),
|
||||
excerpt = excerpt.ifBlank { null },
|
||||
body = body.ifBlank { null },
|
||||
published = published,
|
||||
),
|
||||
).asFeedback(R.string.admin_content_post_created)
|
||||
}
|
||||
}
|
||||
|
||||
fun createCategory(slug: String, title: String, description: String, sortOrder: Int?) {
|
||||
if (slug.isBlank() || title.isBlank()) {
|
||||
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_cat_fields_required)) }
|
||||
return
|
||||
}
|
||||
mutate(onSuccess = ::loadWiki) {
|
||||
admin.createWikiCategory(
|
||||
WikiCategoryRequest(slug.trim(), title.trim(), description.ifBlank { null }, sortOrder),
|
||||
).asFeedback(R.string.admin_content_cat_created)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCategory(id: Long) = mutate(onSuccess = ::loadWiki) {
|
||||
admin.deleteWikiCategory(id).asFeedback(R.string.admin_content_cat_deleted)
|
||||
}
|
||||
|
||||
// ── Shared mutation plumbing ──────────────────────────────────────────
|
||||
|
||||
/** Run a write: set busy + clear feedback, then on completion set the feedback
|
||||
* banner and, only if it succeeded, run [onSuccess] (a targeted reload). */
|
||||
private fun mutate(onSuccess: () -> Unit = {}, block: suspend () -> Feedback) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
val feedback = block()
|
||||
if (feedback.ok) onSuccess()
|
||||
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
|
||||
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
|
||||
is ApiResult.Ok -> Feedback(true, okRes)
|
||||
is ApiResult.HttpError ->
|
||||
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
|
||||
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
||||
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
||||
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,6 +68,37 @@
|
||||
<string name="admin_count_posts">Posts</string>
|
||||
<string name="admin_forbidden">You don\'t have permission for that action.</string>
|
||||
<string name="admin_action_failed">That action couldn\'t be completed. Please try again.</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
|
||||
<!-- Staff content (posts + wiki) -->
|
||||
<string name="admin_content_tab_posts">Posts</string>
|
||||
<string name="admin_content_tab_wiki">Wiki</string>
|
||||
<string name="admin_content_new_post">New post</string>
|
||||
<string name="admin_content_new_category">New category</string>
|
||||
<string name="admin_content_create">Create</string>
|
||||
<string name="admin_content_published">Published</string>
|
||||
<string name="admin_content_draft">Draft</string>
|
||||
<string name="admin_content_publish">Publish</string>
|
||||
<string name="admin_content_unpublish">Unpublish</string>
|
||||
<string name="admin_content_delete">Delete</string>
|
||||
<string name="admin_content_publish_now">Publish now</string>
|
||||
<string name="admin_content_field_title">Title</string>
|
||||
<string name="admin_content_field_excerpt">Excerpt</string>
|
||||
<string name="admin_content_field_body">Body</string>
|
||||
<string name="admin_content_field_slug">Slug</string>
|
||||
<string name="admin_content_field_description">Description</string>
|
||||
<string name="admin_content_field_sort">Sort order</string>
|
||||
<!-- %1$s slug, %2$d page count -->
|
||||
<string name="admin_content_cat_meta">%1$s · %2$d pages</string>
|
||||
<!-- %1$s comma-separated tag labels -->
|
||||
<string name="admin_content_tags">Tags: %1$s</string>
|
||||
<string name="admin_content_post_created">Post created.</string>
|
||||
<string name="admin_content_post_updated">Post updated.</string>
|
||||
<string name="admin_content_post_deleted">Post deleted.</string>
|
||||
<string name="admin_content_cat_created">Category created.</string>
|
||||
<string name="admin_content_cat_deleted">Category deleted.</string>
|
||||
<string name="admin_content_title_required">A title is required.</string>
|
||||
<string name="admin_content_cat_fields_required">Slug and title are required.</string>
|
||||
|
||||
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
||||
<string name="login_title">Sign in</string>
|
||||
|
||||
Reference in New Issue
Block a user