mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: sidebar categories
This commit is contained in:
@@ -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'),
|
||||
|
||||
78
src/App.vue
78
src/App.vue
@@ -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>
|
||||
|
||||
92
src/composables/useCategories.js
Normal file
92
src/composables/useCategories.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user