feat: M10 — native SSO fixes + staff operations #21

Merged
whitlocktech merged 6 commits from feat/m10-native-sso-fix into main 2026-07-21 21:38:49 +00:00
8 changed files with 612 additions and 1 deletions
Showing only changes of commit c5596845c1 - Show all commits

View File

@@ -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>
}

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -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() }
}
}
}

View File

@@ -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))
}
},
)
}

View File

@@ -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)
}
}

View File

@@ -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),
)
/**

View File

@@ -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>