From 234b2550abab603c5caf7d8d3c83b97fcb28d3e9 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Fri, 7 Nov 2025 09:45:54 +0200 Subject: [PATCH] feat: sidebar categories --- lib/Migration/Version1Date20251106004226.php | 25 +++++- src/App.vue | 78 ++++++++++++++--- src/composables/useCategories.js | 92 ++++++++++++++++++++ src/views/CategoriesView.vue | 32 ++++--- 4 files changed, 199 insertions(+), 28 deletions(-) create mode 100644 src/composables/useCategories.js diff --git a/lib/Migration/Version1Date20251106004226.php b/lib/Migration/Version1Date20251106004226.php index 6df6a15..5104f21 100644 --- a/lib/Migration/Version1Date20251106004226.php +++ b/lib/Migration/Version1Date20251106004226.php @@ -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'), diff --git a/src/App.vue b/src/App.vue index 828932a..b0ec20e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,22 +3,39 @@ @@ -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 + }, }, } diff --git a/src/composables/useCategories.js b/src/composables/useCategories.js new file mode 100644 index 0000000..9ed2908 --- /dev/null +++ b/src/composables/useCategories.js @@ -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, + } +} diff --git a/src/views/CategoriesView.vue b/src/views/CategoriesView.vue index b95a306..94611f9 100644 --- a/src/views/CategoriesView.vue +++ b/src/views/CategoriesView.vue @@ -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) } },