mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: migrate everything to typescript
This commit is contained in:
@@ -12,7 +12,7 @@ module.exports = [
|
||||
...tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended),
|
||||
{
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'no-unused-vars': ['off'],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
|
||||
41
src/App.vue
41
src/App.vue
@@ -3,13 +3,15 @@
|
||||
<!-- 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: '/' }"
|
||||
:open="true">
|
||||
<NcAppNavigationItem :name="strings.navHome" :to="{ path: '/' }" :open="true">
|
||||
<template #icon>
|
||||
<HomeIcon :size="20" />
|
||||
</template>
|
||||
@@ -20,7 +22,8 @@
|
||||
:key="`header-${header.id}`"
|
||||
:name="header.name"
|
||||
:open="isHeaderOpen(header.id)"
|
||||
@click.native.prevent="toggleHeader(header.id)">
|
||||
@click.native.prevent="toggleHeader(header.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<FolderIcon :size="20" />
|
||||
</template>
|
||||
@@ -30,7 +33,8 @@
|
||||
v-for="category in header.categories"
|
||||
:key="`category-${category.id}`"
|
||||
:name="category.name"
|
||||
:to="{ path: `/c/${category.slug}` }">
|
||||
:to="{ path: `/c/${category.slug}` }"
|
||||
>
|
||||
<template #icon>
|
||||
<ForumIcon :size="20" />
|
||||
</template>
|
||||
@@ -63,7 +67,8 @@
|
||||
</NcContent>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcContent from '@nextcloud/vue/components/NcContent'
|
||||
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||
@@ -76,9 +81,9 @@ 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'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: 'AppUserWrapper',
|
||||
components: {
|
||||
NcContent,
|
||||
@@ -108,7 +113,7 @@ export default {
|
||||
return {
|
||||
searchValue: '',
|
||||
isRouterLoading: false,
|
||||
openHeaders: {}, // Track which headers are open
|
||||
openHeaders: {} as Record<number, boolean>, // Track which headers are open
|
||||
// Mount path for this app section; adjust to your mount.
|
||||
basePath: '/apps/forum',
|
||||
strings: {
|
||||
@@ -126,8 +131,8 @@ export default {
|
||||
navExamples: t('forum', 'Examples'),
|
||||
navAbout: t('forum', 'About'),
|
||||
},
|
||||
_removeBeforeEach: null,
|
||||
_removeAfterEach: null,
|
||||
_removeBeforeEach: null as (() => void) | null,
|
||||
_removeAfterEach: null as (() => void) | null,
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
@@ -136,7 +141,7 @@ export default {
|
||||
await this.fetchCategories()
|
||||
|
||||
// Initialize all headers as open by default
|
||||
const openState = {}
|
||||
const openState: Record<number, boolean> = {}
|
||||
this.categoryHeaders.forEach((header) => {
|
||||
openState[header.id] = true
|
||||
})
|
||||
@@ -160,23 +165,23 @@ export default {
|
||||
if (typeof this._removeAfterEach === 'function') this._removeAfterEach()
|
||||
},
|
||||
methods: {
|
||||
isPrefixRoute(prefix) {
|
||||
isPrefixRoute(prefix: string): boolean {
|
||||
return this.$route.path.startsWith(prefix)
|
||||
},
|
||||
|
||||
toggleHeader(headerId) {
|
||||
toggleHeader(headerId: number): void {
|
||||
// Vue 3 doesn't need $set - direct assignment works with reactivity
|
||||
this.openHeaders = {
|
||||
...this.openHeaders,
|
||||
[headerId]: !this.openHeaders[headerId]
|
||||
[headerId]: !this.openHeaders[headerId],
|
||||
}
|
||||
},
|
||||
|
||||
isHeaderOpen(headerId) {
|
||||
isHeaderOpen(headerId: number): boolean {
|
||||
return this.openHeaders[headerId] !== false
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -19,14 +19,16 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import type { Category } from '@/types'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: 'CategoryCard',
|
||||
props: {
|
||||
category: {
|
||||
type: Object,
|
||||
type: Object as PropType<Category>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
@@ -39,7 +41,7 @@ export default {
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
@@ -57,8 +58,9 @@ import PencilIcon from '@icons/Pencil.vue'
|
||||
import DeleteIcon from '@icons/Delete.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import type { Post } from '@/types'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: 'PostCard',
|
||||
components: {
|
||||
NcAvatar,
|
||||
@@ -71,7 +73,7 @@ export default {
|
||||
},
|
||||
props: {
|
||||
post: {
|
||||
type: Object,
|
||||
type: Object as PropType<Post>,
|
||||
required: true,
|
||||
},
|
||||
isFirstPost: {
|
||||
@@ -94,22 +96,21 @@ export default {
|
||||
currentUser() {
|
||||
return getCurrentUser()
|
||||
},
|
||||
canEdit() {
|
||||
return this.currentUser && this.currentUser.uid === this.post.authorId
|
||||
canEdit(): boolean {
|
||||
return this.currentUser !== null && this.currentUser.uid === this.post.authorId
|
||||
},
|
||||
canDelete() {
|
||||
canDelete(): boolean {
|
||||
// For now, only author can delete. Later add admin/moderator check
|
||||
return this.currentUser && this.currentUser.uid === this.post.authorId
|
||||
return this.currentUser !== null && this.currentUser.uid === this.post.authorId
|
||||
},
|
||||
formattedContent() {
|
||||
formattedContent(): string {
|
||||
// Content is already parsed by BBCodeService on the backend
|
||||
// BBCodeService handles HTML escaping before parsing BBCodes
|
||||
return this.post.content
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
}
|
||||
methods: {},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -47,15 +47,17 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import PinIcon from '@icons/Pin.vue'
|
||||
import LockIcon from '@icons/Lock.vue'
|
||||
import CommentIcon from '@icons/Comment.vue'
|
||||
import EyeIcon from '@icons/Eye.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import type { Thread } from '@/types'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: 'ThreadCard',
|
||||
components: {
|
||||
NcDateTime,
|
||||
@@ -66,7 +68,7 @@ export default {
|
||||
},
|
||||
props: {
|
||||
thread: {
|
||||
type: Object,
|
||||
type: Object as PropType<Thread>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
@@ -81,7 +83,7 @@ export default {
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
73
src/composables/useCategories.ts
Normal file
73
src/composables/useCategories.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { ocs } from '@/axios'
|
||||
import type { CategoryHeader } from '@/types'
|
||||
|
||||
// Shared state - will persist across components
|
||||
// The API returns an array of headers, each with a nested 'categories' array
|
||||
const categoryHeaders = ref<CategoryHeader[]>([])
|
||||
const loading = ref<boolean>(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loaded = ref<boolean>(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): Promise<CategoryHeader[]> => {
|
||||
// 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<CategoryHeader[]>('/categories')
|
||||
categoryHeaders.value = response.data || []
|
||||
loaded.value = true
|
||||
|
||||
return categoryHeaders.value
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch categories:', e)
|
||||
error.value = (e as Error).message || 'Failed to load categories'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh categories from the API
|
||||
*/
|
||||
const refresh = (): Promise<CategoryHeader[]> => {
|
||||
return fetchCategories(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached categories
|
||||
*/
|
||||
const clear = (): void => {
|
||||
categoryHeaders.value = []
|
||||
loaded.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
categoryHeaders: categoryHeaders as Ref<CategoryHeader[]>,
|
||||
loading: loading as Ref<boolean>,
|
||||
error: error as Ref<string | null>,
|
||||
loaded: loaded as Ref<boolean>,
|
||||
|
||||
// Methods
|
||||
fetchCategories,
|
||||
refresh,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
5
src/types/index.ts
Normal file
5
src/types/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Central export for all TypeScript types
|
||||
*/
|
||||
|
||||
export * from './models'
|
||||
125
src/types/models.ts
Normal file
125
src/types/models.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Forum TypeScript Models
|
||||
* These interfaces match the backend JSON responses
|
||||
*/
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
headerId: number
|
||||
name: string
|
||||
description: string | null
|
||||
slug: string
|
||||
sortOrder: number
|
||||
threadCount: number
|
||||
postCount: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface CategoryHeader {
|
||||
id: number
|
||||
name: string
|
||||
sortOrder: number
|
||||
createdAt: number
|
||||
categories?: Category[]
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: number
|
||||
categoryId: number
|
||||
authorId: string
|
||||
title: string
|
||||
slug: string
|
||||
viewCount: number
|
||||
postCount: number
|
||||
lastPostId: number | null
|
||||
isLocked: boolean
|
||||
isPinned: boolean
|
||||
isHidden: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
// Enriched fields (added by Thread::enrichThreadAuthor)
|
||||
authorDisplayName?: string
|
||||
authorIsDeleted?: boolean
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: number
|
||||
threadId: number
|
||||
authorId: string
|
||||
content: string
|
||||
contentRaw: string
|
||||
slug: string
|
||||
isEdited: boolean
|
||||
editedAt: number | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
// Enriched fields (added by Post::enrichPostContent)
|
||||
authorDisplayName?: string
|
||||
authorIsDeleted?: boolean
|
||||
}
|
||||
|
||||
export interface ForumUser {
|
||||
id: number
|
||||
userId: string
|
||||
postCount: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
deletedAt: number | null
|
||||
isDeleted: boolean
|
||||
}
|
||||
|
||||
export interface BBCode {
|
||||
id: number
|
||||
tag: string
|
||||
replacement: string
|
||||
description: string | null
|
||||
enabled: boolean
|
||||
parseInner: boolean
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface ReadMarker {
|
||||
id: number
|
||||
userId: string
|
||||
threadId: number
|
||||
lastReadPostId: number
|
||||
readAt: number
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface UserRole {
|
||||
id: number
|
||||
userId: string
|
||||
roleId: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: number
|
||||
postId: number
|
||||
userId: string
|
||||
reactionType: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number
|
||||
postId: number
|
||||
fileid: number
|
||||
filename: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface CatHeader {
|
||||
id: number
|
||||
name: string
|
||||
sortOrder: number
|
||||
createdAt: number
|
||||
}
|
||||
@@ -18,8 +18,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent v-else-if="categoryHeaders.length === 0" :title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc" class="mt-16" />
|
||||
<NcEmptyContent
|
||||
v-else-if="categoryHeaders.length === 0"
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
/>
|
||||
|
||||
<!-- Categories list -->
|
||||
<section v-else class="mt-16">
|
||||
@@ -28,8 +32,12 @@
|
||||
|
||||
<!-- Categories grid -->
|
||||
<div v-if="header.categories && header.categories.length > 0" class="categories-grid">
|
||||
<CategoryCard v-for="category in header.categories" :key="category.id" :category="category"
|
||||
@click="navigateToCategory(category)" />
|
||||
<CategoryCard
|
||||
v-for="category in header.categories"
|
||||
:key="category.id"
|
||||
:category="category"
|
||||
@click="navigateToCategory(category)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for header with no categories -->
|
||||
@@ -39,16 +47,17 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<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 CategoryCard from '@/components/CategoryCard.vue'
|
||||
import { useCategories } from '@/composables/useCategories.js'
|
||||
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
import type { Category } from '@/types'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: 'CategoriesView',
|
||||
components: {
|
||||
NcButton,
|
||||
@@ -94,11 +103,11 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
navigateToCategory(category) {
|
||||
navigateToCategory(category: Category) {
|
||||
this.$router.push(`/c/${category.slug}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<NcButton type="tertiary" @click="goBack">{{ strings.back }}</NcButton>
|
||||
<NcButton @click="goBack">{{ strings.back }}</NcButton>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
|
||||
<NcButton type="primary" @click="createThread" :disabled="loading || category?.isLocked">
|
||||
<NcButton @click="createThread" :disabled="loading">
|
||||
{{ strings.newThread }}
|
||||
</NcButton>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton type="primary" @click="createThread">{{ strings.newThread }}</NcButton>
|
||||
<NcButton @click="createThread">{{ strings.newThread }}</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
@@ -69,16 +69,17 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<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 ThreadCard from '@/components/ThreadCard.vue'
|
||||
|
||||
import type { Category, Thread } from '@/types'
|
||||
import { ocs } from '@/axios'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: 'CategoryView',
|
||||
components: {
|
||||
NcButton,
|
||||
@@ -89,9 +90,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
category: null,
|
||||
threads: [],
|
||||
error: null,
|
||||
category: null as Category | null,
|
||||
threads: [] as Thread[],
|
||||
error: null as string | null,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
|
||||
@@ -104,18 +105,19 @@ export default {
|
||||
emptyTitle: t('forum', 'No threads yet'),
|
||||
emptyDesc: t('forum', 'Be the first to start a discussion in this category.'),
|
||||
retry: t('forum', 'Retry'),
|
||||
showingThreads: (count) => n('forum', 'Showing %n thread', 'Showing %n threads', count),
|
||||
showingThreads: (count: number) =>
|
||||
n('forum', 'Showing %n thread', 'Showing %n threads', count),
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
categoryId() {
|
||||
return this.$route.params.id ? parseInt(this.$route.params.id) : null
|
||||
categoryId(): number | null {
|
||||
return this.$route.params.id ? parseInt(this.$route.params.id as string) : null
|
||||
},
|
||||
categorySlug() {
|
||||
return this.$route.params.slug || null
|
||||
categorySlug(): string | null {
|
||||
return (this.$route.params.slug as string) || null
|
||||
},
|
||||
sortedThreads() {
|
||||
sortedThreads(): Thread[] {
|
||||
// Sort pinned threads first, then by updatedAt descending
|
||||
return [...this.threads].sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) {
|
||||
@@ -143,7 +145,7 @@ export default {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh', e)
|
||||
this.error = e.message || t('forum', 'An unexpected error occurred')
|
||||
this.error = (e as Error).message || t('forum', 'An unexpected error occurred')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
@@ -153,9 +155,9 @@ export default {
|
||||
try {
|
||||
let resp
|
||||
if (this.categorySlug) {
|
||||
resp = await ocs.get(`/categories/slug/${this.categorySlug}`)
|
||||
resp = await ocs.get<Category>(`/categories/slug/${this.categorySlug}`)
|
||||
} else if (this.categoryId) {
|
||||
resp = await ocs.get(`/categories/${this.categoryId}`)
|
||||
resp = await ocs.get<Category>(`/categories/${this.categoryId}`)
|
||||
} else {
|
||||
throw new Error(t('forum', 'No category ID or slug provided'))
|
||||
}
|
||||
@@ -168,7 +170,7 @@ export default {
|
||||
|
||||
async fetchThreads() {
|
||||
try {
|
||||
const resp = await ocs.get(`/categories/${this.category.id}/threads`, {
|
||||
const resp = await ocs.get<Thread[]>(`/categories/${this.category!.id}/threads`, {
|
||||
params: {
|
||||
limit: this.limit,
|
||||
offset: this.offset,
|
||||
@@ -181,7 +183,7 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
navigateToThread(thread) {
|
||||
navigateToThread(thread: Thread) {
|
||||
this.$router.push(`/t/${thread.slug}`)
|
||||
},
|
||||
|
||||
@@ -194,7 +196,7 @@ export default {
|
||||
this.$router.back()
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<NcButton type="tertiary" @click="goBack">{{ strings.back }}</NcButton>
|
||||
<NcButton @click="goBack">{{ strings.back }}</NcButton>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
|
||||
<NcButton type="primary" @click="replyToThread" :disabled="loading || thread?.isLocked">
|
||||
<NcButton @click="replyToThread" :disabled="loading || thread?.isLocked">
|
||||
{{ strings.reply }}
|
||||
</NcButton>
|
||||
</div>
|
||||
@@ -21,7 +21,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<NcEmptyContent v-else-if="error" :title="strings.errorTitle" :description="error" class="mt-16">
|
||||
<NcEmptyContent
|
||||
v-else-if="error"
|
||||
:title="strings.errorTitle"
|
||||
:description="error"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
|
||||
</template>
|
||||
@@ -64,8 +69,15 @@
|
||||
<!-- Posts list -->
|
||||
<section v-if="!loading && !error && posts.length > 0" class="mt-16">
|
||||
<div class="posts-list">
|
||||
<PostCard v-for="(post, index) in posts" :key="post.id" :post="post" :is-first-post="index === 0"
|
||||
@reply="handleReply" @edit="handleEdit" @delete="handleDelete" />
|
||||
<PostCard
|
||||
v-for="(post, index) in posts"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:is-first-post="index === 0"
|
||||
@reply="handleReply"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pagination info -->
|
||||
@@ -75,16 +87,21 @@
|
||||
</section>
|
||||
|
||||
<!-- Empty posts state (thread exists but no posts) -->
|
||||
<NcEmptyContent v-else-if="!loading && !error && thread && posts.length === 0" :title="strings.emptyPostsTitle"
|
||||
:description="strings.emptyPostsDesc" class="mt-16">
|
||||
<NcEmptyContent
|
||||
v-else-if="!loading && !error && thread && posts.length === 0"
|
||||
:title="strings.emptyPostsTitle"
|
||||
:description="strings.emptyPostsDesc"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton type="primary" @click="replyToThread">{{ strings.reply }}</NcButton>
|
||||
<NcButton @click="replyToThread">{{ strings.reply }}</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</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'
|
||||
@@ -93,11 +110,11 @@ import PostCard from '@/components/PostCard.vue'
|
||||
import PinIcon from '@icons/Pin.vue'
|
||||
import LockIcon from '@icons/Lock.vue'
|
||||
import EyeIcon from '@icons/Eye.vue'
|
||||
|
||||
import type { Thread, Post } from '@/types'
|
||||
import { ocs } from '@/axios'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: 'ThreadView',
|
||||
components: {
|
||||
NcButton,
|
||||
@@ -112,9 +129,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
thread: null,
|
||||
posts: [],
|
||||
error: null,
|
||||
thread: null as Thread | null,
|
||||
posts: [] as Post[],
|
||||
error: null as string | null,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
|
||||
@@ -128,26 +145,26 @@ export default {
|
||||
emptyPostsDesc: t('forum', 'Be the first to post in this thread.'),
|
||||
retry: t('forum', 'Retry'),
|
||||
by: t('forum', 'by'),
|
||||
views: (count: string) => n('forum', '%n view', '%n views', count),
|
||||
views: (count: number) => n('forum', '%n view', '%n views', count),
|
||||
pinned: t('forum', 'Pinned thread'),
|
||||
locked: t('forum', 'Locked thread'),
|
||||
showingPosts: (count) => n('forum', 'Showing %n post', 'Showing %n posts', count),
|
||||
showingPosts: (count: number) => n('forum', 'Showing %n post', 'Showing %n posts', count),
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
threadId() {
|
||||
return this.$route.params.id ? parseInt(this.$route.params.id) : null
|
||||
threadId(): number | null {
|
||||
return this.$route.params.id ? parseInt(this.$route.params.id as string) : null
|
||||
},
|
||||
threadSlug() {
|
||||
return this.$route.params.slug || null
|
||||
threadSlug(): string | null {
|
||||
return (this.$route.params.slug as string) || null
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.refresh()
|
||||
},
|
||||
methods: {
|
||||
async refresh() {
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
@@ -161,19 +178,19 @@ export default {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh', e)
|
||||
this.error = e.message || t('forum', 'An unexpected error occurred')
|
||||
this.error = (e as Error).message || t('forum', 'An unexpected error occurred')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchThread() {
|
||||
async fetchThread(): Promise<void> {
|
||||
try {
|
||||
let resp
|
||||
if (this.threadSlug) {
|
||||
resp = await ocs.get(`/threads/slug/${this.threadSlug}`)
|
||||
resp = await ocs.get<Thread>(`/threads/slug/${this.threadSlug}`)
|
||||
} else if (this.threadId) {
|
||||
resp = await ocs.get(`/threads/${this.threadId}`)
|
||||
resp = await ocs.get<Thread>(`/threads/${this.threadId}`)
|
||||
} else {
|
||||
throw new Error(t('forum', 'No thread ID or slug provided'))
|
||||
}
|
||||
@@ -184,9 +201,9 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async fetchPosts() {
|
||||
async fetchPosts(): Promise<void> {
|
||||
try {
|
||||
const resp = await ocs.get(`/threads/${this.thread.id}/posts`, {
|
||||
const resp = await ocs.get<Post[]>(`/threads/${this.thread!.id}/posts`, {
|
||||
params: {
|
||||
limit: this.limit,
|
||||
offset: this.offset,
|
||||
@@ -204,7 +221,7 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async markAsRead() {
|
||||
async markAsRead(): Promise<void> {
|
||||
try {
|
||||
// Get the last post ID from the current view
|
||||
const lastPost = this.posts[this.posts.length - 1]
|
||||
@@ -223,19 +240,19 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
handleReply(post) {
|
||||
handleReply(post: Post): void {
|
||||
console.log('Reply to post:', post.id)
|
||||
// TODO: Implement reply functionality
|
||||
// Could open a reply form or navigate to a reply page
|
||||
},
|
||||
|
||||
handleEdit(post) {
|
||||
handleEdit(post: Post): void {
|
||||
console.log('Edit post:', post.id)
|
||||
// TODO: Implement edit functionality
|
||||
// Could open an edit dialog or navigate to edit page
|
||||
},
|
||||
|
||||
async handleDelete(post) {
|
||||
async handleDelete(post: Post): Promise<void> {
|
||||
console.log('Delete post:', post.id)
|
||||
// TODO: Implement delete functionality with confirmation
|
||||
// if (confirm(t('forum', 'Are you sure you want to delete this post?'))) {
|
||||
@@ -244,17 +261,17 @@ export default {
|
||||
// }
|
||||
},
|
||||
|
||||
replyToThread() {
|
||||
replyToThread(): void {
|
||||
console.log('Reply to thread:', this.thread?.id)
|
||||
// TODO: Implement reply to thread functionality
|
||||
// Could open a reply form at the bottom or navigate to a reply page
|
||||
},
|
||||
|
||||
goBack() {
|
||||
goBack(): void {
|
||||
this.$router.back()
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
Reference in New Issue
Block a user