feat: sidebar categories

This commit is contained in:
2025-11-07 09:45:54 +02:00
parent bbe4a1b647
commit 234b2550ab
4 changed files with 199 additions and 28 deletions

View File

@@ -575,13 +575,30 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$categoryId = $qb->getLastInsertId();
$generalCategoryId = $qb->getLastInsertId();
// Create "Support" category
$qb = $db->getQueryBuilder();
$qb->insert('forum_categories')
->values([
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'name' => $qb->createNamedParameter('Support'),
'description' => $qb->createNamedParameter('Ask questions about the forum, provide feedback or report issues.'),
'slug' => $qb->createNamedParameter('support'),
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$supportCategoryId = $qb->getLastInsertId();
// Create category permissions for user role
$qb = $db->getQueryBuilder();
$qb->insert('forum_category_perms')
->values([
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'category_id' => $qb->createNamedParameter($generalCategoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'role_id' => $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
@@ -594,7 +611,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$qb = $db->getQueryBuilder();
$qb->insert('forum_category_perms')
->values([
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'category_id' => $qb->createNamedParameter($generalCategoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'role_id' => $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
@@ -674,7 +691,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$qb = $db->getQueryBuilder();
$qb->insert('forum_threads')
->values([
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'category_id' => $qb->createNamedParameter($generalCategoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'author_id' => $qb->createNamedParameter('admin'),
'title' => $qb->createNamedParameter('Welcome to Nextcloud Forums'),
'slug' => $qb->createNamedParameter('welcome-to-nextcloud-forums'),

View File

@@ -3,22 +3,39 @@
<!-- Left sidebar -->
<NcAppNavigation>
<template #search>
<NcAppNavigationSearch
v-model="searchValue"
:label="strings.searchLabel"
:placeholder="strings.searchPlaceholder"
/>
<NcAppNavigationSearch v-model="searchValue" :label="strings.searchLabel"
:placeholder="strings.searchPlaceholder" />
</template>
<template #list>
<NcAppNavigationItem
:name="strings.navHome"
:to="{ path: '/' }"
:active="$route.path === '/' || $route.path === ''"
>
<NcAppNavigationItem :name="strings.navHome" :to="{ path: '/' }"
:open="true">
<template #icon>
<HomeIcon :size="20" />
</template>
<!-- Category headers as collapsible submenus -->
<NcAppNavigationItem
v-for="header in categoryHeaders"
:key="`header-${header.id}`"
:name="header.name"
:open="isHeaderOpen(header.id)"
@click.native.prevent="toggleHeader(header.id)">
<template #icon>
<FolderIcon :size="20" />
</template>
<!-- Categories under each header -->
<NcAppNavigationItem
v-for="category in header.categories"
:key="`category-${category.id}`"
:name="category.name"
:to="{ path: `/c/${category.slug}` }">
<template #icon>
<ForumIcon :size="20" />
</template>
</NcAppNavigationItem>
</NcAppNavigationItem>
</NcAppNavigationItem>
</template>
@@ -55,8 +72,11 @@ import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import HomeIcon from '@icons/Home.vue'
import ForumIcon from '@icons/Forum.vue'
import FolderIcon from '@icons/Folder.vue'
import PuzzleIcon from '@icons/Puzzle.vue'
import InfoIcon from '@icons/Information.vue'
import { useCategories } from '@/composables/useCategories.js'
export default {
name: 'AppUserWrapper',
@@ -68,9 +88,18 @@ export default {
NcAppNavigationSearch,
NcLoadingIcon,
HomeIcon,
ForumIcon,
FolderIcon,
PuzzleIcon,
InfoIcon,
},
setup() {
const { categoryHeaders, fetchCategories } = useCategories()
return {
categoryHeaders,
fetchCategories,
}
},
// Tell NcContent we *do* have a sidebar so it arranges layout properly
provide() {
return { 'NcContent:setHasAppNavigation': () => true }
@@ -79,6 +108,7 @@ export default {
return {
searchValue: '',
isRouterLoading: false,
openHeaders: {}, // Track which headers are open
// Mount path for this app section; adjust to your mount.
basePath: '/apps/forum',
strings: {
@@ -100,7 +130,21 @@ export default {
_removeAfterEach: null,
}
},
created() {
async created() {
// Fetch categories for sidebar
try {
await this.fetchCategories()
// Initialize all headers as open by default
const openState = {}
this.categoryHeaders.forEach((header) => {
openState[header.id] = true
})
this.openHeaders = openState
} catch (e) {
console.error('Failed to load categories for sidebar:', e)
}
// Show a loading overlay while routes are changing
this._removeBeforeEach = this.$router.beforeEach((to, from, next) => {
this.isRouterLoading = true
@@ -119,6 +163,18 @@ export default {
isPrefixRoute(prefix) {
return this.$route.path.startsWith(prefix)
},
toggleHeader(headerId) {
// Vue 3 doesn't need $set - direct assignment works with reactivity
this.openHeaders = {
...this.openHeaders,
[headerId]: !this.openHeaders[headerId]
}
},
isHeaderOpen(headerId) {
return this.openHeaders[headerId] !== false
},
},
}
</script>

View File

@@ -0,0 +1,92 @@
import { ref, computed } from 'vue'
import { ocs } from '@/axios'
// Shared state - will persist across components
// The API returns an array of headers, each with a nested 'categories' array
const categoryHeaders = ref([])
const loading = ref(false)
const error = ref(null)
const loaded = ref(false)
/**
* Composable for managing categories
* Provides shared state across components to avoid redundant API calls
*/
export function useCategories() {
/**
* Fetch categories from the API
* Uses cached data if already loaded
*/
const fetchCategories = async (force = false) => {
// Return cached data if already loaded and not forcing refresh
if (loaded.value && !force) {
return categoryHeaders.value
}
try {
loading.value = true
error.value = null
const response = await ocs.get('/categories')
categoryHeaders.value = response.data || []
loaded.value = true
return categoryHeaders.value
} catch (e) {
console.error('Failed to fetch categories:', e)
error.value = e.message || 'Failed to load categories'
throw e
} finally {
loading.value = false
}
}
/**
* Get all categories as a flat list (extracted from all headers)
* Useful for sidebar navigation
*/
const categoriesList = computed(() => {
const allCategories = []
categoryHeaders.value.forEach((header) => {
if (header.categories && Array.isArray(header.categories)) {
allCategories.push(...header.categories)
}
})
// Sort by sortOrder
return allCategories.sort((a, b) => a.sortOrder - b.sortOrder)
})
/**
* Refresh categories from the API
*/
const refresh = () => {
return fetchCategories(true)
}
/**
* Clear cached categories
*/
const clear = () => {
categoryHeaders.value = []
loaded.value = false
error.value = null
}
return {
// State
categoryHeaders,
loading,
error,
loaded,
// Computed
categoriesList,
// Methods
fetchCategories,
refresh,
clear,
}
}

View File

@@ -44,8 +44,8 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import CategoryCard from '@/components/CategoryCard.vue'
import { useCategories } from '@/composables/useCategories.js'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
export default {
@@ -56,11 +56,17 @@ export default {
NcLoadingIcon,
CategoryCard,
},
setup() {
const { categoryHeaders, loading, fetchCategories, refresh } = useCategories()
return {
categoryHeaders,
loading,
fetchCategories,
refreshCategories: refresh,
}
},
data() {
return {
loading: false,
categoryHeaders: [],
strings: {
title: t('forum', 'Categories'),
refresh: t('forum', 'Refresh'),
@@ -71,20 +77,20 @@ export default {
},
}
},
created() {
this.refresh()
async created() {
// Fetch categories if not already loaded
try {
await this.fetchCategories()
} catch (e) {
console.error('Failed to fetch categories', e)
}
},
methods: {
async refresh() {
try {
this.loading = true
const resp = await ocs.get('/categories')
this.categoryHeaders = resp.data || []
await this.refreshCategories()
} catch (e) {
console.error('Failed to fetch categories', e)
this.categoryHeaders = []
} finally {
this.loading = false
console.error('Failed to refresh categories', e)
}
},