feat: admin settings + forum title/subtitle

This commit is contained in:
2025-11-10 15:37:16 +02:00
parent f529dce767
commit d6146375a7
6 changed files with 589 additions and 13 deletions

View File

@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\PostMapper;
@@ -19,12 +20,14 @@ use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class AdminController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
@@ -36,6 +39,7 @@ class AdminController extends OCSController {
private UserRoleMapper $userRoleMapper,
private IUserManager $userManager,
private IUserSession $userSession,
private IConfig $config,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
@@ -154,6 +158,65 @@ class AdminController extends OCSController {
}
}
/**
* Get general forum settings
*
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Settings returned
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[ApiRoute(verb: 'GET', url: '/api/admin/settings')]
public function getSettings(): DataResponse {
try {
$settings = [
'title' => $this->config->getAppValue(Application::APP_ID, 'title', 'Forum'),
'subtitle' => $this->config->getAppValue(Application::APP_ID, 'subtitle', 'Welcome to the forum'),
];
return new DataResponse($settings);
} catch (\Exception $e) {
$this->logger->error('Error fetching settings: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch settings'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update general forum settings
*
* @param string|null $title Forum title
* @param string|null $subtitle Forum subtitle
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Settings updated
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[ApiRoute(verb: 'PUT', url: '/api/admin/settings')]
public function updateSettings(?string $title = null, ?string $subtitle = null): DataResponse {
try {
if ($title !== null) {
$this->config->setAppValue(Application::APP_ID, 'title', $title);
}
if ($subtitle !== null) {
$this->config->setAppValue(Application::APP_ID, 'subtitle', $subtitle);
}
// Return updated settings
$settings = [
'title' => $this->config->getAppValue(Application::APP_ID, 'title', 'Forum'),
'subtitle' => $this->config->getAppValue(Application::APP_ID, 'subtitle', 'Welcome to the forum'),
];
return new DataResponse($settings);
} catch (\Exception $e) {
$this->logger->error('Error updating settings: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to update settings'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Check if user has admin role
*/

View File

@@ -242,6 +242,212 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/settings": {
"get": {
"operationId": "admin-get-settings",
"summary": "Get general forum settings",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Settings returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"put": {
"operationId": "admin-update-settings",
"summary": "Update general forum settings",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"nullable": true,
"default": null,
"description": "Forum title"
},
"subtitle": {
"type": "string",
"nullable": true,
"default": null,
"description": "Forum subtitle"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Settings updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/posts/{postId}/attachments": {
"get": {
"operationId": "attachment-by-post",

View File

@@ -130,6 +130,16 @@
<CodeBracketsIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.navAdminSettings"
:to="{ path: '/admin/settings' }"
:active="isAdminSettingsActive"
>
<template #icon>
<CogIcon :size="20" />
</template>
</NcAppNavigationItem>
</template>
</NcAppNavigationItem>
</template>
@@ -180,6 +190,7 @@ import ShieldAccountIcon from '@icons/ShieldAccount.vue'
import ChartLineIcon from '@icons/ChartLine.vue'
import AccountMultipleIcon from '@icons/AccountMultiple.vue'
import CodeBracketsIcon from '@icons/CodeBrackets.vue'
import CogIcon from '@icons/Cog.vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import { useCategories } from '@/composables/useCategories'
import { useCurrentUser } from '@/composables/useCurrentUser'
@@ -211,6 +222,7 @@ export default defineComponent({
ChartLineIcon,
AccountMultipleIcon,
CodeBracketsIcon,
CogIcon,
},
setup() {
const { categoryHeaders, fetchCategories } = useCategories()
@@ -259,6 +271,7 @@ export default defineComponent({
navAdminRoles: t('forum', 'Roles'),
navAdminCategories: t('forum', 'Categories'),
navAdminBBCodes: t('forum', 'BBCodes'),
navAdminSettings: t('forum', 'Settings'),
navExamples: t('forum', 'Examples'),
navAbout: t('forum', 'About'),
},
@@ -285,6 +298,9 @@ export default defineComponent({
isAdminBBCodesActive(): boolean {
return this.$route.path.startsWith('/admin/bbcodes')
},
isAdminSettingsActive(): boolean {
return this.$route.path === '/admin/settings'
},
},
async created() {
// Fetch categories for sidebar

View File

@@ -14,6 +14,7 @@ const routes: RouteRecordRaw[] = [
{ path: '/u/:userId', component: () => import('@/views/ProfileView.vue') },
{ path: '/search', component: () => import('@/views/SearchView.vue') },
{ path: '/admin', component: () => import('@/views/admin/AdminDashboard.vue') },
{ path: '/admin/settings', component: () => import('@/views/admin/AdminGeneralSettings.vue') },
{ path: '/admin/users', component: () => import('@/views/admin/AdminUserList.vue') },
{ path: '/admin/roles', component: () => import('@/views/admin/AdminRoleList.vue') },
{ path: '/admin/roles/create', component: () => import('@/views/admin/AdminRoleEdit.vue') },

View File

@@ -1,8 +1,8 @@
<template>
<div class="categories-view">
<header class="page-header">
<h2>{{ strings.mainTitle }}</h2>
<p class="muted" v-html="strings.subtitle"></p>
<h2>{{ forumTitle }}</h2>
<p class="muted">{{ forumSubtitle }}</p>
</header>
<!-- Toolbar -->
@@ -60,6 +60,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import CategoryCard from '@/components/CategoryCard.vue'
import { useCategories } from '@/composables/useCategories'
import type { Category } from '@/types'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
export default defineComponent({
@@ -81,15 +82,9 @@ export default defineComponent({
},
data() {
return {
forumTitle: t('forum', 'Forum'),
forumSubtitle: t('forum', 'Welcome to the forum'),
strings: {
mainTitle: t('forum', 'Hello World — App'),
subtitle: t(
'forum',
'Use the sidebar to navigate between views. Backend calls use {cStart}axios{cEnd} and OCS responses.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
title: t('forum', 'Categories'),
refresh: t('forum', 'Refresh'),
loading: t('forum', 'Loading…'),
@@ -100,14 +95,25 @@ export default defineComponent({
}
},
async created() {
// Fetch categories if not already loaded
// Fetch forum settings and categories
try {
await this.fetchCategories()
await Promise.all([this.fetchForumSettings(), this.fetchCategories()])
} catch (e) {
console.error('Failed to fetch categories', e)
console.error('Failed to fetch initial data', e)
}
},
methods: {
async fetchForumSettings() {
try {
const response = await ocs.get<{ title: string; subtitle: string }>('/admin/settings')
this.forumTitle = response.data.title || t('forum', 'Forum')
this.forumSubtitle = response.data.subtitle || t('forum', 'Welcome to the forum')
} catch (e) {
// Silently fail and use defaults if settings can't be loaded
console.debug('Could not load forum settings, using defaults', e)
}
},
async refresh() {
try {
await this.refreshCategories()

View File

@@ -0,0 +1,284 @@
<template>
<div class="admin-general-settings">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent v-else-if="error" :title="strings.errorTitle" :description="error" class="mt-16">
<template #action>
<NcButton @click="loadSettings">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Settings form -->
<div v-else class="settings-form">
<div class="form-section">
<h3>{{ strings.appearanceTitle }}</h3>
<p class="muted">{{ strings.appearanceDesc }}</p>
<div class="form-group">
<label for="forum-title">{{ strings.forumTitle }}</label>
<NcTextField
id="forum-title"
v-model.trim="formData.title"
:placeholder="strings.forumTitlePlaceholder"
:maxlength="100"
/>
<p class="hint">{{ strings.forumTitleHint }}</p>
</div>
<div class="form-group">
<label for="forum-subtitle">{{ strings.forumSubtitle }}</label>
<NcTextArea
id="forum-subtitle"
v-model.trim="formData.subtitle"
:placeholder="strings.forumSubtitlePlaceholder"
:rows="3"
:maxlength="500"
/>
<p class="hint">{{ strings.forumSubtitleHint }}</p>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<NcButton :disabled="saving || !hasChanges" @click="saveSettings">
<template #icon>
<NcLoadingIcon v-if="saving" :size="20" />
<CheckIcon v-else :size="20" />
</template>
{{ strings.save }}
</NcButton>
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
{{ strings.cancel }}
</NcButton>
</div>
<!-- Success message -->
<div v-if="saveSuccess" class="success-message">
<CheckIcon :size="20" />
<span>{{ strings.saveSuccess }}</span>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import CheckIcon from '@icons/Check.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
interface Settings {
title: string
subtitle: string
}
export default defineComponent({
name: 'AdminGeneralSettings',
components: {
NcButton,
NcEmptyContent,
NcLoadingIcon,
NcTextField,
NcTextArea,
CheckIcon,
},
data() {
return {
loading: false,
saving: false,
saveSuccess: false,
error: null as string | null,
originalData: {
title: '',
subtitle: '',
} as Settings,
formData: {
title: '',
subtitle: '',
} as Settings,
strings: {
title: t('forum', 'General Settings'),
subtitle: t('forum', 'Configure general forum settings'),
loading: t('forum', 'Loading settings…'),
errorTitle: t('forum', 'Error loading settings'),
retry: t('forum', 'Retry'),
appearanceTitle: t('forum', 'Appearance'),
appearanceDesc: t('forum', 'Customize how your forum looks to users'),
forumTitle: t('forum', 'Forum Title'),
forumTitlePlaceholder: t('forum', 'Forum'),
forumTitleHint: t('forum', 'Displayed at the top of the forum home page'),
forumSubtitle: t('forum', 'Forum Subtitle'),
forumSubtitlePlaceholder: t('forum', 'Welcome to the forum'),
forumSubtitleHint: t('forum', 'A brief description shown below the title'),
save: t('forum', 'Save'),
cancel: t('forum', 'Cancel'),
saveSuccess: t('forum', 'Settings saved successfully'),
},
}
},
computed: {
hasChanges(): boolean {
return (
this.formData.title !== this.originalData.title ||
this.formData.subtitle !== this.originalData.subtitle
)
},
},
created() {
this.loadSettings()
},
methods: {
async loadSettings(): Promise<void> {
try {
this.loading = true
this.error = null
const response = await ocs.get<Settings>('/admin/settings')
this.originalData = { ...response.data }
this.formData = { ...response.data }
} catch (e) {
console.error('Failed to load settings', e)
this.error = (e as Error).message || t('forum', 'An unexpected error occurred')
} finally {
this.loading = false
}
},
async saveSettings(): Promise<void> {
try {
this.saving = true
this.saveSuccess = false
await ocs.put('/admin/settings', this.formData)
this.originalData = { ...this.formData }
this.saveSuccess = true
// Hide success message after 3 seconds
setTimeout(() => {
this.saveSuccess = false
}, 3000)
} catch (e) {
console.error('Failed to save settings', e)
this.error = (e as Error).message || t('forum', 'Failed to save settings')
} finally {
this.saving = false
}
},
resetForm(): void {
this.formData = { ...this.originalData }
this.saveSuccess = false
},
},
})
</script>
<style scoped lang="scss">
.admin-general-settings {
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
.mt-16 {
margin-top: 16px;
}
.ml-8 {
margin-left: 8px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
.page-header {
margin-bottom: 24px;
h2 {
margin: 0 0 6px 0;
}
}
.settings-form {
max-width: 800px;
.form-section {
margin-bottom: 32px;
padding: 24px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
h3 {
margin: 0 0 8px 0;
font-size: 1.1rem;
font-weight: 600;
}
> p {
margin: 0 0 20px 0;
font-size: 0.9rem;
}
}
.form-group {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--color-main-text);
}
.hint {
margin: 6px 0 0 0;
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
}
}
.form-actions {
display: flex;
gap: 12px;
align-items: center;
}
.success-message {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 16px;
background: var(--color-success-light);
color: var(--color-success-dark);
border-radius: 6px;
font-weight: 500;
}
}
}
</style>