feat: create threads view/form

This commit is contained in:
2025-11-09 21:00:05 +02:00
parent be778fa2d2
commit 5a8a0ab400
8 changed files with 575 additions and 57 deletions

View File

@@ -137,7 +137,6 @@ class ThreadController extends OCSController {
*
* @param int $categoryId Category ID
* @param string $title Thread title
* @param string $slug Thread slug
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
*
* 201: Thread created
@@ -145,13 +144,19 @@ class ThreadController extends OCSController {
#[NoAdminRequired]
#[RequirePermission('canPost', resourceType: 'category', resourceIdBody: 'categoryId')]
#[ApiRoute(verb: 'POST', url: '/api/threads')]
public function create(int $categoryId, string $title, string $slug): DataResponse {
public function create(int $categoryId, string $title): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
// Generate slug from title
$slug = $this->generateSlug($title);
// Ensure slug is unique
$slug = $this->ensureUniqueSlug($slug);
$thread = new \OCA\Forum\Db\Thread();
$thread->setCategoryId($categoryId);
$thread->setAuthorId($user->getUID());
@@ -254,4 +259,59 @@ class ThreadController extends OCSController {
return new DataResponse(['error' => 'Failed to delete thread'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Generate a URL-friendly slug from a string
*
* @param string $text The text to convert to a slug
* @return string A URL-friendly slug
*/
private function generateSlug(string $text): string {
// Convert to lowercase
$slug = mb_strtolower($text, 'UTF-8');
// Replace spaces and underscores with hyphens
$slug = preg_replace('/[\s_]+/', '-', $slug);
// Remove all non-word chars except hyphens
$slug = preg_replace('/[^\w\-]+/u', '', $slug);
// Replace multiple hyphens with single hyphen
$slug = preg_replace('/-+/', '-', $slug);
// Remove leading/trailing hyphens
$slug = trim($slug, '-');
// If slug is empty after processing, generate a random one
if (empty($slug)) {
$slug = 'thread-' . uniqid();
}
return $slug;
}
/**
* Ensure slug is unique by appending a number if necessary
*
* @param string $slug The base slug to make unique
* @return string A unique slug
*/
private function ensureUniqueSlug(string $slug): string {
$originalSlug = $slug;
$counter = 1;
// Keep trying until we find a unique slug
while (true) {
try {
// Try to find a thread with this slug
$this->threadMapper->findBySlug($slug);
// If we get here, slug exists, try the next one
$slug = $originalSlug . '-' . $counter;
$counter++;
} catch (DoesNotExistException $e) {
// Slug doesn't exist, we can use it
return $slug;
}
}
}
}

View File

@@ -6246,8 +6246,7 @@
"type": "object",
"required": [
"categoryId",
"title",
"slug"
"title"
],
"properties": {
"categoryId": {
@@ -6258,10 +6257,6 @@
"title": {
"type": "string",
"description": "Thread title"
},
"slug": {
"type": "string",
"description": "Thread slug"
}
}
}

View File

@@ -21,6 +21,7 @@
"dependencies": {
"@nextcloud/auth": "^2.5.3",
"@nextcloud/axios": "^2.5.2",
"@nextcloud/dialogs": "^7.1.0",
"@nextcloud/l10n": "^3.4.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vite-config": "2.3.5",

132
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@nextcloud/axios':
specifier: ^2.5.2
version: 2.5.2
'@nextcloud/dialogs':
specifier: ^7.1.0
version: 7.1.0(rollup@4.52.5)(typescript@5.9.2)(vite@6.4.1(@types/node@20.17.10)(sass-embedded@1.93.3)(sass@1.93.3)(yaml@2.8.1))
'@nextcloud/l10n':
specifier: ^3.4.0
version: 3.4.0
@@ -467,6 +470,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mdi/js@7.4.47':
resolution: {integrity: sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==}
'@microsoft/api-extractor-model@7.31.3':
resolution: {integrity: sha512-dv4quQI46p0U03TCEpasUf6JrJL3qjMN7JUAobsPElxBv4xayYYvWW9aPpfYV+Jx6hqUcVaLVOeV7+5hxsyoFQ==}
@@ -506,6 +512,10 @@ packages:
resolution: {integrity: sha512-L1NQtOfHWzkfj0Ple1MEJt6HmOHWAi3y4qs+OnwSWexqJT0DtXTVPyRxi7ADyITwRxS5H9R/HMl6USAj4Nr1nQ==}
engines: {node: ^20.0.0, npm: ^10.0.0}
'@nextcloud/dialogs@7.1.0':
resolution: {integrity: sha512-/q4vr4AqJkQhCf8r1i89c9g3A49HgrBkqUCLc9sDUZFLqpTHxC4eqP8zO5/G9iTdVttq8RHva/u8XV8EtGHf3A==}
engines: {node: ^20 || ^22 || ^24}
'@nextcloud/eslint-config@8.4.2':
resolution: {integrity: sha512-zsDcBxvp2Vr/BgasK/vNYJ84LOXjl4RseJPrcp93zcnaB2WnygV50Sd0nQ5JN0ngTyPjiIlGd92MMzrMTofjRA==}
engines: {node: ^20.0.0, npm: ^10.0.0}
@@ -911,6 +921,9 @@ packages:
'@types/sizzle@2.3.10':
resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==}
'@types/toastify-js@1.12.4':
resolution: {integrity: sha512-zfZHU4tKffPCnZRe7pjv/eFKzTVHozKewFCKaCjZ4gFinKgJRz/t0bkZiMCXJxPhv/ZoeDGNOeRD09R0kQZ/nw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -3583,6 +3596,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
toastify-js@1.12.0:
resolution: {integrity: sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==}
tributejs@5.1.3:
resolution: {integrity: sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ==}
@@ -3749,6 +3765,11 @@ packages:
peerDependencies:
vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
vite-plugin-node-polyfills@0.24.0:
resolution: {integrity: sha512-GA9QKLH+vIM8NPaGA+o2t8PDfFUl32J8rUp1zQfMKVJQiNkOX4unE51tR6ppl6iKw5yOrDAdSH7r/UIFLCVhLw==}
peerDependencies:
vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
vite@6.4.1:
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -4026,7 +4047,6 @@ snapshots:
'@buttercup/fetch@0.2.1':
optionalDependencies:
node-fetch: 3.3.2
optional: true
'@ckpack/vue-color@1.6.0(vue@3.5.22(typescript@5.9.2))':
dependencies:
@@ -4234,6 +4254,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mdi/js@7.4.47': {}
'@microsoft/api-extractor-model@7.31.3(@types/node@20.17.10)':
dependencies:
'@microsoft/tsdoc': 0.15.1
@@ -4297,6 +4319,37 @@ snapshots:
dependencies:
'@nextcloud/initial-state': 2.2.0
'@nextcloud/dialogs@7.1.0(rollup@4.52.5)(typescript@5.9.2)(vite@6.4.1(@types/node@20.17.10)(sass-embedded@1.93.3)(sass@1.93.3)(yaml@2.8.1))':
dependencies:
'@mdi/js': 7.4.47
'@nextcloud/auth': 2.5.3
'@nextcloud/axios': 2.5.2
'@nextcloud/browser-storage': 0.5.0
'@nextcloud/event-bus': 3.3.2
'@nextcloud/files': 3.12.0
'@nextcloud/initial-state': 3.0.0
'@nextcloud/l10n': 3.4.0
'@nextcloud/paths': 2.2.1
'@nextcloud/router': 3.0.1
'@nextcloud/sharing': 0.3.0
'@nextcloud/typings': 1.9.1
'@nextcloud/vue': 9.0.1(typescript@5.9.2)
'@types/toastify-js': 1.12.4
'@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.9.2))
cancelable-promise: 4.3.1
p-queue: 9.0.0
toastify-js: 1.12.0
vite-plugin-node-polyfills: 0.24.0(rollup@4.52.5)(vite@6.4.1(@types/node@20.17.10)(sass-embedded@1.93.3)(sass@1.93.3)(yaml@2.8.1))
vue: 3.5.22(typescript@5.9.2)
webdav: 5.8.0
transitivePeerDependencies:
- '@nuxt/kit'
- debug
- rollup
- supports-color
- typescript
- vite
'@nextcloud/eslint-config@8.4.2(@babel/core@7.26.0)(@babel/eslint-parser@7.25.9(@babel/core@7.26.0)(eslint@9.39.1))(@nextcloud/eslint-plugin@2.2.1(eslint@9.39.1))(@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.32.0(eslint@9.39.1))(eslint@9.39.1)(typescript@5.9.2))(eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@16.6.2(eslint@9.39.1))(eslint-plugin-promise@6.6.0(eslint@9.39.1))(eslint@9.39.1))(eslint-import-resolver-exports@1.0.0-beta.5(eslint-plugin-import@2.31.0)(eslint@9.39.1))(eslint-import-resolver-typescript@3.6.3)(eslint-plugin-import@2.31.0)(eslint-plugin-jsdoc@46.10.1(eslint@9.39.1))(eslint-plugin-n@16.6.2(eslint@9.39.1))(eslint-plugin-promise@6.6.0(eslint@9.39.1))(eslint-plugin-vue@9.32.0(eslint@9.39.1))(eslint@9.39.1)(typescript@5.9.2)':
dependencies:
'@babel/core': 7.26.0
@@ -4339,7 +4392,6 @@ snapshots:
is-svg: 6.1.0
typescript-event-target: 1.1.1
webdav: 5.8.0
optional: true
'@nextcloud/initial-state@2.2.0': {}
@@ -4357,8 +4409,7 @@ snapshots:
dependencies:
'@nextcloud/auth': 2.5.3
'@nextcloud/paths@2.2.1':
optional: true
'@nextcloud/paths@2.2.1': {}
'@nextcloud/router@3.0.1':
dependencies:
@@ -4367,7 +4418,6 @@ snapshots:
'@nextcloud/sharing@0.2.5':
dependencies:
'@nextcloud/initial-state': 2.2.0
optional: true
'@nextcloud/sharing@0.3.0':
dependencies:
@@ -4715,6 +4765,8 @@ snapshots:
'@types/sizzle@2.3.10': {}
'@types/toastify-js@1.12.4': {}
'@types/trusted-types@2.0.7':
optional: true
@@ -5217,8 +5269,7 @@ snapshots:
balanced-match@2.0.0: {}
base-64@1.0.0:
optional: true
base-64@1.0.0: {}
base64-js@1.5.1: {}
@@ -5325,8 +5376,7 @@ snapshots:
dependencies:
semver: 7.7.3
byte-length@1.0.2:
optional: true
byte-length@1.0.2: {}
call-bind-apply-helpers@1.0.2:
dependencies:
@@ -5347,8 +5397,7 @@ snapshots:
callsites@3.1.0: {}
cancelable-promise@4.3.1:
optional: true
cancelable-promise@4.3.1: {}
caniuse-lite@1.0.30001753: {}
@@ -5367,8 +5416,7 @@ snapshots:
character-reference-invalid@2.0.1: {}
charenc@0.0.2:
optional: true
charenc@0.0.2: {}
chokidar@4.0.3:
dependencies:
@@ -5478,8 +5526,7 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
crypt@0.0.2:
optional: true
crypt@0.0.2: {}
crypto-browserify@3.12.1:
dependencies:
@@ -5507,8 +5554,7 @@ snapshots:
csstype@3.1.3: {}
data-uri-to-buffer@4.0.1:
optional: true
data-uri-to-buffer@4.0.1: {}
data-view-buffer@1.0.2:
dependencies:
@@ -5651,8 +5697,7 @@ snapshots:
entities@4.5.0: {}
entities@6.0.1:
optional: true
entities@6.0.1: {}
env-paths@2.2.1: {}
@@ -6063,7 +6108,6 @@ snapshots:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
optional: true
file-entry-cache@8.0.0:
dependencies:
@@ -6121,7 +6165,6 @@ snapshots:
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
optional: true
fs-extra@11.3.2:
dependencies:
@@ -6309,8 +6352,7 @@ snapshots:
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
hot-patcher@2.0.1:
optional: true
hot-patcher@2.0.1: {}
html-tags@3.3.1: {}
@@ -6395,8 +6437,7 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-buffer@1.1.6:
optional: true
is-buffer@1.1.6: {}
is-builtin-module@3.2.1:
dependencies:
@@ -6570,8 +6611,7 @@ snapshots:
kolorist@1.8.0: {}
layerr@3.0.0:
optional: true
layerr@3.0.0: {}
levn@0.4.1:
dependencies:
@@ -6662,7 +6702,6 @@ snapshots:
charenc: 0.0.2
crypt: 0.0.2
is-buffer: 1.1.6
optional: true
mdast-squeeze-paragraphs@6.0.0:
dependencies:
@@ -6964,21 +7003,18 @@ snapshots:
natural-compare@1.4.0: {}
nested-property@4.0.0:
optional: true
nested-property@4.0.0: {}
node-addon-api@7.1.1:
optional: true
node-domexception@1.0.0:
optional: true
node-domexception@1.0.0: {}
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
optional: true
node-releases@2.0.27: {}
@@ -7133,8 +7169,7 @@ snapshots:
path-parse@1.0.7: {}
path-posix@1.0.0:
optional: true
path-posix@1.0.0: {}
path-type@4.0.0: {}
@@ -7253,8 +7288,7 @@ snapshots:
querystring-es3@0.2.1: {}
querystringify@2.2.0:
optional: true
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}
@@ -7363,8 +7397,7 @@ snapshots:
requireindex@1.2.0: {}
requires-port@1.0.0:
optional: true
requires-port@1.0.0: {}
resolve-from@4.0.0: {}
@@ -7955,6 +7988,8 @@ snapshots:
dependencies:
is-number: 7.0.0
toastify-js@1.12.0: {}
tributejs@5.1.3: {}
trim-lines@3.0.1: {}
@@ -8030,8 +8065,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
typescript-event-target@1.1.1:
optional: true
typescript-event-target@1.1.1: {}
typescript@5.8.2: {}
@@ -8103,14 +8137,12 @@ snapshots:
dependencies:
punycode: 2.3.1
url-join@5.0.0:
optional: true
url-join@5.0.0: {}
url-parse@1.5.10:
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
optional: true
url@0.11.4:
dependencies:
@@ -8170,6 +8202,14 @@ snapshots:
transitivePeerDependencies:
- rollup
vite-plugin-node-polyfills@0.24.0(rollup@4.52.5)(vite@6.4.1(@types/node@20.17.10)(sass-embedded@1.93.3)(sass@1.93.3)(yaml@2.8.1)):
dependencies:
'@rollup/plugin-inject': 5.0.5(rollup@4.52.5)
node-stdlib-browser: 1.3.1
vite: 6.4.1(@types/node@20.17.10)(sass-embedded@1.93.3)(sass@1.93.3)(yaml@2.8.1)
transitivePeerDependencies:
- rollup
vite@6.4.1(@types/node@20.17.10)(sass-embedded@1.93.3)(sass@1.93.3)(yaml@2.8.1):
dependencies:
esbuild: 0.25.12
@@ -8233,8 +8273,7 @@ snapshots:
optionalDependencies:
typescript: 5.9.2
web-streams-polyfill@3.3.3:
optional: true
web-streams-polyfill@3.3.3: {}
webdav@5.8.0:
dependencies:
@@ -8252,7 +8291,6 @@ snapshots:
path-posix: 1.0.0
url-join: 5.0.0
url-parse: 1.5.10
optional: true
which-boxed-primitive@1.1.1:
dependencies:

View File

@@ -0,0 +1,206 @@
<template>
<div class="thread-create-form">
<div class="form-header">
<div class="user-info">
<NcAvatar v-if="userId" :user="userId" :size="40" />
<NcAvatar v-else :display-name="displayName" :size="40" />
<span class="user-name">{{ displayName }}</span>
</div>
</div>
<div class="form-body">
<NcTextField v-model="title" :label="strings.titleLabel" :placeholder="strings.titlePlaceholder"
:disabled="submitting" @keydown.enter="focusContent" class="title-input" />
<NcTextArea v-model="content" :placeholder="strings.contentPlaceholder" :rows="6" :disabled="submitting"
@keydown.ctrl.enter="submitThread" @keydown.meta.enter="submitThread" class="content-textarea"
ref="contentTextarea" />
<div class="form-footer">
<div class="form-footer-left">
<span class="hint">{{ strings.hint }}</span>
</div>
<div class="form-footer-right">
<NcButton @click="cancel" :disabled="submitting">
{{ strings.cancel }}
</NcButton>
<NcButton @click="submitThread" :disabled="!canSubmit || submitting" type="primary">
<template v-if="submitting">
<NcLoadingIcon :size="20" />
</template>
{{ strings.submit }}
</NcButton>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'
export default defineComponent({
name: 'ThreadCreateForm',
components: {
NcAvatar,
NcButton,
NcLoadingIcon,
NcTextArea,
NcTextField,
},
emits: ['submit', 'cancel'],
setup() {
const { userId, displayName } = useCurrentUser()
return {
userId,
displayName,
}
},
data() {
return {
title: '',
content: '',
submitting: false,
strings: {
titleLabel: t('forum', 'Title'),
titlePlaceholder: t('forum', 'Enter thread title...'),
contentPlaceholder: t('forum', 'Write your first post...'),
hint: t('forum', 'Ctrl+Enter to submit'),
cancel: t('forum', 'Cancel'),
submit: t('forum', 'Create Thread'),
confirmCancel: t('forum', 'Are you sure you want to discard this thread?'),
},
}
},
computed: {
canSubmit(): boolean {
return this.title.trim().length > 0 && this.content.trim().length > 0
},
hasContent(): boolean {
return this.title.trim().length > 0 || this.content.trim().length > 0
},
},
methods: {
async submitThread(): Promise<void> {
if (!this.canSubmit || this.submitting) {
return
}
this.submitting = true
this.$emit('submit', {
title: this.title.trim(),
content: this.content.trim(),
})
},
clear(): void {
this.title = ''
this.content = ''
this.submitting = false
},
setSubmitting(value: boolean): void {
this.submitting = value
},
cancel(): void {
// Only confirm if there's content to discard
if (this.hasContent) {
// eslint-disable-next-line no-alert
if (!confirm(this.strings.confirmCancel)) {
return
}
}
this.title = ''
this.content = ''
this.$emit('cancel')
},
focusContent(): void {
// Move focus to content area when Enter is pressed in title field
const textarea = this.$refs.contentTextarea as any
if (textarea?.$el?.querySelector('textarea')) {
textarea.$el.querySelector('textarea').focus()
}
},
},
})
</script>
<style scoped lang="scss">
.thread-create-form {
padding: 16px;
background: var(--color-main-background);
}
.form-header {
margin-bottom: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
}
.form-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.title-input {
:global(.input-field__input) {
font-size: 1.1rem;
font-weight: 500;
}
}
.content-textarea {
min-height: 8rem;
resize: vertical;
:global(.textarea__main-wrapper),
textarea {
min-height: calc(var(--default-clickable-area) * 3);
height: unset !important;
}
}
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.form-footer-left {
flex: 1;
}
.form-footer-right {
display: flex;
gap: 8px;
}
.hint {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
font-style: italic;
}
</style>

View File

@@ -7,6 +7,8 @@ const routes: RouteRecordRaw[] = [
{ path: '/', component: () => import('@/views/CategoriesView.vue') },
{ path: '/category/:id', component: () => import('@/views/CategoryView.vue') },
{ path: '/c/:slug', component: () => import('@/views/CategoryView.vue') },
{ path: '/category/:categoryId/new', component: () => import('@/views/CreateThreadView.vue') },
{ path: '/c/:categorySlug/new', component: () => import('@/views/CreateThreadView.vue') },
{ path: '/thread/:id', component: () => import('@/views/ThreadView.vue') },
{ path: '/t/:slug', component: () => import('@/views/ThreadView.vue') },
{ path: '/admin', component: () => import('@/views/admin/AdminDashboard.vue') },

View File

@@ -188,8 +188,9 @@ export default defineComponent({
},
createThread() {
console.log('Create new thread in category:', this.category?.id)
// Example: this.$router.push({ name: 'new-thread', params: { categoryId: this.category.id } })
if (this.category) {
this.$router.push(`/c/${this.category.slug || this.category.id}/new`)
}
},
goBack(): void {

View File

@@ -0,0 +1,215 @@
<template>
<div class="create-thread-view">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<NcButton @click="goBack">{{ strings.back }}</NcButton>
</div>
</div>
<!-- Page Header -->
<div class="page-header mt-16">
<h2 class="page-title">{{ strings.title }}</h2>
<p v-if="category" class="page-subtitle">{{ strings.subtitle(category.name) }}</p>
</div>
<!-- Loading state -->
<div class="center mt-16" v-if="loading && !category">
<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="goBack">{{ strings.back }}</NcButton>
</template>
</NcEmptyContent>
<!-- Create Thread Form -->
<div v-else class="mt-16">
<ThreadCreateForm ref="createForm" @submit="handleCreateThread" @cancel="goBack" />
</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 ThreadCreateForm from '@/components/ThreadCreateForm.vue'
import type { Category, Thread } from '@/types'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import { showError, showSuccess } from '@nextcloud/dialogs'
export default defineComponent({
name: 'CreateThreadView',
components: {
NcButton,
NcEmptyContent,
NcLoadingIcon,
ThreadCreateForm,
},
data() {
return {
loading: false,
category: null as Category | null,
error: null as string | null,
strings: {
back: t('forum', 'Back'),
title: t('forum', 'Create New Thread'),
subtitle: (categoryName: string) => t('forum', 'in {category}', { category: categoryName }),
loading: t('forum', 'Loading…'),
errorTitle: t('forum', 'Error loading category'),
creating: t('forum', 'Creating thread…'),
success: t('forum', 'Thread created successfully'),
errorCreating: t('forum', 'Failed to create thread'),
},
}
},
computed: {
categoryId(): number | null {
return this.$route.params.categoryId ? parseInt(this.$route.params.categoryId as string) : null
},
categorySlug(): string | null {
return (this.$route.params.categorySlug as string) || null
},
},
created() {
this.fetchCategory()
},
methods: {
async fetchCategory() {
if (!this.categoryId && !this.categorySlug) {
this.error = t('forum', 'No category specified')
return
}
try {
this.loading = true
this.error = null
let resp
if (this.categorySlug) {
resp = await ocs.get<Category>(`/categories/slug/${this.categorySlug}`)
} else if (this.categoryId) {
resp = await ocs.get<Category>(`/categories/${this.categoryId}`)
}
this.category = resp!.data
} catch (e) {
console.error('Failed to fetch category', e)
this.error = t('forum', 'Category not found')
} finally {
this.loading = false
}
},
async handleCreateThread(data: { title: string; content: string }) {
if (!this.category) {
showError(this.strings.errorCreating)
return
}
const form = this.$refs.createForm as any
form?.setSubmitting(true)
try {
// Step 1: Create the thread (backend will generate slug)
const threadResp = await ocs.post<Thread>('/threads', {
categoryId: this.category.id,
title: data.title,
})
const newThread = threadResp.data
// Step 2: Create the initial post
await ocs.post('/posts', {
threadId: newThread.id,
content: data.content,
})
showSuccess(this.strings.success)
// Navigate to the new thread
this.$router.push(`/t/${newThread.slug}`)
} catch (e) {
console.error('Failed to create thread', e)
showError(this.strings.errorCreating)
form?.setSubmitting(false)
}
},
goBack(): void {
// Navigate back to the category
if (this.category) {
this.$router.push(`/c/${this.category.slug || this.category.id}`)
} else {
this.$router.push('/')
}
},
},
})
</script>
<style scoped lang="scss">
.create-thread-view {
max-width: 800px;
margin: 0 auto;
.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;
}
.toolbar {
margin-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
}
.page-header {
padding: 20px;
background: var(--color-background-hover);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.page-title {
margin: 0 0 4px 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--color-main-text);
}
.page-subtitle {
margin: 0;
font-size: 1rem;
color: var(--color-text-lighter);
}
}
</style>