feat: photos/notes preset sorting options

This commit is contained in:
2026-04-08 16:18:55 +03:00
parent c7f1b6d076
commit 86a8bd3567
24 changed files with 1252 additions and 110 deletions

View File

@@ -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));
});

View File

@@ -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));
});

View File

@@ -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
*

View File

@@ -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
*/

View File

@@ -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);
}

View File

@@ -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
*/

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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 ?? []
}

View File

@@ -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 ?? []
}

View File

@@ -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

View File

@@ -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')
})
})
})

View File

@@ -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))

View File

@@ -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()
})
})
})

View File

@@ -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)
}

View File

@@ -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')
})
})
})

View File

@@ -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 }
}

View File

@@ -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])
})
})
})

View File

@@ -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,

View File

@@ -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

View File

@@ -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>

View File

@@ -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>