diff --git a/lib/Controller/NoteController.php b/lib/Controller/NoteController.php index 8ff8473..c93ee90 100644 --- a/lib/Controller/NoteController.php +++ b/lib/Controller/NoteController.php @@ -43,6 +43,7 @@ final class NoteController extends OCSController { * List all notes in a house * * @param int $houseId House id. + * @param string $sortBy Sort mode (custom, newest, oldest, title_asc, title_desc). * @param int<1, 500> $limit Maximum number of notes to return. * @param int<0, max> $offset Number of notes to skip. * @@ -52,10 +53,10 @@ final class NoteController extends OCSController { */ #[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/notes')] #[NoAdminRequired] - public function indexNotes(int $houseId, int $limit = 100, int $offset = 0): DataResponse { - return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse { + public function indexNotes(int $houseId, string $sortBy = 'custom', int $limit = 100, int $offset = 0): DataResponse { + return $this->runAction(function () use ($houseId, $sortBy, $limit, $offset): DataResponse { $this->auth->requireMember($houseId, $this->requireUid()); - $all = $this->notes->listNotes($houseId); + $all = $this->notes->listNotes($houseId, $sortBy); $sliced = array_slice($all, max(0, $offset), max(0, $limit)); return new DataResponse(array_map(fn ($n) => $n->jsonSerialize(), $sliced)); }); diff --git a/lib/Controller/PhotoController.php b/lib/Controller/PhotoController.php index db2831f..eea196c 100644 --- a/lib/Controller/PhotoController.php +++ b/lib/Controller/PhotoController.php @@ -48,6 +48,7 @@ final class PhotoController extends OCSController { * List all photo folders in a house * * @param int $houseId House id. + * @param string $sortBy Sort mode (custom, newest, oldest, description_asc, description_desc). * @param int<1, 500> $limit Maximum number of folders to return. * @param int<0, max> $offset Number of folders to skip. * @@ -57,10 +58,10 @@ final class PhotoController extends OCSController { */ #[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/photos/folders')] #[NoAdminRequired] - public function indexFolders(int $houseId, int $limit = 100, int $offset = 0): DataResponse { - return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse { + public function indexFolders(int $houseId, string $sortBy = 'custom', int $limit = 100, int $offset = 0): DataResponse { + return $this->runAction(function () use ($houseId, $sortBy, $limit, $offset): DataResponse { $this->auth->requireMember($houseId, $this->requireUid()); - $all = $this->photos->listFolders($houseId); + $all = $this->photos->listFolders($houseId, $sortBy); $sliced = array_slice($all, max(0, $offset), max(0, $limit)); return new DataResponse(array_map(fn ($f) => $f->jsonSerialize(), $sliced)); }); @@ -167,6 +168,7 @@ final class PhotoController extends OCSController { * List all photos in a house * * @param int $houseId House id. + * @param string $sortBy Sort mode (custom, newest, oldest, description_asc, description_desc). * @param int<1, 1000> $limit Maximum number of photos to return. * @param int<0, max> $offset Number of photos to skip. * @@ -176,10 +178,10 @@ final class PhotoController extends OCSController { */ #[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/photos')] #[NoAdminRequired] - public function indexPhotos(int $houseId, int $limit = 200, int $offset = 0): DataResponse { - return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse { + public function indexPhotos(int $houseId, string $sortBy = 'custom', int $limit = 200, int $offset = 0): DataResponse { + return $this->runAction(function () use ($houseId, $sortBy, $limit, $offset): DataResponse { $this->auth->requireMember($houseId, $this->requireUid()); - $all = $this->photos->listPhotos($houseId); + $all = $this->photos->listPhotos($houseId, $sortBy); $sliced = array_slice($all, max(0, $offset), max(0, $limit)); return new DataResponse(array_map(fn ($p) => $p->jsonSerialize(), $sliced)); }); diff --git a/lib/Controller/PrefsController.php b/lib/Controller/PrefsController.php index fbf3e56..665e6dd 100644 --- a/lib/Controller/PrefsController.php +++ b/lib/Controller/PrefsController.php @@ -125,6 +125,100 @@ final class PrefsController extends OCSController { }); } + /** + * Get photo sort preference for a house + * + * @param int $houseId House id. + * + * @return DataResponse + * + * 200: Sort preference returned + */ + #[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/prefs/photo-sort')] + #[NoAdminRequired] + public function getPhotoSort(int $houseId): DataResponse { + return $this->runAction(function () use ($houseId): DataResponse { + $uid = $this->requireUid(); + $this->auth->requireMember($houseId, $uid); + return new DataResponse([ + 'sort' => $this->prefs->getPhotoSort($uid, $houseId), + 'foldersFirst' => $this->prefs->getPhotoFoldersFirst($uid, $houseId), + ]); + }); + } + + /** + * Set photo sort preference for a house + * + * @param int $houseId House id. + * @param string|null $sort Sort mode. + * @param bool|null $foldersFirst Whether folders appear first. + * + * @return DataResponse + * + * 200: Sort preference updated + */ + #[ApiRoute(verb: 'PUT', url: '/api/houses/{houseId}/prefs/photo-sort')] + #[NoAdminRequired] + public function setPhotoSort(int $houseId, ?string $sort = null, ?bool $foldersFirst = null): DataResponse { + return $this->runAction(function () use ($houseId, $sort, $foldersFirst): DataResponse { + $uid = $this->requireUid(); + $this->auth->requireMember($houseId, $uid); + if ($sort !== null) { + $this->prefs->setPhotoSort($uid, $houseId, $sort); + } + if ($foldersFirst !== null) { + $this->prefs->setPhotoFoldersFirst($uid, $houseId, $foldersFirst); + } + return new DataResponse([ + 'sort' => $this->prefs->getPhotoSort($uid, $houseId), + 'foldersFirst' => $this->prefs->getPhotoFoldersFirst($uid, $houseId), + ]); + }); + } + + /** + * Get note sort preference for a house + * + * @param int $houseId House id. + * + * @return DataResponse + * + * 200: Sort preference returned + */ + #[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/prefs/note-sort')] + #[NoAdminRequired] + public function getNoteSort(int $houseId): DataResponse { + return $this->runAction(function () use ($houseId): DataResponse { + $uid = $this->requireUid(); + $this->auth->requireMember($houseId, $uid); + return new DataResponse([ + 'sort' => $this->prefs->getNoteSort($uid, $houseId), + ]); + }); + } + + /** + * Set note sort preference for a house + * + * @param int $houseId House id. + * @param string $sort Sort mode. + * + * @return DataResponse + * + * 200: Sort preference updated + */ + #[ApiRoute(verb: 'PUT', url: '/api/houses/{houseId}/prefs/note-sort')] + #[NoAdminRequired] + public function setNoteSort(int $houseId, string $sort): DataResponse { + return $this->runAction(function () use ($houseId, $sort): DataResponse { + $uid = $this->requireUid(); + $this->auth->requireMember($houseId, $uid); + $stored = $this->prefs->setNoteSort($uid, $houseId, $sort); + return new DataResponse(['sort' => $stored]); + }); + } + /** * Get notification preferences for a house * diff --git a/lib/Db/NoteMapper.php b/lib/Db/NoteMapper.php index 82f10dc..881b753 100644 --- a/lib/Db/NoteMapper.php +++ b/lib/Db/NoteMapper.php @@ -24,17 +24,39 @@ class NoteMapper extends QBMapper { /** * @return Note[] */ - public function findByHouse(int $houseId): array { + public function findByHouse(int $houseId, string $sortBy = 'custom'): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->getTableName()) - ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))) - ->orderBy('sort_order', 'ASC') - ->addOrderBy('created_at', 'DESC'); + ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))); + $this->applySort($qb, $sortBy); return $this->findEntities($qb); } + private function applySort(IQueryBuilder $qb, string $sortBy): void { + switch ($sortBy) { + case 'newest': + $qb->orderBy('created_at', 'DESC'); + break; + case 'oldest': + $qb->orderBy('created_at', 'ASC'); + break; + case 'title_asc': + $qb->orderBy('title', 'ASC') + ->addOrderBy('created_at', 'DESC'); + break; + case 'title_desc': + $qb->orderBy('title', 'DESC') + ->addOrderBy('created_at', 'DESC'); + break; + default: // custom + $qb->orderBy('sort_order', 'ASC') + ->addOrderBy('created_at', 'DESC'); + break; + } + } + /** * @throws DoesNotExistException */ diff --git a/lib/Db/PhotoFolderMapper.php b/lib/Db/PhotoFolderMapper.php index 0442cef..9f57ff4 100644 --- a/lib/Db/PhotoFolderMapper.php +++ b/lib/Db/PhotoFolderMapper.php @@ -24,13 +24,32 @@ class PhotoFolderMapper extends QBMapper { /** * @return PhotoFolder[] */ - public function findByHouse(int $houseId): array { + public function findByHouse(int $houseId, string $sortBy = 'custom'): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->getTableName()) - ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))) - ->orderBy('sort_order', 'ASC') - ->addOrderBy('name', 'ASC'); + ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))); + + switch ($sortBy) { + case 'newest': + $qb->orderBy('created_at', 'DESC'); + break; + case 'oldest': + $qb->orderBy('created_at', 'ASC'); + break; + case 'description_asc': + $qb->orderBy('name', 'ASC') + ->addOrderBy('created_at', 'DESC'); + break; + case 'description_desc': + $qb->orderBy('name', 'DESC') + ->addOrderBy('created_at', 'DESC'); + break; + default: // custom + $qb->orderBy('sort_order', 'ASC') + ->addOrderBy('name', 'ASC'); + break; + } return $this->findEntities($qb); } diff --git a/lib/Db/PhotoMapper.php b/lib/Db/PhotoMapper.php index 6b2d7dc..f4f0899 100644 --- a/lib/Db/PhotoMapper.php +++ b/lib/Db/PhotoMapper.php @@ -24,13 +24,12 @@ class PhotoMapper extends QBMapper { /** * @return Photo[] */ - public function findByHouse(int $houseId): array { + public function findByHouse(int $houseId, string $sortBy = 'custom'): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->getTableName()) - ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))) - ->orderBy('sort_order', 'ASC') - ->addOrderBy('created_at', 'DESC'); + ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))); + $this->applySort($qb, $sortBy); return $this->findEntities($qb); } @@ -38,13 +37,12 @@ class PhotoMapper extends QBMapper { /** * @return Photo[] */ - public function findByFolder(int $folderId): array { + public function findByFolder(int $folderId, string $sortBy = 'custom'): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->getTableName()) - ->where($qb->expr()->eq('folder_id', $qb->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))) - ->orderBy('sort_order', 'ASC') - ->addOrderBy('created_at', 'DESC'); + ->where($qb->expr()->eq('folder_id', $qb->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); + $this->applySort($qb, $sortBy); return $this->findEntities($qb); } @@ -52,18 +50,40 @@ class PhotoMapper extends QBMapper { /** * @return Photo[] */ - public function findRootByHouse(int $houseId): array { + public function findRootByHouse(int $houseId, string $sortBy = 'custom'): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->getTableName()) ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->isNull('folder_id')) - ->orderBy('sort_order', 'ASC') - ->addOrderBy('created_at', 'DESC'); + ->andWhere($qb->expr()->isNull('folder_id')); + $this->applySort($qb, $sortBy); return $this->findEntities($qb); } + private function applySort(IQueryBuilder $qb, string $sortBy): void { + switch ($sortBy) { + case 'newest': + $qb->orderBy('created_at', 'DESC'); + break; + case 'oldest': + $qb->orderBy('created_at', 'ASC'); + break; + case 'description_asc': + $qb->orderBy('caption', 'ASC') + ->addOrderBy('created_at', 'DESC'); + break; + case 'description_desc': + $qb->orderBy('caption', 'DESC') + ->addOrderBy('created_at', 'DESC'); + break; + default: // custom + $qb->orderBy('sort_order', 'ASC') + ->addOrderBy('created_at', 'DESC'); + break; + } + } + /** * @throws DoesNotExistException */ diff --git a/lib/Service/NoteService.php b/lib/Service/NoteService.php index c0ce132..bb033bf 100644 --- a/lib/Service/NoteService.php +++ b/lib/Service/NoteService.php @@ -21,8 +21,8 @@ class NoteService { /** * @return Note[] */ - public function listNotes(int $houseId): array { - return $this->noteMapper->findByHouse($houseId); + public function listNotes(int $houseId, string $sortBy = 'custom'): array { + return $this->noteMapper->findByHouse($houseId, $sortBy); } public function getNote(int $noteId): Note { diff --git a/lib/Service/PhotoService.php b/lib/Service/PhotoService.php index 983a0aa..f362cac 100644 --- a/lib/Service/PhotoService.php +++ b/lib/Service/PhotoService.php @@ -26,8 +26,8 @@ class PhotoService { /** * @return PhotoFolder[] */ - public function listFolders(int $houseId): array { - return $this->folderMapper->findByHouse($houseId); + public function listFolders(int $houseId, string $sortBy = 'custom'): array { + return $this->folderMapper->findByHouse($houseId, $sortBy); } public function getFolder(int $folderId): PhotoFolder { @@ -110,15 +110,15 @@ class PhotoService { /** * @return Photo[] */ - public function listPhotos(int $houseId): array { - return $this->photoMapper->findByHouse($houseId); + public function listPhotos(int $houseId, string $sortBy = 'custom'): array { + return $this->photoMapper->findByHouse($houseId, $sortBy); } /** * @return Photo[] */ - public function listPhotosByFolder(int $folderId): array { - return $this->photoMapper->findByFolder($folderId); + public function listPhotosByFolder(int $folderId, string $sortBy = 'custom'): array { + return $this->photoMapper->findByFolder($folderId, $sortBy); } public function getPhoto(int $photoId): Photo { diff --git a/lib/Service/PrefsService.php b/lib/Service/PrefsService.php index b9df34d..26d9891 100644 --- a/lib/Service/PrefsService.php +++ b/lib/Service/PrefsService.php @@ -52,6 +52,61 @@ class PrefsService { return $normalized; } + // ----- Sort preferences ----- + + private const KEY_PHOTO_SORT = 'photo_sort'; + private const KEY_NOTE_SORT = 'note_sort'; + + public function getPhotoSort(string $uid, int $houseId): string { + return $this->config->getUserValue( + $uid, + Application::APP_ID, + self::KEY_PHOTO_SORT . '_' . $houseId, + 'custom', + ); + } + + public function setPhotoSort(string $uid, int $houseId, string $sort): string { + $allowed = ['custom', 'newest', 'oldest', 'description_asc', 'description_desc']; + if (!in_array($sort, $allowed, true)) { + $sort = 'custom'; + } + $this->config->setUserValue($uid, Application::APP_ID, self::KEY_PHOTO_SORT . '_' . $houseId, $sort); + return $sort; + } + + public function getPhotoFoldersFirst(string $uid, int $houseId): bool { + return $this->config->getUserValue( + $uid, + Application::APP_ID, + 'photo_folders_first_' . $houseId, + '1', + ) === '1'; + } + + public function setPhotoFoldersFirst(string $uid, int $houseId, bool $value): bool { + $this->config->setUserValue($uid, Application::APP_ID, 'photo_folders_first_' . $houseId, $value ? '1' : '0'); + return $value; + } + + public function getNoteSort(string $uid, int $houseId): string { + return $this->config->getUserValue( + $uid, + Application::APP_ID, + self::KEY_NOTE_SORT . '_' . $houseId, + 'custom', + ); + } + + public function setNoteSort(string $uid, int $houseId, string $sort): string { + $allowed = ['custom', 'newest', 'oldest', 'title_asc', 'title_desc']; + if (!in_array($sort, $allowed, true)) { + $sort = 'custom'; + } + $this->config->setUserValue($uid, Application::APP_ID, self::KEY_NOTE_SORT . '_' . $houseId, $sort); + return $sort; + } + // ----- Notification preferences ----- public function getNotificationPref(string $uid, int $houseId, string $prefKey): bool { diff --git a/openapi.json b/openapi.json index b9969dc..9c3ef40 100644 --- a/openapi.json +++ b/openapi.json @@ -4018,6 +4018,15 @@ "format": "int64" } }, + { + "name": "sortBy", + "in": "query", + "description": "Sort mode (custom, newest, oldest, title_asc, title_desc).", + "schema": { + "type": "string", + "default": "custom" + } + }, { "name": "limit", "in": "query", @@ -4657,6 +4666,15 @@ "format": "int64" } }, + { + "name": "sortBy", + "in": "query", + "description": "Sort mode (custom, newest, oldest, description_asc, description_desc).", + "schema": { + "type": "string", + "default": "custom" + } + }, { "name": "limit", "in": "query", @@ -5273,6 +5291,15 @@ "format": "int64" } }, + { + "name": "sortBy", + "in": "query", + "description": "Sort mode (custom, newest, oldest, description_asc, description_desc).", + "schema": { + "type": "string", + "default": "custom" + } + }, { "name": "limit", "in": "query", @@ -6285,6 +6312,481 @@ } } }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/prefs/photo-sort": { + "get": { + "operationId": "prefs-get-photo-sort", + "summary": "Get photo sort preference for a house", + "tags": [ + "prefs" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House 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": "Sort preference 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": [ + "sort", + "foldersFirst" + ], + "properties": { + "sort": { + "type": "string" + }, + "foldersFirst": { + "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": {} + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "prefs-set-photo-sort", + "summary": "Set photo sort preference for a house", + "tags": [ + "prefs" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sort": { + "type": "string", + "nullable": true, + "default": null, + "description": "Sort mode." + }, + "foldersFirst": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Whether folders appear first." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House 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": "Sort preference updated", + "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": [ + "sort", + "foldersFirst" + ], + "properties": { + "sort": { + "type": "string" + }, + "foldersFirst": { + "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/pantry/api/houses/{houseId}/prefs/note-sort": { + "get": { + "operationId": "prefs-get-note-sort", + "summary": "Get note sort preference for a house", + "tags": [ + "prefs" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House 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": "Sort preference 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": [ + "sort" + ], + "properties": { + "sort": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "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": "prefs-set-note-sort", + "summary": "Set note sort preference for a house", + "tags": [ + "prefs" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "sort" + ], + "properties": { + "sort": { + "type": "string", + "description": "Sort mode." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House 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": "Sort preference updated", + "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": [ + "sort" + ], + "properties": { + "sort": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "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/pantry/api/houses/{houseId}/prefs/notifications": { "get": { "operationId": "prefs-get-notification-prefs", diff --git a/src/api/notes.ts b/src/api/notes.ts index ca3fcad..e23c459 100644 --- a/src/api/notes.ts +++ b/src/api/notes.ts @@ -1,8 +1,10 @@ import { ocs } from '@/axios' import type { Note } from './types' -export async function listNotes(houseId: number): Promise { - const resp = await ocs.get(`/houses/${houseId}/notes`) +export async function listNotes(houseId: number, sortBy?: string): Promise { + const resp = await ocs.get(`/houses/${houseId}/notes`, { + params: sortBy ? { sortBy } : undefined, + }) return resp.data ?? [] } diff --git a/src/api/photos.ts b/src/api/photos.ts index 0d5e51d..7693eac 100644 --- a/src/api/photos.ts +++ b/src/api/photos.ts @@ -3,8 +3,10 @@ import type { Photo, PhotoFolder } from './types' // ----- Folders ----- -export async function listFolders(houseId: number): Promise { - const resp = await ocs.get(`/houses/${houseId}/photos/folders`) +export async function listFolders(houseId: number, sortBy?: string): Promise { + const resp = await ocs.get(`/houses/${houseId}/photos/folders`, { + params: sortBy ? { sortBy } : undefined, + }) return resp.data ?? [] } @@ -35,8 +37,10 @@ export async function reorderFolders( // ----- Photos ----- -export async function listPhotos(houseId: number): Promise { - const resp = await ocs.get(`/houses/${houseId}/photos`) +export async function listPhotos(houseId: number, sortBy?: string): Promise { + const resp = await ocs.get(`/houses/${houseId}/photos`, { + params: sortBy ? { sortBy } : undefined, + }) return resp.data ?? [] } diff --git a/src/api/prefs.ts b/src/api/prefs.ts index c7f5cc9..556c3e2 100644 --- a/src/api/prefs.ts +++ b/src/api/prefs.ts @@ -21,6 +21,37 @@ export async function setImageFolder(houseId: number, folder: string): Promise { + const resp = await ocs.get(`/houses/${houseId}/prefs/photo-sort`) + return resp.data ?? { sort: 'custom', foldersFirst: true } +} + +export async function setPhotoSort( + houseId: number, + prefs: Partial, +): Promise { + const resp = await ocs.put(`/houses/${houseId}/prefs/photo-sort`, prefs) + return resp.data ?? { sort: 'custom', foldersFirst: true } +} + +export async function getNoteSort(houseId: number): Promise<{ sort: NoteSort }> { + const resp = await ocs.get<{ sort: NoteSort }>(`/houses/${houseId}/prefs/note-sort`) + return resp.data ?? { sort: 'custom' } +} + +export async function setNoteSort(houseId: number, sort: NoteSort): Promise<{ sort: NoteSort }> { + const resp = await ocs.put<{ sort: NoteSort }>(`/houses/${houseId}/prefs/note-sort`, { sort }) + return resp.data ?? { sort } +} + export interface NotificationPrefs { notifyPhoto: boolean notifyNoteCreate: boolean diff --git a/src/components/Notes/NoteCard.test.ts b/src/components/Notes/NoteCard.test.ts index 768eef6..87d9012 100644 --- a/src/components/Notes/NoteCard.test.ts +++ b/src/components/Notes/NoteCard.test.ts @@ -155,4 +155,38 @@ describe('NoteCard', () => { expect(card.classes()).not.toContain('note-card--dragging') }) }) + + describe('draggableEnabled', () => { + it('is draggable by default', () => { + const wrapper = mount(NoteCard, { props: { note: makeNote() } }) + expect(wrapper.find('.note-card').attributes('draggable')).toBe('true') + }) + + it('is not draggable when draggableEnabled is false', () => { + const wrapper = mount(NoteCard, { + props: { note: makeNote(), draggableEnabled: false }, + }) + expect(wrapper.find('.note-card').attributes('draggable')).toBe('false') + }) + + it('does not emit drag-start when draggableEnabled is false', async () => { + const wrapper = mount(NoteCard, { + props: { note: makeNote({ id: 7 }), draggableEnabled: false }, + }) + await wrapper.find('.note-card').trigger('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn() }, + }) + expect(wrapper.emitted('drag-start')).toBeFalsy() + }) + + it('does not apply dragging class when draggableEnabled is false', async () => { + const wrapper = mount(NoteCard, { + props: { note: makeNote(), draggableEnabled: false }, + }) + await wrapper.find('.note-card').trigger('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn() }, + }) + expect(wrapper.find('.note-card').classes()).not.toContain('note-card--dragging') + }) + }) }) diff --git a/src/components/Notes/NoteCard.vue b/src/components/Notes/NoteCard.vue index 6685568..2407a15 100644 --- a/src/components/Notes/NoteCard.vue +++ b/src/components/Notes/NoteCard.vue @@ -4,7 +4,7 @@ :class="{ 'note-card--dragging': isDragging }" :style="cardStyle" :data-drag-id="note.id" - draggable="true" + :draggable="draggableEnabled ? 'true' : 'false'" @dragstart="onDragStart" @dragend="onDragEnd" @dragover.prevent="onDragOver" @@ -35,7 +35,9 @@ import DeleteIcon from '@icons/Delete.vue' import { contrastColor } from './noteColors' import type { Note } from '@/api/types' -const props = defineProps<{ note: Note }>() +const props = withDefaults(defineProps<{ note: Note; draggableEnabled?: boolean }>(), { + draggableEnabled: true, +}) const emit = defineEmits<{ edit: [note: Note] delete: [note: Note] @@ -54,7 +56,7 @@ const cardStyle = computed(() => { }) function onDragStart(e: DragEvent) { - if (!e.dataTransfer) return + if (!props.draggableEnabled || !e.dataTransfer) return isDragging.value = true e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('application/x-pantry-note', String(props.note.id)) diff --git a/src/components/Photos/PhotoCard.test.ts b/src/components/Photos/PhotoCard.test.ts index 0edc4e3..0391c96 100644 --- a/src/components/Photos/PhotoCard.test.ts +++ b/src/components/Photos/PhotoCard.test.ts @@ -50,8 +50,8 @@ function makePhoto(overrides: Partial = {}): Photo { } } -function mountCard(overrides: Partial = {}) { - return mount(PhotoCard, { props: { photo: makePhoto(overrides), houseId: 1 } }) +function mountCard(overrides: Partial = {}, reorderEnabled = true) { + return mount(PhotoCard, { props: { photo: makePhoto(overrides), houseId: 1, reorderEnabled } }) } describe('PhotoCard', () => { @@ -180,4 +180,47 @@ describe('PhotoCard', () => { expect(card.classes()).not.toContain('photo-card--dragging') }) }) + + describe('reorderEnabled', () => { + it('is draggable even when reorder is disabled', () => { + const wrapper = mountCard({}, false) + expect(wrapper.find('.photo-card').attributes('draggable')).toBe('true') + }) + + it('does not emit drag-start when reorder is disabled', async () => { + const wrapper = mountCard({ id: 7 }, false) + await wrapper.find('.photo-card').trigger('dragstart', { + dataTransfer: { effectAllowed: '', setData: vi.fn() }, + }) + expect(wrapper.emitted('drag-start')).toBeFalsy() + }) + + it('still sets drag data when reorder is disabled (for folder drops)', async () => { + const setData = vi.fn() + const wrapper = mountCard({ id: 3, folderId: null }, false) + await wrapper.find('.photo-card').trigger('dragstart', { + dataTransfer: { effectAllowed: '', setData }, + }) + expect(setData).toHaveBeenCalledWith( + 'application/x-pantry-photo', + JSON.stringify({ id: 3, folderId: null }), + ) + }) + + it('does not emit reorder-over when reorder is disabled', async () => { + const wrapper = mountCard({}, false) + await wrapper.find('.photo-card').trigger('dragover', { + dataTransfer: { types: ['application/x-pantry-photo'] }, + }) + expect(wrapper.emitted('reorder-over')).toBeFalsy() + }) + + it('emits reorder-over when reorder is enabled', async () => { + const wrapper = mountCard({ id: 2 }, true) + await wrapper.find('.photo-card').trigger('dragover', { + dataTransfer: { types: ['application/x-pantry-photo'] }, + }) + expect(wrapper.emitted('reorder-over')).toBeTruthy() + }) + }) }) diff --git a/src/components/Photos/PhotoCard.vue b/src/components/Photos/PhotoCard.vue index 4e6ee81..68402a0 100644 --- a/src/components/Photos/PhotoCard.vue +++ b/src/components/Photos/PhotoCard.vue @@ -47,7 +47,10 @@ import DeleteIcon from '@icons/Delete.vue' import ArrowUpIcon from '@icons/ArrowUp.vue' import type { Photo } from '@/api/types' -const props = defineProps<{ photo: Photo; houseId: number }>() +const props = withDefaults( + defineProps<{ photo: Photo; houseId: number; reorderEnabled?: boolean }>(), + { reorderEnabled: true }, +) const emit = defineEmits<{ preview: [photo: Photo] edit: [photo: Photo] @@ -71,7 +74,9 @@ function onDragStart(e: DragEvent) { 'application/x-pantry-photo', JSON.stringify({ id: props.photo.id, folderId: props.photo.folderId }), ) - emit('drag-start', props.photo.id) + if (props.reorderEnabled) { + emit('drag-start', props.photo.id) + } } function onDragEnd() { @@ -79,6 +84,7 @@ function onDragEnd() { } function onDragOver(e: DragEvent) { + if (!props.reorderEnabled) return if (!e.dataTransfer?.types.includes('application/x-pantry-photo')) return emit('reorder-over', props.photo.id, e) } diff --git a/src/composables/useNotes.test.ts b/src/composables/useNotes.test.ts index 515d3b9..db185c5 100644 --- a/src/composables/useNotes.test.ts +++ b/src/composables/useNotes.test.ts @@ -121,4 +121,40 @@ describe('useNotes', () => { expect(wall.notes.value[1].id).toBe(1) }) }) + + describe('sortBy', () => { + it('defaults to custom', () => { + const wall = useNotes(1) + expect(wall.sortBy.value).toBe('custom') + }) + + it('passes sortBy value to listNotes', async () => { + mockApi.listNotes.mockResolvedValue([]) + + const wall = useNotes(1) + wall.sortBy.value = 'newest' + await wall.load() + + expect(mockApi.listNotes).toHaveBeenCalledWith(1, 'newest') + }) + + it('uses sort argument when provided to load()', async () => { + mockApi.listNotes.mockResolvedValue([]) + + const wall = useNotes(1) + wall.sortBy.value = 'custom' + await wall.load('title_asc') + + expect(mockApi.listNotes).toHaveBeenCalledWith(1, 'title_asc') + }) + + it('uses default custom sort when no argument given', async () => { + mockApi.listNotes.mockResolvedValue([]) + + const wall = useNotes(1) + await wall.load() + + expect(mockApi.listNotes).toHaveBeenCalledWith(1, 'custom') + }) + }) }) diff --git a/src/composables/useNotes.ts b/src/composables/useNotes.ts index 7c8b442..7707c42 100644 --- a/src/composables/useNotes.ts +++ b/src/composables/useNotes.ts @@ -1,17 +1,20 @@ import { ref } from 'vue' import * as api from '@/api/notes' import type { Note } from '@/api/types' +import type { NoteSort } from '@/api/prefs' export function useNotes(houseId: number) { const notes = ref([]) const loading = ref(false) const error = ref(null) + const sortBy = ref('custom') - async function load(): Promise { + async function load(sort?: NoteSort): Promise { loading.value = true error.value = null + const s = sort ?? sortBy.value try { - notes.value = await api.listNotes(houseId) + notes.value = await api.listNotes(houseId, s) } catch (e) { error.value = (e as Error).message } finally { @@ -51,5 +54,5 @@ export function useNotes(houseId: number) { await api.reorderNotes(houseId, items) } - return { notes, loading, error, load, create, update, remove, reorder } + return { notes, loading, error, sortBy, load, create, update, remove, reorder } } diff --git a/src/composables/usePhotos.test.ts b/src/composables/usePhotos.test.ts index c5f4251..7e5ad1c 100644 --- a/src/composables/usePhotos.test.ts +++ b/src/composables/usePhotos.test.ts @@ -259,4 +259,79 @@ describe('usePhotos', () => { expect(board.folders.value[1].id).toBe(1) }) }) + + describe('sortBy', () => { + it('defaults to custom', () => { + const board = usePhotos(1) + expect(board.sortBy.value).toBe('custom') + }) + + it('passes sortBy value to listPhotos and listFolders', async () => { + mockApi.listPhotos.mockResolvedValue([]) + mockApi.listFolders.mockResolvedValue([]) + + const board = usePhotos(1) + board.sortBy.value = 'newest' + await board.load() + + expect(mockApi.listPhotos).toHaveBeenCalledWith(1, 'newest') + expect(mockApi.listFolders).toHaveBeenCalledWith(1, 'newest') + }) + + it('uses sort argument when provided to load()', async () => { + mockApi.listPhotos.mockResolvedValue([]) + mockApi.listFolders.mockResolvedValue([]) + + const board = usePhotos(1) + board.sortBy.value = 'custom' + await board.load('description_asc') + + expect(mockApi.listPhotos).toHaveBeenCalledWith(1, 'description_asc') + expect(mockApi.listFolders).toHaveBeenCalledWith(1, 'description_asc') + }) + + it('rootPhotos sorts by sortOrder in custom mode', async () => { + mockApi.listPhotos.mockResolvedValue([ + makePhoto({ id: 1, folderId: null, sortOrder: 2 }), + makePhoto({ id: 2, folderId: null, sortOrder: 0 }), + makePhoto({ id: 3, folderId: null, sortOrder: 1 }), + ]) + mockApi.listFolders.mockResolvedValue([]) + + const board = usePhotos(1) + await board.load() + + expect(board.rootPhotos.value.map((p) => p.id)).toEqual([2, 3, 1]) + }) + + it('rootPhotos preserves server order in non-custom mode', async () => { + mockApi.listPhotos.mockResolvedValue([ + makePhoto({ id: 3, folderId: null, sortOrder: 2 }), + makePhoto({ id: 1, folderId: null, sortOrder: 0 }), + makePhoto({ id: 2, folderId: null, sortOrder: 1 }), + ]) + mockApi.listFolders.mockResolvedValue([]) + + const board = usePhotos(1) + board.sortBy.value = 'newest' + await board.load() + + // Should preserve the array order from the server + expect(board.rootPhotos.value.map((p) => p.id)).toEqual([3, 1, 2]) + }) + + it('photosInFolder preserves server order in non-custom mode', async () => { + mockApi.listPhotos.mockResolvedValue([ + makePhoto({ id: 3, folderId: 5, sortOrder: 2 }), + makePhoto({ id: 1, folderId: 5, sortOrder: 0 }), + ]) + mockApi.listFolders.mockResolvedValue([]) + + const board = usePhotos(1) + board.sortBy.value = 'oldest' + await board.load() + + expect(board.photosInFolder(5).map((p) => p.id)).toEqual([3, 1]) + }) + }) }) diff --git a/src/composables/usePhotos.ts b/src/composables/usePhotos.ts index 27e5e44..01d4966 100644 --- a/src/composables/usePhotos.ts +++ b/src/composables/usePhotos.ts @@ -1,6 +1,7 @@ import { computed, ref } from 'vue' import * as api from '@/api/photos' import type { Photo, PhotoFolder } from '@/api/types' +import type { PhotoSort } from '@/api/prefs' export interface UploadEntry { id: string @@ -17,12 +18,14 @@ export function usePhotos(houseId: number) { const loading = ref(false) const error = ref(null) const uploads = ref([]) + const sortBy = ref('custom') - async function load(): Promise { + async function load(sort?: PhotoSort): Promise { loading.value = true error.value = null + const s = sort ?? sortBy.value try { - const [p, f] = await Promise.all([api.listPhotos(houseId), api.listFolders(houseId)]) + const [p, f] = await Promise.all([api.listPhotos(houseId, s), api.listFolders(houseId, s)]) photos.value = p folders.value = f } catch (e) { @@ -32,14 +35,20 @@ export function usePhotos(houseId: number) { } } - const rootPhotos = computed(() => - photos.value.filter((p) => p.folderId === null).sort((a, b) => a.sortOrder - b.sortOrder), - ) + const rootPhotos = computed(() => { + const filtered = photos.value.filter((p) => p.folderId === null) + if (sortBy.value === 'custom') { + return filtered.sort((a, b) => a.sortOrder - b.sortOrder) + } + return filtered + }) function photosInFolder(folderId: number): Photo[] { - return photos.value - .filter((p) => p.folderId === folderId) - .sort((a, b) => a.sortOrder - b.sortOrder) + const filtered = photos.value.filter((p) => p.folderId === folderId) + if (sortBy.value === 'custom') { + return filtered.sort((a, b) => a.sortOrder - b.sortOrder) + } + return filtered } // ----- Photos ----- @@ -126,6 +135,7 @@ export function usePhotos(houseId: number) { uploads, loading, error, + sortBy, load, rootPhotos, photosInFolder, diff --git a/src/composables/useTouchReorder.ts b/src/composables/useTouchReorder.ts index 2eb2147..ebe57ae 100644 --- a/src/composables/useTouchReorder.ts +++ b/src/composables/useTouchReorder.ts @@ -22,6 +22,7 @@ const SCROLL_SPEED = 8 export function useTouchReorder( containerRef: Ref, callbacks: TouchReorderCallbacks, + enabled?: Ref, ) { const isTouchDragging = ref(false) @@ -120,6 +121,7 @@ export function useTouchReorder( } function onTouchStart(e: TouchEvent) { + if (enabled && !enabled.value) return const touch = e.touches[0] if (!touch) return diff --git a/src/views/NotesView.vue b/src/views/NotesView.vue index d058390..4da826a 100644 --- a/src/views/NotesView.vue +++ b/src/views/NotesView.vue @@ -2,6 +2,23 @@