feat: M10 — native SSO fixes + staff operations #21
@@ -4,11 +4,22 @@
|
|||||||
package com.runicgateway.app.data.api
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
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.SiteModeRequest
|
||||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
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.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.PATCH
|
||||||
import retrofit2.http.PUT
|
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
|
* 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 /admin/site-mode` — switch live/maintenance (admin only; 403 otherwise). */
|
||||||
@PUT("api/v1/admin/site-mode")
|
@PUT("api/v1/admin/site-mode")
|
||||||
suspend fun setSiteMode(@Body body: SiteModeRequest): SiteModeStateDto
|
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_at") val changedAt: String? = null,
|
||||||
@SerialName("changed_by") val changedBy: 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.core.result.safeApiCall
|
||||||
import com.runicgateway.app.data.api.AdminApi
|
import com.runicgateway.app.data.api.AdminApi
|
||||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
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.SiteModeRequest
|
||||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
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.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -27,4 +35,32 @@ class AdminRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setSiteMode(mode: String): ApiResult<SiteModeStateDto> =
|
suspend fun setSiteMode(mode: String): ApiResult<SiteModeStateDto> =
|
||||||
safeApiCall { api.setSiteMode(SiteModeRequest(mode)) }
|
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.navigation.visibleEntries
|
||||||
import com.runicgateway.app.ui.news.NewsScreen
|
import com.runicgateway.app.ui.news.NewsScreen
|
||||||
import com.runicgateway.app.ui.news.PostScreen
|
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.admin.AdminDashboardScreen
|
||||||
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
||||||
import com.runicgateway.app.ui.page.PageScreen
|
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.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||||
Routes.NOTIFICATIONS,
|
Routes.NOTIFICATIONS,
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
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)
|
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),
|
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.
|
// 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_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_count_posts">Posts</string>
|
||||||
<string name="admin_forbidden">You don\'t have permission for that action.</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="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) ──────────────────────────────────────────── -->
|
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
||||||
<string name="login_title">Sign in</string>
|
<string name="login_title">Sign in</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user