mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: create threads view/form
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
132
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
206
src/components/ThreadCreateForm.vue
Normal file
206
src/components/ThreadCreateForm.vue
Normal 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>
|
||||
@@ -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') },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
215
src/views/CreateThreadView.vue
Normal file
215
src/views/CreateThreadView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user