mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-18 01:28:57 +00:00
feat: photos/notes preset sorting options
This commit is contained in:
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -125,6 +125,100 @@ final class PrefsController extends OCSController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get photo sort preference for a house
|
||||
*
|
||||
* @param int $houseId House id.
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{sort: string, foldersFirst: bool}, array{}>
|
||||
*
|
||||
* 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<Http::STATUS_OK, array{sort: string, foldersFirst: bool}, array{}>
|
||||
*
|
||||
* 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<Http::STATUS_OK, array{sort: string}, array{}>
|
||||
*
|
||||
* 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<Http::STATUS_OK, array{sort: string}, array{}>
|
||||
*
|
||||
* 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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
502
openapi.json
502
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",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ocs } from '@/axios'
|
||||
import type { Note } from './types'
|
||||
|
||||
export async function listNotes(houseId: number): Promise<Note[]> {
|
||||
const resp = await ocs.get<Note[]>(`/houses/${houseId}/notes`)
|
||||
export async function listNotes(houseId: number, sortBy?: string): Promise<Note[]> {
|
||||
const resp = await ocs.get<Note[]>(`/houses/${houseId}/notes`, {
|
||||
params: sortBy ? { sortBy } : undefined,
|
||||
})
|
||||
return resp.data ?? []
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { Photo, PhotoFolder } from './types'
|
||||
|
||||
// ----- Folders -----
|
||||
|
||||
export async function listFolders(houseId: number): Promise<PhotoFolder[]> {
|
||||
const resp = await ocs.get<PhotoFolder[]>(`/houses/${houseId}/photos/folders`)
|
||||
export async function listFolders(houseId: number, sortBy?: string): Promise<PhotoFolder[]> {
|
||||
const resp = await ocs.get<PhotoFolder[]>(`/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<Photo[]> {
|
||||
const resp = await ocs.get<Photo[]>(`/houses/${houseId}/photos`)
|
||||
export async function listPhotos(houseId: number, sortBy?: string): Promise<Photo[]> {
|
||||
const resp = await ocs.get<Photo[]>(`/houses/${houseId}/photos`, {
|
||||
params: sortBy ? { sortBy } : undefined,
|
||||
})
|
||||
return resp.data ?? []
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,37 @@ export async function setImageFolder(houseId: number, folder: string): Promise<s
|
||||
return resp.data?.folder ?? folder
|
||||
}
|
||||
|
||||
export type PhotoSort = 'custom' | 'newest' | 'oldest' | 'description_asc' | 'description_desc'
|
||||
export type NoteSort = 'custom' | 'newest' | 'oldest' | 'title_asc' | 'title_desc'
|
||||
|
||||
export interface PhotoSortPrefs {
|
||||
sort: PhotoSort
|
||||
foldersFirst: boolean
|
||||
}
|
||||
|
||||
export async function getPhotoSort(houseId: number): Promise<PhotoSortPrefs> {
|
||||
const resp = await ocs.get<PhotoSortPrefs>(`/houses/${houseId}/prefs/photo-sort`)
|
||||
return resp.data ?? { sort: 'custom', foldersFirst: true }
|
||||
}
|
||||
|
||||
export async function setPhotoSort(
|
||||
houseId: number,
|
||||
prefs: Partial<PhotoSortPrefs>,
|
||||
): Promise<PhotoSortPrefs> {
|
||||
const resp = await ocs.put<PhotoSortPrefs>(`/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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -50,8 +50,8 @@ function makePhoto(overrides: Partial<Photo> = {}): Photo {
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(overrides: Partial<Photo> = {}) {
|
||||
return mount(PhotoCard, { props: { photo: makePhoto(overrides), houseId: 1 } })
|
||||
function mountCard(overrides: Partial<Photo> = {}, 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Note[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const sortBy = ref<NoteSort>('custom')
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function load(sort?: NoteSort): Promise<void> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const uploads = ref<UploadEntry[]>([])
|
||||
const sortBy = ref<PhotoSort>('custom')
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function load(sort?: PhotoSort): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -22,6 +22,7 @@ const SCROLL_SPEED = 8
|
||||
export function useTouchReorder(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
callbacks: TouchReorderCallbacks,
|
||||
enabled?: Ref<boolean>,
|
||||
) {
|
||||
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
|
||||
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
<div ref="wallRef" class="pantry-notes">
|
||||
<PageToolbar :title="strings.title">
|
||||
<template #actions>
|
||||
<NcActions :aria-label="strings.sortLabel" type="tertiary">
|
||||
<template #icon>
|
||||
<SortIcon :size="20" />
|
||||
</template>
|
||||
<NcActionButton
|
||||
v-for="opt in noteSortOptions"
|
||||
:key="opt.value"
|
||||
:class="{ 'pantry-sort-active': currentSort === opt.value }"
|
||||
@click="changeNoteSort(opt.value)"
|
||||
>
|
||||
<template #icon>
|
||||
<RadioboxMarkedIcon v-if="currentSort === opt.value" :size="20" />
|
||||
<RadioboxBlankIcon v-else :size="20" />
|
||||
</template>
|
||||
{{ opt.label }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
<NcButton variant="primary" @click="openCreateDialog">
|
||||
<template #icon><PlusIcon :size="20" /></template>
|
||||
{{ strings.newNote }}
|
||||
@@ -40,6 +57,7 @@
|
||||
<NoteCard
|
||||
v-else
|
||||
:note="item.note"
|
||||
:draggable-enabled="isCustomSort"
|
||||
@edit="openEditDialog"
|
||||
@delete="confirmDelete"
|
||||
@drag-start="onDragStart"
|
||||
@@ -82,23 +100,62 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import PageToolbar from '@/components/PageToolbar'
|
||||
import { NoteCard, NoteDialog } from '@/components/Notes'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import NoteIcon from '@icons/Note.vue'
|
||||
import SortIcon from '@icons/Sort.vue'
|
||||
import RadioboxBlankIcon from '@icons/RadioboxBlank.vue'
|
||||
import RadioboxMarkedIcon from '@icons/RadioboxMarked.vue'
|
||||
import type { Note } from '@/api/types'
|
||||
import type { NoteSort } from '@/api/prefs'
|
||||
import { getNoteSort, setNoteSort } from '@/api/prefs'
|
||||
import { useNotes } from '@/composables/useNotes'
|
||||
import { useTouchReorder } from '@/composables/useTouchReorder'
|
||||
|
||||
const props = defineProps<{ houseId: string }>()
|
||||
|
||||
const houseIdNum = computed(() => Number(props.houseId))
|
||||
const { notes, loading, load, create, update, remove, reorder } = useNotes(houseIdNum.value)
|
||||
const { notes, loading, load, create, update, remove, reorder, sortBy } = useNotes(houseIdNum.value)
|
||||
|
||||
onMounted(load)
|
||||
// ----- Sort -----
|
||||
|
||||
const currentSort = ref<NoteSort>('custom')
|
||||
const isCustomSort = computed(() => currentSort.value === 'custom')
|
||||
|
||||
const noteSortOptions: { value: NoteSort; label: string }[] = [
|
||||
{ value: 'newest', label: t('pantry', 'Newest first') },
|
||||
{ value: 'oldest', label: t('pantry', 'Oldest first') },
|
||||
{ value: 'title_asc', label: t('pantry', 'Title A\u2013Z') },
|
||||
{ value: 'title_desc', label: t('pantry', 'Title Z\u2013A') },
|
||||
{ value: 'custom', label: t('pantry', 'Custom') },
|
||||
]
|
||||
|
||||
async function loadSortPref() {
|
||||
const prefs = await getNoteSort(houseIdNum.value)
|
||||
currentSort.value = prefs.sort
|
||||
sortBy.value = prefs.sort
|
||||
}
|
||||
|
||||
async function changeNoteSort(value: NoteSort) {
|
||||
currentSort.value = value
|
||||
sortBy.value = value
|
||||
await setNoteSort(houseIdNum.value, value)
|
||||
await load(value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSortPref()
|
||||
await load()
|
||||
})
|
||||
watch(
|
||||
() => props.houseId,
|
||||
() => load(),
|
||||
async () => {
|
||||
await loadSortPref()
|
||||
await load()
|
||||
},
|
||||
)
|
||||
|
||||
// ----- Reorder -----
|
||||
@@ -196,18 +253,22 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// ----- Touch reorder -----
|
||||
useTouchReorder(wallRef, {
|
||||
onDragStart: onDragStart,
|
||||
onReorderOver(hoveredId, clientX) {
|
||||
const el = wallRef.value?.querySelector<HTMLElement>(`[data-drag-id="${hoveredId}"]`)
|
||||
computeDropIndex(hoveredId, clientX, el)
|
||||
useTouchReorder(
|
||||
wallRef,
|
||||
{
|
||||
onDragStart: onDragStart,
|
||||
onReorderOver(hoveredId, clientX) {
|
||||
const el = wallRef.value?.querySelector<HTMLElement>(`[data-drag-id="${hoveredId}"]`)
|
||||
computeDropIndex(hoveredId, clientX, el)
|
||||
},
|
||||
onDrop: commitReorder,
|
||||
onCancel() {
|
||||
draggingNoteId.value = null
|
||||
dropIndex.value = null
|
||||
},
|
||||
},
|
||||
onDrop: commitReorder,
|
||||
onCancel() {
|
||||
draggingNoteId.value = null
|
||||
dropIndex.value = null
|
||||
},
|
||||
})
|
||||
isCustomSort,
|
||||
)
|
||||
|
||||
// ----- Create / Edit -----
|
||||
|
||||
@@ -279,6 +340,7 @@ const strings = {
|
||||
emptyTitle: t('pantry', 'No notes yet'),
|
||||
emptyBody: t('pantry', 'Create your first note to start sharing reminders with your household.'),
|
||||
deleteTitle: t('pantry', 'Delete note'),
|
||||
sortLabel: t('pantry', 'Sort order'),
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -312,4 +374,8 @@ const strings = {
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.pantry-sort-active {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,27 @@
|
||||
</NcButton>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NcActions :aria-label="strings.sortLabel" type="tertiary">
|
||||
<template #icon>
|
||||
<SortIcon :size="20" />
|
||||
</template>
|
||||
<NcActionCheckbox :checked="foldersFirst" @update:checked="toggleFoldersFirst">
|
||||
{{ strings.foldersFirst }}
|
||||
</NcActionCheckbox>
|
||||
<NcActionSeparator />
|
||||
<NcActionButton
|
||||
v-for="opt in photoSortOptions"
|
||||
:key="opt.value"
|
||||
:class="{ 'pantry-sort-active': sortPrefs.sort === opt.value }"
|
||||
@click="changePhotoSort(opt.value)"
|
||||
>
|
||||
<template #icon>
|
||||
<RadioboxMarkedIcon v-if="sortPrefs.sort === opt.value" :size="20" />
|
||||
<RadioboxBlankIcon v-else :size="20" />
|
||||
</template>
|
||||
{{ opt.label }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
<NcButton v-if="!activeFolderId" @click="showFolderDialog = true">
|
||||
<template #icon>
|
||||
<FolderPlusIcon :size="20" />
|
||||
@@ -60,20 +81,22 @@
|
||||
</NcEmptyContent>
|
||||
|
||||
<div v-else class="pantry-photos__grid">
|
||||
<FolderStack
|
||||
v-for="folder in folders"
|
||||
:key="'f-' + folder.id"
|
||||
:folder="folder"
|
||||
:photos="photosInFolder(folder.id)"
|
||||
:house-id="houseIdNum"
|
||||
@open="(f) => navigateToFolder(f.id)"
|
||||
@rename="startRenameFolder(folder)"
|
||||
@delete="confirmDeleteFolder(folder)"
|
||||
@drop-photo="onDropPhotoToFolder"
|
||||
@drop-files="onDropFilesToFolder"
|
||||
@drag-over-change="onFolderDragOverChange"
|
||||
/>
|
||||
<template v-for="(item, i) in rootGridItems" :key="item.key">
|
||||
<template v-if="foldersFirst">
|
||||
<FolderStack
|
||||
v-for="folder in folders"
|
||||
:key="'f-' + folder.id"
|
||||
:folder="folder"
|
||||
:photos="photosInFolder(folder.id)"
|
||||
:house-id="houseIdNum"
|
||||
@open="(f) => navigateToFolder(f.id)"
|
||||
@rename="startRenameFolder(folder)"
|
||||
@delete="confirmDeleteFolder(folder)"
|
||||
@drop-photo="onDropPhotoToFolder"
|
||||
@drop-files="onDropFilesToFolder"
|
||||
@drag-over-change="onFolderDragOverChange"
|
||||
/>
|
||||
</template>
|
||||
<template v-for="item in rootGridItems" :key="item.key">
|
||||
<div
|
||||
v-if="item.type === 'placeholder'"
|
||||
class="pantry-photos__placeholder"
|
||||
@@ -84,10 +107,23 @@
|
||||
<NcProgressBar :value="item.progress" size="medium" />
|
||||
<span class="pantry-photos__upload-name">{{ item.fileName }}</span>
|
||||
</div>
|
||||
<FolderStack
|
||||
v-else-if="item.type === 'folder'"
|
||||
:folder="item.folder"
|
||||
:photos="photosInFolder(item.folder.id)"
|
||||
:house-id="houseIdNum"
|
||||
@open="(f) => navigateToFolder(f.id)"
|
||||
@rename="startRenameFolder(item.folder)"
|
||||
@delete="confirmDeleteFolder(item.folder)"
|
||||
@drop-photo="onDropPhotoToFolder"
|
||||
@drop-files="onDropFilesToFolder"
|
||||
@drag-over-change="onFolderDragOverChange"
|
||||
/>
|
||||
<PhotoCard
|
||||
v-else
|
||||
:photo="item.photo"
|
||||
:house-id="houseIdNum"
|
||||
:reorder-enabled="isCustomSort"
|
||||
@preview="openPreview"
|
||||
@edit="startEditPhoto"
|
||||
@delete="confirmDeletePhoto"
|
||||
@@ -129,9 +165,10 @@
|
||||
<span class="pantry-photos__upload-name">{{ item.fileName }}</span>
|
||||
</div>
|
||||
<PhotoCard
|
||||
v-else
|
||||
v-else-if="item.type === 'photo'"
|
||||
:photo="item.photo"
|
||||
:house-id="houseIdNum"
|
||||
:reorder-enabled="isCustomSort"
|
||||
@preview="openPreview"
|
||||
@edit="startEditPhoto"
|
||||
@delete="confirmDeletePhoto"
|
||||
@@ -234,7 +271,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
@@ -243,13 +280,22 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
|
||||
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
|
||||
import PageToolbar from '@/components/PageToolbar'
|
||||
import { PhotoCard, FolderStack, FolderDialog, PhotoPreview } from '@/components/Photos'
|
||||
import UploadIcon from '@icons/Upload.vue'
|
||||
import ImageIcon from '@icons/Image.vue'
|
||||
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import FolderPlusIcon from '@icons/FolderPlus.vue'
|
||||
import SortIcon from '@icons/Sort.vue'
|
||||
import RadioboxBlankIcon from '@icons/RadioboxBlank.vue'
|
||||
import RadioboxMarkedIcon from '@icons/RadioboxMarked.vue'
|
||||
import type { Photo, PhotoFolder } from '@/api/types'
|
||||
import type { PhotoSort, PhotoSortPrefs } from '@/api/prefs'
|
||||
import { getPhotoSort, setPhotoSort } from '@/api/prefs'
|
||||
import { usePhotos, type UploadEntry } from '@/composables/usePhotos'
|
||||
import { useTouchReorder } from '@/composables/useTouchReorder'
|
||||
|
||||
@@ -271,12 +317,52 @@ const {
|
||||
updateFolder,
|
||||
removeFolder,
|
||||
uploads,
|
||||
sortBy,
|
||||
} = usePhotos(houseIdNum.value)
|
||||
|
||||
onMounted(load)
|
||||
// ----- Sort -----
|
||||
|
||||
const sortPrefs = reactive<PhotoSortPrefs>({ sort: 'custom', foldersFirst: true })
|
||||
const foldersFirst = computed(() => sortPrefs.foldersFirst)
|
||||
const isCustomSort = computed(() => sortPrefs.sort === 'custom')
|
||||
|
||||
const photoSortOptions: { value: PhotoSort; label: string }[] = [
|
||||
{ value: 'newest', label: t('pantry', 'Newest first') },
|
||||
{ value: 'oldest', label: t('pantry', 'Oldest first') },
|
||||
{ value: 'description_asc', label: t('pantry', 'By description A\u2013Z') },
|
||||
{ value: 'description_desc', label: t('pantry', 'By description Z\u2013A') },
|
||||
{ value: 'custom', label: t('pantry', 'Custom') },
|
||||
]
|
||||
|
||||
async function loadSortPrefs() {
|
||||
const prefs = await getPhotoSort(houseIdNum.value)
|
||||
sortPrefs.sort = prefs.sort
|
||||
sortPrefs.foldersFirst = prefs.foldersFirst
|
||||
sortBy.value = prefs.sort
|
||||
}
|
||||
|
||||
async function changePhotoSort(value: PhotoSort) {
|
||||
sortPrefs.sort = value
|
||||
sortBy.value = value
|
||||
await setPhotoSort(houseIdNum.value, { sort: value })
|
||||
await load(value)
|
||||
}
|
||||
|
||||
async function toggleFoldersFirst(value: boolean) {
|
||||
sortPrefs.foldersFirst = value
|
||||
await setPhotoSort(houseIdNum.value, { foldersFirst: value })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSortPrefs()
|
||||
await load()
|
||||
})
|
||||
watch(
|
||||
() => props.houseId,
|
||||
() => load(),
|
||||
async () => {
|
||||
await loadSortPrefs()
|
||||
await load()
|
||||
},
|
||||
)
|
||||
|
||||
// ----- State -----
|
||||
@@ -307,13 +393,18 @@ function navigateToFolder(folderId: number | null) {
|
||||
|
||||
type GridItem =
|
||||
| { type: 'photo'; key: string; photo: Photo }
|
||||
| { type: 'folder'; key: string; folder: PhotoFolder }
|
||||
| { type: 'placeholder'; key: string }
|
||||
| { type: 'upload'; key: string; fileName: string; progress: number }
|
||||
|
||||
const draggingPhotoId = ref<number | null>(null)
|
||||
const dropIndex = ref<number | null>(null)
|
||||
|
||||
function buildGridItems(source: Photo[], activeUploads: UploadEntry[]): GridItem[] {
|
||||
function buildGridItems(
|
||||
source: Photo[],
|
||||
activeUploads: UploadEntry[],
|
||||
mixedFolders?: PhotoFolder[],
|
||||
): GridItem[] {
|
||||
// Upload placeholders go first (newest-first sort means in-progress uploads are at the top).
|
||||
const uploadItems: GridItem[] = activeUploads.map((u) => ({
|
||||
type: 'upload' as const,
|
||||
@@ -322,14 +413,20 @@ function buildGridItems(source: Photo[], activeUploads: UploadEntry[]): GridItem
|
||||
progress: u.progress,
|
||||
}))
|
||||
|
||||
const folderItems: GridItem[] = (mixedFolders ?? []).map((f) => ({
|
||||
type: 'folder' as const,
|
||||
key: 'f-' + f.id,
|
||||
folder: f,
|
||||
}))
|
||||
|
||||
const dragId = draggingPhotoId.value
|
||||
if (dragId === null || dropIndex.value === null) {
|
||||
if (dragId === null || dropIndex.value === null || !isCustomSort.value) {
|
||||
const photoItems: GridItem[] = source.map((p) => ({
|
||||
type: 'photo' as const,
|
||||
key: 'p-' + p.id,
|
||||
photo: p,
|
||||
}))
|
||||
return [...uploadItems, ...photoItems]
|
||||
return [...uploadItems, ...folderItems, ...photoItems]
|
||||
}
|
||||
|
||||
const without = source.filter((p) => p.id !== dragId)
|
||||
@@ -341,7 +438,7 @@ function buildGridItems(source: Photo[], activeUploads: UploadEntry[]): GridItem
|
||||
|
||||
const clampedIndex = Math.min(dropIndex.value, items.length)
|
||||
items.splice(clampedIndex, 0, { type: 'placeholder', key: 'drop-placeholder' })
|
||||
return [...uploadItems, ...items]
|
||||
return [...uploadItems, ...folderItems, ...items]
|
||||
}
|
||||
|
||||
const rootUploads = computed(() => uploads.value.filter((u) => u.folderId === null))
|
||||
@@ -349,7 +446,13 @@ const folderUploads = computed(() =>
|
||||
uploads.value.filter((u) => u.folderId === activeFolderId.value),
|
||||
)
|
||||
|
||||
const rootGridItems = computed(() => buildGridItems(rootPhotos.value, rootUploads.value))
|
||||
const rootGridItems = computed(() =>
|
||||
buildGridItems(
|
||||
rootPhotos.value,
|
||||
rootUploads.value,
|
||||
foldersFirst.value ? undefined : folders.value,
|
||||
),
|
||||
)
|
||||
const folderGridItems = computed(() =>
|
||||
buildGridItems(activeFolderPhotos.value, folderUploads.value),
|
||||
)
|
||||
@@ -434,19 +537,23 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// ----- Touch reorder -----
|
||||
useTouchReorder(boardRef, {
|
||||
onDragStart: onPhotoDragStart,
|
||||
onReorderOver(hoveredId, clientX) {
|
||||
const source = activeFolderId.value ? activeFolderPhotos.value : rootPhotos.value
|
||||
const el = boardRef.value?.querySelector<HTMLElement>(`[data-drag-id="${hoveredId}"]`)
|
||||
computePhotoDropIndex(hoveredId, source, clientX, el)
|
||||
useTouchReorder(
|
||||
boardRef,
|
||||
{
|
||||
onDragStart: onPhotoDragStart,
|
||||
onReorderOver(hoveredId, clientX) {
|
||||
const source = activeFolderId.value ? activeFolderPhotos.value : rootPhotos.value
|
||||
const el = boardRef.value?.querySelector<HTMLElement>(`[data-drag-id="${hoveredId}"]`)
|
||||
computePhotoDropIndex(hoveredId, source, clientX, el)
|
||||
},
|
||||
onDrop: commitReorder,
|
||||
onCancel() {
|
||||
draggingPhotoId.value = null
|
||||
dropIndex.value = null
|
||||
},
|
||||
},
|
||||
onDrop: commitReorder,
|
||||
onCancel() {
|
||||
draggingPhotoId.value = null
|
||||
dropIndex.value = null
|
||||
},
|
||||
})
|
||||
isCustomSort,
|
||||
)
|
||||
|
||||
// Folder dialog
|
||||
const showFolderDialog = ref(false)
|
||||
@@ -642,6 +749,8 @@ const strings = {
|
||||
deletePhotoTitle: t('pantry', 'Delete photo'),
|
||||
deletePhotoBody: t('pantry', 'Are you sure you want to delete this photo?'),
|
||||
deleteFolderTitle: t('pantry', 'Delete folder'),
|
||||
sortLabel: t('pantry', 'Sort order'),
|
||||
foldersFirst: t('pantry', 'Folders first'),
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -726,4 +835,8 @@ const strings = {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pantry-sort-active {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user