feat: thread drafts

This commit is contained in:
2025-12-14 00:22:21 +02:00
parent a481a93782
commit 0a0e64dae5
10 changed files with 1182 additions and 5 deletions

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Controller;
use OCA\Forum\Db\Draft;
use OCA\Forum\Db\DraftMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class DraftController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private DraftMapper $draftMapper,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* Get a thread draft for a category
*
* @param int $categoryId Category ID
* @return DataResponse<Http::STATUS_OK, array{draft: array<string, mixed>|null}, array{}>
*
* 200: Draft found or null
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/drafts/thread/{categoryId}')]
public function getThreadDraft(int $categoryId): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
try {
$draft = $this->draftMapper->findThreadDraft($user->getUID(), $categoryId);
return new DataResponse(['draft' => $draft->jsonSerialize()]);
} catch (DoesNotExistException) {
return new DataResponse(['draft' => null]);
}
} catch (\Exception $e) {
$this->logger->error('Error getting thread draft: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to get thread draft'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Save (create or update) a thread draft for a category
*
* @param int $categoryId Category ID
* @param string|null $title Draft title (can be empty)
* @param string $content Draft content
* @return DataResponse<Http::STATUS_OK, array{draft: array<string, mixed>}, array{}>
*
* 200: Draft saved
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/drafts/thread/{categoryId}')]
public function saveThreadDraft(int $categoryId, ?string $title, string $content): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$draft = $this->draftMapper->saveThreadDraft(
$user->getUID(),
$categoryId,
$title,
$content
);
return new DataResponse(['draft' => $draft->jsonSerialize()]);
} catch (\Exception $e) {
$this->logger->error('Error saving thread draft: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to save thread draft'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a thread draft for a category
*
* @param int $categoryId Category ID
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
*
* 200: Draft deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/drafts/thread/{categoryId}')]
public function deleteThreadDraft(int $categoryId): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$this->draftMapper->deleteThreadDraft($user->getUID(), $categoryId);
return new DataResponse(['success' => true]);
} catch (\Exception $e) {
$this->logger->error('Error deleting thread draft: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to delete thread draft'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get all thread drafts for the current user
*
* @return DataResponse<Http::STATUS_OK, array{drafts: list<array<string, mixed>>}, array{}>
*
* 200: List of drafts returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/drafts/thread')]
public function listThreadDrafts(): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$drafts = $this->draftMapper->findThreadDrafts($user->getUID());
return new DataResponse([
'drafts' => array_map(fn (Draft $d) => $d->jsonSerialize(), $drafts),
]);
} catch (\Exception $e) {
$this->logger->error('Error listing thread drafts: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to list thread drafts'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -9,6 +9,7 @@ namespace OCA\Forum\Controller;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\DraftMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
@@ -40,6 +41,7 @@ class ThreadController extends OCSController {
private PostMapper $postMapper,
private ForumUserMapper $forumUserMapper,
private ThreadSubscriptionMapper $threadSubscriptionMapper,
private DraftMapper $draftMapper,
private ThreadEnrichmentService $threadEnrichmentService,
private UserPreferencesService $userPreferencesService,
private UserService $userService,
@@ -367,6 +369,13 @@ class ThreadController extends OCSController {
$this->logger->warning('Failed to send mention notifications: ' . $e->getMessage());
}
// Delete any draft for this category now that the thread is created
try {
$this->draftMapper->deleteThreadDraft($user->getUID(), $categoryId);
} catch (\Exception $e) {
$this->logger->warning('Failed to delete thread draft: ' . $e->getMessage());
}
return new DataResponse($createdThread->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating thread: ' . $e->getMessage());

69
lib/Db/Draft.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* Polymorphic draft entity that supports both thread and post drafts
*
* @method int getId()
* @method void setId(int $value)
* @method string getUserId()
* @method void setUserId(string $value)
* @method string getEntityType()
* @method void setEntityType(string $value)
* @method int getParentId()
* @method void setParentId(int $value)
* @method string|null getTitle()
* @method void setTitle(?string $value)
* @method string getContent()
* @method void setContent(string $value)
* @method int getCreatedAt()
* @method void setCreatedAt(int $value)
* @method int getUpdatedAt()
* @method void setUpdatedAt(int $value)
*/
class Draft extends Entity implements JsonSerializable {
public const ENTITY_TYPE_THREAD = 'thread';
public const ENTITY_TYPE_POST = 'post';
protected $userId;
protected $entityType;
protected $parentId;
protected $title;
protected $content;
protected $createdAt;
protected $updatedAt;
public function __construct() {
$this->addType('id', 'integer');
$this->addType('userId', 'string');
$this->addType('entityType', 'string');
$this->addType('parentId', 'integer');
$this->addType('title', 'string');
$this->addType('content', 'string');
$this->addType('createdAt', 'integer');
$this->addType('updatedAt', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'userId' => $this->getUserId(),
'entityType' => $this->getEntityType(),
'parentId' => $this->getParentId(),
'title' => $this->getTitle(),
'content' => $this->getContent(),
'createdAt' => $this->getCreatedAt(),
'updatedAt' => $this->getUpdatedAt(),
];
}
}

210
lib/Db/DraftMapper.php Normal file
View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Db;
use OCA\Forum\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* Mapper for polymorphic drafts that can be used for threads or posts
*
* @template-extends QBMapper<Draft>
*/
class DraftMapper extends QBMapper {
public function __construct(
IDBConnection $db,
) {
parent::__construct($db, Application::tableName('forum_drafts'), Draft::class);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(int $id): Draft {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
);
return $this->findEntity($qb);
}
/**
* Find a draft by user, entity type and parent ID
*
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function findByUserAndParent(string $userId, string $entityType, int $parentId): Draft {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('entity_type', $qb->createNamedParameter($entityType, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('parent_id', $qb->createNamedParameter($parentId, IQueryBuilder::PARAM_INT))
);
return $this->findEntity($qb);
}
/**
* Find a thread draft by user and category (convenience method)
*
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function findThreadDraft(string $userId, int $categoryId): Draft {
return $this->findByUserAndParent($userId, Draft::ENTITY_TYPE_THREAD, $categoryId);
}
/**
* Check if a user has a draft for a specific entity type and parent
*/
public function hasDraft(string $userId, string $entityType, int $parentId): bool {
try {
$this->findByUserAndParent($userId, $entityType, $parentId);
return true;
} catch (DoesNotExistException) {
return false;
}
}
/**
* Check if a user has a thread draft for a category (convenience method)
*/
public function hasThreadDraft(string $userId, int $categoryId): bool {
return $this->hasDraft($userId, Draft::ENTITY_TYPE_THREAD, $categoryId);
}
/**
* Get all drafts for a user by entity type
*
* @return array<Draft>
*/
public function findByUserAndType(string $userId, string $entityType): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('entity_type', $qb->createNamedParameter($entityType, IQueryBuilder::PARAM_STR))
)
->orderBy('updated_at', 'DESC');
return $this->findEntities($qb);
}
/**
* Get all thread drafts for a user (convenience method)
*
* @return array<Draft>
*/
public function findThreadDrafts(string $userId): array {
return $this->findByUserAndType($userId, Draft::ENTITY_TYPE_THREAD);
}
/**
* Create or update a draft (upsert behavior)
*/
public function saveDraft(string $userId, string $entityType, int $parentId, ?string $title, string $content): Draft {
$now = time();
try {
// Try to find existing draft
$draft = $this->findByUserAndParent($userId, $entityType, $parentId);
// Update existing draft
$draft->setTitle($title);
$draft->setContent($content);
$draft->setUpdatedAt($now);
return $this->update($draft);
} catch (DoesNotExistException) {
// Create new draft
$draft = new Draft();
$draft->setUserId($userId);
$draft->setEntityType($entityType);
$draft->setParentId($parentId);
$draft->setTitle($title);
$draft->setContent($content);
$draft->setCreatedAt($now);
$draft->setUpdatedAt($now);
return $this->insert($draft);
}
}
/**
* Save a thread draft (convenience method)
*/
public function saveThreadDraft(string $userId, int $categoryId, ?string $title, string $content): Draft {
return $this->saveDraft($userId, Draft::ENTITY_TYPE_THREAD, $categoryId, $title, $content);
}
/**
* Delete a draft by user, entity type and parent
*/
public function deleteDraft(string $userId, string $entityType, int $parentId): void {
try {
$draft = $this->findByUserAndParent($userId, $entityType, $parentId);
$this->delete($draft);
} catch (DoesNotExistException) {
// Already deleted or doesn't exist, nothing to do
}
}
/**
* Delete a thread draft (convenience method)
*/
public function deleteThreadDraft(string $userId, int $categoryId): void {
$this->deleteDraft($userId, Draft::ENTITY_TYPE_THREAD, $categoryId);
}
/**
* Count drafts for a user by entity type
*/
public function countByUserAndType(string $userId, string $entityType): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('entity_type', $qb->createNamedParameter($entityType, IQueryBuilder::PARAM_STR))
);
$result = $qb->executeQuery();
$count = (int)($result->fetchOne() ?? 0);
$result->closeCursor();
return $count;
}
/**
* Count thread drafts for a user (convenience method)
*/
public function countThreadDrafts(string $userId): int {
return $this->countByUserAndType($userId, Draft::ENTITY_TYPE_THREAD);
}
/**
* @return array<Draft>
*/
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName());
return $this->findEntities($qb);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version11Date20251213000000 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$this->createForumDraftsTable($schema);
return $schema;
}
private function createForumDraftsTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('forum_drafts')) {
return;
}
$table = $schema->createTable('forum_drafts');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('user_id', 'string', [
'notnull' => true,
'length' => 64,
]);
// Polymorphic entity type: 'thread' or 'post' (for future use)
$table->addColumn('entity_type', 'string', [
'notnull' => true,
'length' => 32,
'default' => 'thread',
]);
// Parent ID: category_id for thread drafts, thread_id for post drafts
$table->addColumn('parent_id', 'bigint', [
'notnull' => true,
'unsigned' => true,
]);
// Title for thread drafts (nullable for post drafts)
$table->addColumn('title', 'string', [
'notnull' => false,
'length' => 255,
]);
// Content is required
$table->addColumn('content', 'text', [
'notnull' => true,
]);
$table->addColumn('created_at', 'integer', [
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('updated_at', 'integer', [
'notnull' => true,
'unsigned' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['user_id'], 'drafts_uid_idx');
$table->addIndex(['user_id', 'entity_type'], 'drafts_uid_type_idx');
// Index for finding drafts by user and parent (category for threads, thread for posts)
$table->addIndex(['user_id', 'entity_type', 'parent_id'], 'drafts_uid_type_parent_idx');
// Unique constraint: one draft per user per entity type per parent
$table->addUniqueIndex(['user_id', 'entity_type', 'parent_id'], 'drafts_uniq_idx');
}
}

View File

@@ -3592,6 +3592,457 @@
}
}
},
"/ocs/v2.php/apps/forum/api/drafts/thread/{categoryId}": {
"get": {
"operationId": "draft-get-thread-draft",
"summary": "Get a thread draft for a category",
"tags": [
"draft"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "categoryId",
"in": "path",
"description": "Category ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Draft found or null",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"draft"
],
"properties": {
"draft": {
"type": "object",
"nullable": true,
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"put": {
"operationId": "draft-save-thread-draft",
"summary": "Save (create or update) a thread draft for a category",
"tags": [
"draft"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"content"
],
"properties": {
"title": {
"type": "string",
"nullable": true,
"description": "Draft title (can be empty)"
},
"content": {
"type": "string",
"description": "Draft content"
}
}
}
}
}
},
"parameters": [
{
"name": "categoryId",
"in": "path",
"description": "Category ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Draft saved",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"draft"
],
"properties": {
"draft": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "draft-delete-thread-draft",
"summary": "Delete a thread draft for a category",
"tags": [
"draft"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "categoryId",
"in": "path",
"description": "Category ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Draft deleted",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"success"
],
"properties": {
"success": {
"type": "boolean"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/drafts/thread": {
"get": {
"operationId": "draft-list-thread-drafts",
"summary": "Get all thread drafts for the current user",
"tags": [
"draft"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "List of drafts returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"drafts"
],
"properties": {
"drafts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/users": {
"get": {
"operationId": "forum_user-index",

View File

@@ -31,6 +31,12 @@
/>
<div class="form-footer">
<span v-if="draftStatus" :class="['draft-status', `draft-status--${draftStatus}`]">
<ContentSaveIcon v-if="draftStatus === 'saving'" :size="16" class="saving-icon" />
<ContentSaveCheckIcon v-else-if="draftStatus === 'saved'" :size="16" />
<ContentSaveAlertIcon v-else-if="draftStatus === 'dirty'" :size="16" />
{{ draftStatusText }}
</span>
<NcButton @click="cancel" :disabled="submitting">
{{ strings.cancel }}
</NcButton>
@@ -47,16 +53,21 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { defineComponent, type PropType } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import CheckIcon from '@icons/Check.vue'
import ContentSaveIcon from '@icons/ContentSave.vue'
import ContentSaveCheckIcon from '@icons/ContentSaveCheck.vue'
import ContentSaveAlertIcon from '@icons/ContentSaveAlert.vue'
import UserInfo from './UserInfo.vue'
import BBCodeEditor from './BBCodeEditor.vue'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'
export type DraftStatus = 'saving' | 'saved' | 'dirty' | null
export default defineComponent({
name: 'ThreadCreateForm',
components: {
@@ -64,10 +75,19 @@ export default defineComponent({
NcLoadingIcon,
NcTextField,
CheckIcon,
ContentSaveIcon,
ContentSaveCheckIcon,
ContentSaveAlertIcon,
UserInfo,
BBCodeEditor,
},
emits: ['submit', 'cancel'],
emits: ['submit', 'cancel', 'update:title', 'update:content'],
props: {
draftStatus: {
type: String as PropType<DraftStatus>,
default: null,
},
},
setup() {
const { userId, displayName } = useCurrentUser()
@@ -88,6 +108,9 @@ export default defineComponent({
cancel: t('forum', 'Cancel'),
submit: t('forum', 'Create thread'),
confirmCancel: t('forum', 'Are you sure you want to discard this thread?'),
draftSaving: t('forum', 'Saving draft …'),
draftSaved: t('forum', 'Draft saved'),
draftDirty: t('forum', 'Unsaved changes'),
},
}
},
@@ -98,6 +121,26 @@ export default defineComponent({
hasContent(): boolean {
return this.title.trim().length > 0 || this.content.trim().length > 0
},
draftStatusText(): string {
if (this.draftStatus === 'saving') {
return this.strings.draftSaving
}
if (this.draftStatus === 'saved') {
return this.strings.draftSaved
}
if (this.draftStatus === 'dirty') {
return this.strings.draftDirty
}
return ''
},
},
watch: {
title(newVal: string) {
this.$emit('update:title', newVal)
},
content(newVal: string) {
this.$emit('update:content', newVal)
},
},
methods: {
async submitThread(): Promise<void> {
@@ -122,6 +165,14 @@ export default defineComponent({
this.submitting = value
},
setTitle(value: string): void {
this.title = value
},
setContent(value: string): void {
this.content = value
},
cancel(): void {
// Only confirm if there's content to discard
if (this.hasContent) {
@@ -177,6 +228,40 @@ export default defineComponent({
gap: 12px;
}
.draft-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.85rem;
margin-right: auto;
&--saving {
color: var(--color-text-maxcontrast);
.saving-icon {
animation: pulse 1s ease-in-out infinite;
}
}
&--saved {
color: var(--color-success-text);
}
&--dirty {
color: var(--color-warning-text);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
.hint {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);

View File

@@ -164,3 +164,14 @@ export interface SearchParams {
limit: number
offset: number
}
export interface Draft {
id: number
userId: string
entityType: 'thread' | 'post'
parentId: number
title: string | null
content: string
createdAt: number
updatedAt: number
}

View File

@@ -46,7 +46,14 @@
<!-- Create Thread Form -->
<div v-else class="mt-16">
<ThreadCreateForm ref="createForm" @submit="handleCreateThread" @cancel="goBack" />
<ThreadCreateForm
ref="createForm"
:draft-status="draftStatus"
@submit="handleCreateThread"
@cancel="goBack"
@update:title="handleTitleChange"
@update:content="handleContentChange"
/>
</div>
</div>
</PageWrapper>
@@ -60,13 +67,15 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import ThreadCreateForm from '@/components/ThreadCreateForm.vue'
import ThreadCreateForm, { type DraftStatus } from '@/components/ThreadCreateForm.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import type { Category, Thread } from '@/types'
import type { Category, Thread, Draft } from '@/types'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import { showError, showSuccess } from '@nextcloud/dialogs'
const DRAFT_DEBOUNCE_DELAY = 1500 // 1.5 seconds
export default defineComponent({
name: 'CreateThreadView',
components: {
@@ -84,6 +93,11 @@ export default defineComponent({
loading: false,
category: null as Category | null,
error: null as string | null,
// Draft state
draftTitle: '',
draftContent: '',
draftStatus: null as DraftStatus,
draftDebounceTimer: null as ReturnType<typeof setTimeout> | null,
strings: {
back: t('forum', 'Back'),
@@ -110,6 +124,12 @@ export default defineComponent({
created() {
this.fetchCategory()
},
beforeUnmount() {
// Clear any pending draft save
if (this.draftDebounceTimer) {
clearTimeout(this.draftDebounceTimer)
}
},
methods: {
async fetchCategory() {
if (!this.categoryId && !this.categorySlug) {
@@ -128,6 +148,9 @@ export default defineComponent({
resp = await ocs.get<Category>(`/categories/${this.categoryId}`)
}
this.category = resp!.data
// After loading category, fetch any existing draft
await this.fetchDraft()
} catch (e) {
console.error('Failed to fetch category', e)
this.error = t('forum', 'Category not found')
@@ -136,6 +159,86 @@ export default defineComponent({
}
},
async fetchDraft() {
if (!this.category) return
try {
const response = await ocs.get<{ draft: Draft | null }>(
`/drafts/thread/${this.category.id}`,
)
const draft = response.data.draft
if (draft) {
// Load draft into form
const form = this.$refs.createForm as any
if (form) {
form.setTitle(draft.title || '')
form.setContent(draft.content || '')
}
// Update local state for tracking
this.draftTitle = draft.title || ''
this.draftContent = draft.content || ''
// Mark as saved since we just loaded it
this.draftStatus = 'saved'
}
} catch (e) {
console.error('Failed to fetch draft', e)
// Silently fail - not critical
}
},
async saveDraft() {
if (!this.category) return
// Do not save if content is empty
if (!this.draftContent.trim()) {
return
}
try {
this.draftStatus = 'saving'
await ocs.put(`/drafts/thread/${this.category.id}`, {
title: this.draftTitle || null,
content: this.draftContent,
})
this.draftStatus = 'saved'
} catch (e) {
console.error('Failed to save draft', e)
// On error, mark as dirty since it was not saved
this.draftStatus = 'dirty'
}
},
scheduleDraftSave() {
// Clear any existing timer
if (this.draftDebounceTimer) {
clearTimeout(this.draftDebounceTimer)
}
// Only save if there's content
if (!this.draftContent.trim()) {
this.draftStatus = null
return
}
// Mark as dirty immediately when user makes changes
this.draftStatus = 'dirty'
this.draftDebounceTimer = setTimeout(() => {
this.saveDraft()
}, DRAFT_DEBOUNCE_DELAY)
},
handleTitleChange(title: string) {
this.draftTitle = title
this.scheduleDraftSave()
},
handleContentChange(content: string) {
this.draftContent = content
this.scheduleDraftSave()
},
async handleCreateThread(data: { title: string; content: string }) {
if (!this.category) {
showError(this.strings.errorCreating)
@@ -145,6 +248,12 @@ export default defineComponent({
const form = this.$refs.createForm as any
form?.setSubmitting(true)
// Cancel any pending draft save
if (this.draftDebounceTimer) {
clearTimeout(this.draftDebounceTimer)
this.draftDebounceTimer = null
}
try {
// Create the thread with initial post in a single request
const threadResp = await ocs.post<Thread>('/threads', {

View File

@@ -8,6 +8,7 @@ use OCA\Forum\AppInfo\Application;
use OCA\Forum\Controller\ThreadController;
use OCA\Forum\Db\Category;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\DraftMapper;
use OCA\Forum\Db\ForumUser;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\Post;
@@ -35,6 +36,7 @@ class ThreadControllerTest extends TestCase {
private PostMapper $postMapper;
private ForumUserMapper $forumUserMapper;
private ThreadSubscriptionMapper $threadSubscriptionMapper;
private DraftMapper $draftMapper;
private ThreadEnrichmentService $threadEnrichmentService;
private UserPreferencesService $userPreferencesService;
private UserService $userService;
@@ -51,6 +53,7 @@ class ThreadControllerTest extends TestCase {
$this->postMapper = $this->createMock(PostMapper::class);
$this->forumUserMapper = $this->createMock(ForumUserMapper::class);
$this->threadSubscriptionMapper = $this->createMock(ThreadSubscriptionMapper::class);
$this->draftMapper = $this->createMock(DraftMapper::class);
$this->threadEnrichmentService = $this->createMock(ThreadEnrichmentService::class);
$this->userPreferencesService = $this->createMock(UserPreferencesService::class);
$this->userService = $this->createMock(UserService::class);
@@ -78,6 +81,7 @@ class ThreadControllerTest extends TestCase {
$this->postMapper,
$this->forumUserMapper,
$this->threadSubscriptionMapper,
$this->draftMapper,
$this->threadEnrichmentService,
$this->userPreferencesService,
$this->userService,