diff --git a/appinfo/info.xml b/appinfo/info.xml index 3969547..e178a59 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -17,7 +17,7 @@ Pantry helps households stay organized in Nextcloud. All data is scoped to a house; members only see the houses they belong to. ]]> - 1.0.0 + 0.0.1 agpl Chen Asraf Pantry diff --git a/composer.json b/composer.json index f7ade40..ab58a32 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ }, "require": { "php": "^8.1", - "sabre/vobject": "^4.5" + "sabre/vobject": "^4.5", + "sabre/xml": "^2.1" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8", diff --git a/composer.lock b/composer.lock index 58d7425..4a3a44b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,33 +4,32 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "899d5eaf730a04eeedf3a31c6a28d2fb", + "content-hash": "e09b973f650d6031942bc831635b4083", "packages": [ { "name": "sabre/uri", - "version": "3.0.3", + "version": "2.3.4", "source": { "type": "git", "url": "https://github.com/sabre-io/uri.git", - "reference": "4fa0b2049e06a4fbe4aea4f0aa69e7b8410a13bc" + "reference": "b76524c22de90d80ca73143680a8e77b1266c291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sabre-io/uri/zipball/4fa0b2049e06a4fbe4aea4f0aa69e7b8410a13bc", - "reference": "4fa0b2049e06a4fbe4aea4f0aa69e7b8410a13bc", + "url": "https://api.github.com/repos/sabre-io/uri/zipball/b76524c22de90d80ca73143680a8e77b1266c291", + "reference": "b76524c22de90d80ca73143680a8e77b1266c291", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94", + "friendsofphp/php-cs-fixer": "^3.63", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "rector/rector": "^2.3" + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-phpunit": "^1.4", + "phpstan/phpstan-strict-rules": "^1.6", + "phpunit/phpunit": "^9.6" }, "type": "library", "autoload": { @@ -65,7 +64,7 @@ "issues": "https://github.com/sabre-io/uri/issues", "source": "https://github.com/fruux/sabre-uri" }, - "time": "2026-04-01T08:19:11+00:00" + "time": "2024-08-27T12:18:16+00:00" }, { "name": "sabre/vobject", @@ -173,16 +172,16 @@ }, { "name": "sabre/xml", - "version": "4.0.7", + "version": "2.2.11", "source": { "type": "git", "url": "https://github.com/sabre-io/xml.git", - "reference": "53db7bad0953949fb61037fbf9b13b421492395c" + "reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sabre-io/xml/zipball/53db7bad0953949fb61037fbf9b13b421492395c", - "reference": "53db7bad0953949fb61037fbf9b13b421492395c", + "url": "https://api.github.com/repos/sabre-io/xml/zipball/01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc", + "reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc", "shasum": "" }, "require": { @@ -190,14 +189,13 @@ "ext-xmlreader": "*", "ext-xmlwriter": "*", "lib-libxml": ">=2.6.20", - "php": "^7.4 || ^8.0", - "sabre/uri": ">=2.0,<4.0.0" + "php": "^7.1 || ^8.0", + "sabre/uri": ">=1.0,<3.0.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94", - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^9.6", - "rector/rector": "^2.3" + "friendsofphp/php-cs-fixer": "~2.17.1||3.63.2", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6" }, "type": "library", "autoload": { @@ -239,7 +237,7 @@ "issues": "https://github.com/sabre-io/xml/issues", "source": "https://github.com/fruux/sabre-xml" }, - "time": "2026-04-02T11:40:41+00:00" + "time": "2024-09-06T07:37:46+00:00" } ], "packages-dev": [ @@ -3284,5 +3282,5 @@ "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/lib/Controller/PrefsController.php b/lib/Controller/PrefsController.php index 67dbad5..7834379 100644 --- a/lib/Controller/PrefsController.php +++ b/lib/Controller/PrefsController.php @@ -21,6 +21,7 @@ use OCP\IUserSession; /** * @psalm-import-type PantryLastHouse from ResponseDefinitions + * @psalm-import-type PantryImageFolder from ResponseDefinitions */ final class PrefsController extends OCSController { use TranslatesDomainExceptions; @@ -83,6 +84,41 @@ final class PrefsController extends OCSController { }); } + /** + * Get the user's preferred image upload folder + * + * @return DataResponse + * + * 200: Folder returned + */ + #[ApiRoute(verb: 'GET', url: '/api/prefs/image-folder')] + #[NoAdminRequired] + public function getImageFolder(): DataResponse { + return $this->runAction(function (): DataResponse { + $uid = $this->requireUid(); + return new DataResponse(['folder' => $this->prefs->getImageFolder($uid)]); + }); + } + + /** + * Set the user's preferred image upload folder + * + * @param string $folder Absolute path within the user's files. + * + * @return DataResponse + * + * 200: Folder updated + */ + #[ApiRoute(verb: 'PUT', url: '/api/prefs/image-folder')] + #[NoAdminRequired] + public function setImageFolder(string $folder): DataResponse { + return $this->runAction(function () use ($folder): DataResponse { + $uid = $this->requireUid(); + $stored = $this->prefs->setImageFolder($uid, $folder); + return new DataResponse(['folder' => $stored]); + }); + } + private function requireUid(): string { $user = $this->userSession->getUser(); if ($user === null) { diff --git a/lib/Controller/ShoppingListController.php b/lib/Controller/ShoppingListController.php index 86104f8..c746a94 100644 --- a/lib/Controller/ShoppingListController.php +++ b/lib/Controller/ShoppingListController.php @@ -12,6 +12,7 @@ use OCA\Pantry\Exception\NotFoundException; use OCA\Pantry\ResponseDefinitions; use OCA\Pantry\Service\CategoryService; use OCA\Pantry\Service\HouseAuthService; +use OCA\Pantry\Service\ImageService; use OCA\Pantry\Service\ShoppingListService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -35,6 +36,7 @@ final class ShoppingListController extends OCSController { private ShoppingListService $lists, private CategoryService $categories, private HouseAuthService $auth, + private ImageService $images, private IUserSession $userSession, ) { parent::__construct($appName, $request); @@ -248,6 +250,7 @@ final class ShoppingListController extends OCSController { * @param string|null $quantity New quantity (empty string clears). * @param string|null $rrule New RRULE (empty string clears). * @param bool|null $repeatFromCompletion New recurrence anchor mode. + * @param int|null $imageFileId File id of attached image (0 or negative clears). * @param int|null $sortOrder New sort order. * * @return DataResponse @@ -265,9 +268,10 @@ final class ShoppingListController extends OCSController { ?string $quantity = null, ?string $rrule = null, ?bool $repeatFromCompletion = null, + ?int $imageFileId = null, ?int $sortOrder = null, ): DataResponse { - return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $categoryId, $quantity, $rrule, $repeatFromCompletion, $sortOrder): DataResponse { + return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $categoryId, $quantity, $rrule, $repeatFromCompletion, $imageFileId, $sortOrder): DataResponse { $this->auth->requireMember($houseId, $this->requireUid()); $item = $this->lists->getItem($itemId); $list = $this->lists->getList($item->getListId()); @@ -296,6 +300,9 @@ final class ShoppingListController extends OCSController { if ($repeatFromCompletion !== null) { $patch['repeatFromCompletion'] = $repeatFromCompletion; } + if ($imageFileId !== null) { + $patch['imageFileId'] = $imageFileId > 0 ? $imageFileId : null; + } if ($sortOrder !== null) { $patch['sortOrder'] = $sortOrder; } @@ -359,6 +366,81 @@ final class ShoppingListController extends OCSController { }); } + /** + * Upload an image for an item + * + * Uploads the request body as an image into the user's configured pantry + * image folder and attaches it to the item. + * + * @param int $houseId House id. + * @param int $listId List id. + * @param int $itemId Item id. + * + * @return DataResponse + * + * 200: Image uploaded and attached + */ + #[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}/image')] + #[NoAdminRequired] + public function uploadItemImage(int $houseId, int $listId, int $itemId): DataResponse { + return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse { + $uid = $this->requireUid(); + $this->auth->requireMember($houseId, $uid); + $item = $this->lists->getItem($itemId); + $list = $this->lists->getList($item->getListId()); + $this->assertListInHouse($list->getHouseId(), $houseId); + if ($item->getListId() !== $listId) { + throw new NotFoundException('Item does not belong to this list'); + } + + $data = $this->request->getUploadedFile('image'); + if ($data === null || !is_array($data) || ($data['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + throw new \InvalidArgumentException('No image uploaded'); + } + $tmp = (string)($data['tmp_name'] ?? ''); + if ($tmp === '' || !is_uploaded_file($tmp)) { + throw new \InvalidArgumentException('Invalid upload'); + } + $bytes = file_get_contents($tmp); + if ($bytes === false) { + throw new \RuntimeException('Could not read uploaded file'); + } + $original = (string)($data['name'] ?? 'image.jpg'); + $fileId = $this->images->uploadForUser($uid, $original, $bytes); + + $updated = $this->lists->updateItem($itemId, ['imageFileId' => $fileId]); + return new DataResponse($updated->jsonSerialize()); + }); + } + + /** + * Clear the image attached to an item + * + * @param int $houseId House id. + * @param int $listId List id. + * @param int $itemId Item id. + * + * @return DataResponse + * + * 200: Image cleared + */ + #[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/lists/{listId}/items/{itemId}/image')] + #[NoAdminRequired] + public function clearItemImage(int $houseId, int $listId, int $itemId): DataResponse { + return $this->runAction(function () use ($houseId, $listId, $itemId): DataResponse { + $uid = $this->requireUid(); + $this->auth->requireMember($houseId, $uid); + $item = $this->lists->getItem($itemId); + $list = $this->lists->getList($item->getListId()); + $this->assertListInHouse($list->getHouseId(), $houseId); + if ($item->getListId() !== $listId) { + throw new NotFoundException('Item does not belong to this list'); + } + $updated = $this->lists->updateItem($itemId, ['imageFileId' => null]); + return new DataResponse($updated->jsonSerialize()); + }); + } + private function requireUid(): string { $user = $this->userSession->getUser(); if ($user === null) { diff --git a/lib/Db/ShoppingListItem.php b/lib/Db/ShoppingListItem.php index 82e1da0..4185e9f 100644 --- a/lib/Db/ShoppingListItem.php +++ b/lib/Db/ShoppingListItem.php @@ -30,6 +30,8 @@ use OCP\AppFramework\Db\Entity; * @method void setRepeatFromCompletion(bool $repeatFromCompletion) * @method int|null getNextDueAt() * @method void setNextDueAt(?int $nextDueAt) + * @method int|null getImageFileId() + * @method void setImageFileId(?int $imageFileId) * @method int getSortOrder() * @method void setSortOrder(int $sortOrder) * @method int getCreatedAt() @@ -48,6 +50,7 @@ class ShoppingListItem extends Entity implements \JsonSerializable { protected ?string $rrule = null; protected bool $repeatFromCompletion = false; protected ?int $nextDueAt = null; + protected ?int $imageFileId = null; protected int $sortOrder = 0; protected int $createdAt = 0; protected int $updatedAt = 0; @@ -59,6 +62,7 @@ class ShoppingListItem extends Entity implements \JsonSerializable { $this->addType('boughtAt', 'integer'); $this->addType('repeatFromCompletion', 'boolean'); $this->addType('nextDueAt', 'integer'); + $this->addType('imageFileId', 'integer'); $this->addType('sortOrder', 'integer'); $this->addType('createdAt', 'integer'); $this->addType('updatedAt', 'integer'); @@ -83,6 +87,7 @@ class ShoppingListItem extends Entity implements \JsonSerializable { 'rrule' => $this->rrule, 'repeatFromCompletion' => $this->repeatFromCompletion, 'nextDueAt' => $this->nextDueAt, + 'imageFileId' => $this->imageFileId, 'sortOrder' => $this->sortOrder, 'createdAt' => $this->createdAt, 'updatedAt' => $this->updatedAt, diff --git a/lib/Migration/Version1Date20260405000000.php b/lib/Migration/Version1Date20260405000000.php index cfe80ce..5a851d5 100644 --- a/lib/Migration/Version1Date20260405000000.php +++ b/lib/Migration/Version1Date20260405000000.php @@ -211,6 +211,10 @@ class Version1Date20260405000000 extends SimpleMigrationStep { 'notnull' => false, 'length' => 20, ]); + $table->addColumn('image_file_id', Types::BIGINT, [ + 'notnull' => false, + 'length' => 20, + ]); $table->addColumn('sort_order', Types::INTEGER, [ 'notnull' => true, 'default' => 0, @@ -226,6 +230,16 @@ class Version1Date20260405000000 extends SimpleMigrationStep { $table->setPrimaryKey(['id']); $table->addIndex(['list_id'], 'pantry_items_list_idx'); $table->addIndex(['category_id'], 'pantry_items_cat_idx'); + } else { + // Idempotent adds for columns introduced after the initial create + // (covers early-dev deployments where the table already existed). + $table = $schema->getTable($itemsTable); + if (!$table->hasColumn('image_file_id')) { + $table->addColumn('image_file_id', Types::BIGINT, [ + 'notnull' => false, + 'length' => 20, + ]); + } } return $schema; diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 74ccf21..eaec5b8 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -49,6 +49,7 @@ namespace OCA\Pantry; * rrule: string|null, * repeatFromCompletion: bool, * nextDueAt: int|null, + * imageFileId: int|null, * sortOrder: int, * createdAt: int, * updatedAt: int, @@ -68,6 +69,8 @@ namespace OCA\Pantry; * @psalm-type PantrySuccess = array{success: true} * * @psalm-type PantryLastHouse = array{houseId: int|null} + * + * @psalm-type PantryImageFolder = array{folder: string} */ class ResponseDefinitions { } diff --git a/lib/Service/ImageService.php b/lib/Service/ImageService.php new file mode 100644 index 0000000..b68dd66 --- /dev/null +++ b/lib/Service/ImageService.php @@ -0,0 +1,92 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Pantry\Service; + +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; + +class ImageService { + public const SHOPPING_ITEMS_SUBDIR = 'Shopping list items'; + + public function __construct( + private IRootFolder $rootFolder, + private PrefsService $prefs, + ) { + } + + /** + * Upload image bytes to the user's configured pantry image folder, returning + * the Nextcloud file id on success. + */ + public function uploadForUser(string $uid, string $originalName, string $data): int { + if ($data === '') { + throw new \InvalidArgumentException('Empty file'); + } + $folder = $this->resolveShoppingItemsFolder($uid); + $filename = $this->uniqueName($folder, $originalName); + try { + $file = $folder->newFile($filename, $data); + } catch (NotPermittedException $e) { + throw new \RuntimeException('Could not write file: ' . $e->getMessage(), 0, $e); + } + return $file->getId(); + } + + private function resolveShoppingItemsFolder(string $uid): Folder { + $base = $this->resolveBaseFolder($uid); + return $this->getOrCreateSubFolder($base, self::SHOPPING_ITEMS_SUBDIR); + } + + private function resolveBaseFolder(string $uid): Folder { + $userFolder = $this->rootFolder->getUserFolder($uid); + $path = $this->prefs->getImageFolder($uid); + $relative = ltrim($path, '/'); + if ($relative === '') { + return $userFolder; + } + if ($userFolder->nodeExists($relative)) { + $node = $userFolder->get($relative); + if (!$node instanceof Folder) { + throw new \RuntimeException('Configured image path is not a folder: ' . $path); + } + return $node; + } + return $userFolder->newFolder($relative); + } + + private function getOrCreateSubFolder(Folder $parent, string $name): Folder { + if ($parent->nodeExists($name)) { + $node = $parent->get($name); + if (!$node instanceof Folder) { + throw new \RuntimeException('Expected a folder at ' . $name); + } + return $node; + } + return $parent->newFolder($name); + } + + private function uniqueName(Folder $folder, string $original): string { + $base = basename($original); + if ($base === '' || $base === '.' || $base === '..') { + $base = 'image.jpg'; + } + // Strip characters Nextcloud disallows in filenames. + $base = preg_replace('/[\/\\\\]/', '_', $base) ?? 'image.jpg'; + $dot = strrpos($base, '.'); + $name = $dot === false ? $base : substr($base, 0, $dot); + $ext = $dot === false ? '' : substr($base, $dot); + $candidate = $base; + $i = 1; + while ($folder->nodeExists($candidate)) { + $candidate = sprintf('%s (%d)%s', $name, $i, $ext); + $i++; + } + return $candidate; + } +} diff --git a/lib/Service/PrefsService.php b/lib/Service/PrefsService.php index c6cfe75..3f5a80b 100644 --- a/lib/Service/PrefsService.php +++ b/lib/Service/PrefsService.php @@ -12,6 +12,8 @@ use OCP\IConfig; class PrefsService { private const KEY_LAST_HOUSE = 'last_house_id'; + private const KEY_IMAGE_FOLDER = 'image_folder'; + public const DEFAULT_IMAGE_FOLDER = '/Pantry'; public function __construct( private IConfig $config, @@ -33,4 +35,31 @@ class PrefsService { } $this->config->setUserValue($uid, Application::APP_ID, self::KEY_LAST_HOUSE, (string)$houseId); } + + public function getImageFolder(string $uid): string { + $value = $this->config->getUserValue( + $uid, + Application::APP_ID, + self::KEY_IMAGE_FOLDER, + self::DEFAULT_IMAGE_FOLDER, + ); + return $this->normalizeFolder($value); + } + + public function setImageFolder(string $uid, string $folder): string { + $normalized = $this->normalizeFolder($folder); + $this->config->setUserValue($uid, Application::APP_ID, self::KEY_IMAGE_FOLDER, $normalized); + return $normalized; + } + + private function normalizeFolder(string $folder): string { + $trimmed = trim($folder); + if ($trimmed === '') { + return self::DEFAULT_IMAGE_FOLDER; + } + // Ensure leading slash, no trailing slash. + $withLeading = str_starts_with($trimmed, '/') ? $trimmed : '/' . $trimmed; + $clean = rtrim($withLeading, '/'); + return $clean === '' ? '/' : $clean; + } } diff --git a/lib/Service/ShoppingListService.php b/lib/Service/ShoppingListService.php index 5ae6f17..ed98def 100644 --- a/lib/Service/ShoppingListService.php +++ b/lib/Service/ShoppingListService.php @@ -145,6 +145,7 @@ class ShoppingListService { $item->setRrule($rrule); $item->setRepeatFromCompletion(!empty($data['repeatFromCompletion'])); $item->setNextDueAt(null); + $item->setImageFileId($this->intOrNull($data['imageFileId'] ?? null)); $item->setSortOrder(isset($data['sortOrder']) ? (int)$data['sortOrder'] : 0); $item->setCreatedAt($now); $item->setUpdatedAt($now); @@ -186,6 +187,9 @@ class ShoppingListService { if (array_key_exists('repeatFromCompletion', $patch)) { $item->setRepeatFromCompletion((bool)$patch['repeatFromCompletion']); } + if (array_key_exists('imageFileId', $patch)) { + $item->setImageFileId($this->intOrNull($patch['imageFileId'])); + } // If already bought and rrule or mode changed, recompute next due. if ($item->getBought() && $item->getRrule() !== null && (array_key_exists('rrule', $patch) || array_key_exists('repeatFromCompletion', $patch))) { diff --git a/openapi.json b/openapi.json index 82c7edf..9d2206f 100644 --- a/openapi.json +++ b/openapi.json @@ -103,6 +103,17 @@ } } }, + "ImageFolder": { + "type": "object", + "required": [ + "folder" + ], + "properties": { + "folder": { + "type": "string" + } + } + }, "LastHouse": { "type": "object", "required": [ @@ -171,6 +182,7 @@ "rrule", "repeatFromCompletion", "nextDueAt", + "imageFileId", "sortOrder", "createdAt", "updatedAt" @@ -220,6 +232,11 @@ "format": "int64", "nullable": true }, + "imageFileId": { + "type": "integer", + "format": "int64", + "nullable": true + }, "sortOrder": { "type": "integer", "format": "int64" @@ -2140,6 +2157,201 @@ } } }, + "/ocs/v2.php/apps/pantry/api/prefs/image-folder": { + "get": { + "operationId": "prefs-get-image-folder", + "summary": "Get the user's preferred image upload folder", + "tags": [ + "prefs" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Folder returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ImageFolder" + } + } + } + } + } + } + } + }, + "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-image-folder", + "summary": "Set the user's preferred image upload folder", + "tags": [ + "prefs" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "folder" + ], + "properties": { + "folder": { + "type": "string", + "description": "Absolute path within the user's files." + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Folder updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ImageFolder" + } + } + } + } + } + } + } + }, + "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}/lists": { "get": { "operationId": "shopping_list-index-lists", @@ -3087,6 +3299,13 @@ "default": null, "description": "New recurrence anchor mode." }, + "imageFileId": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "File id of attached image (0 or negative clears)." + }, "sortOrder": { "type": "integer", "format": "int64", @@ -3438,6 +3657,243 @@ } } } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/{itemId}/image": { + "post": { + "operationId": "shopping_list-upload-item-image", + "summary": "Upload an image for an item", + "description": "Uploads the request body as an image into the user's configured pantry image folder and attaches it to the item.", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item 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": "Image uploaded and attached", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ListItem" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "shopping_list-clear-item-image", + "summary": "Clear the image attached to an item", + "tags": [ + "shopping_list" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "listId", + "in": "path", + "description": "List id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "itemId", + "in": "path", + "description": "Item 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": "Image cleared", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ListItem" + } + } + } + } + } + } + } + }, + "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": {} + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/package.json b/package.json index e3eaf4f..162beb1 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ ], "dependencies": { "@nextcloud/axios": "^2.5.2", + "@nextcloud/dialogs": "^7.3.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", "@nextcloud/vite-config": "^2.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e20354..24b2ddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@nextcloud/axios': specifier: ^2.5.2 version: 2.5.2 + '@nextcloud/dialogs': + specifier: ^7.3.0 + version: 7.3.0(@vue/compiler-sfc@3.5.32)(typescript@6.0.2) '@nextcloud/l10n': specifier: ^3.4.1 version: 3.4.1 @@ -647,6 +650,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mdi/js@7.4.47': + resolution: {integrity: sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==} + '@microsoft/api-extractor-model@7.33.5': resolution: {integrity: sha512-Xh4dXuusndVQqVz4nEN9xOp0DyzsKxeD2FFJkSPg4arAjDSKPcy6cAc7CaeBPA7kF2wV1fuDlo2p/bNMpVr8yg==} @@ -688,6 +694,10 @@ packages: resolution: {integrity: sha512-snZ0/910zzwN6PDsIlx2Uvktr1S5x0ClhDUnfPlCj7ntNvECzuVHNY5wzby22LIkc+9ZjaDKtCwuCt2ye+9p/Q==} engines: {node: ^20.0.0 || ^22.0.0 || ^24.0.0} + '@nextcloud/dialogs@7.3.0': + resolution: {integrity: sha512-pFuM10Dkvip+wSBaElcbSAN7Jynp41HJUh5kndRYpJipYl0SpNfjIe32+uNfOI43/tln4ScTlrfjIX6cK+3uHg==} + engines: {node: ^20 || ^22 || ^24} + '@nextcloud/eslint-config@8.4.2': resolution: {integrity: sha512-zsDcBxvp2Vr/BgasK/vNYJ84LOXjl4RseJPrcp93zcnaB2WnygV50Sd0nQ5JN0ngTyPjiIlGd92MMzrMTofjRA==} engines: {node: ^20.0.0, npm: ^10.0.0} @@ -1228,6 +1238,9 @@ packages: '@types/sizzle@2.3.10': resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} + '@types/toastify-js@1.12.4': + resolution: {integrity: sha512-zfZHU4tKffPCnZRe7pjv/eFKzTVHozKewFCKaCjZ4gFinKgJRz/t0bkZiMCXJxPhv/ZoeDGNOeRD09R0kQZ/nw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -4267,6 +4280,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toastify-js@1.12.0: + resolution: {integrity: sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==} + tributejs@5.1.3: resolution: {integrity: sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ==} @@ -4862,7 +4878,6 @@ snapshots: '@buttercup/fetch@0.2.1': optionalDependencies: node-fetch: 3.3.2 - optional: true '@ckpack/vue-color@1.6.0(vue@3.5.32(typescript@6.0.2))': dependencies: @@ -5160,6 +5175,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mdi/js@7.4.47': {} + '@microsoft/api-extractor-model@7.33.5(@types/node@25.5.2)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -5226,6 +5243,35 @@ snapshots: dependencies: '@nextcloud/initial-state': 3.0.0 + '@nextcloud/dialogs@7.3.0(@vue/compiler-sfc@3.5.32)(typescript@6.0.2)': + dependencies: + '@mdi/js': 7.4.47 + '@nextcloud/auth': 2.5.3 + '@nextcloud/axios': 2.5.2 + '@nextcloud/browser-storage': 0.5.0 + '@nextcloud/event-bus': 3.3.3 + '@nextcloud/files': 4.0.0 + '@nextcloud/initial-state': 3.0.0 + '@nextcloud/l10n': 3.4.1 + '@nextcloud/paths': 3.1.0 + '@nextcloud/router': 3.1.0 + '@nextcloud/sharing': 0.4.0 + '@nextcloud/vue': 9.6.0(@vue/compiler-sfc@3.5.32)(typescript@6.0.2) + '@types/toastify-js': 1.12.4 + '@vueuse/core': 14.2.1(vue@3.5.32(typescript@6.0.2)) + p-queue: 9.1.1 + toastify-js: 1.12.0 + vue: 3.5.32(typescript@6.0.2) + webdav: 5.9.0 + transitivePeerDependencies: + - '@nuxt/kit' + - '@pinia/colada' + - '@vue/compiler-sfc' + - debug + - pinia + - supports-color + - typescript + '@nextcloud/eslint-config@8.4.2(@babel/core@7.26.0)(@babel/eslint-parser@7.25.9(@babel/core@7.26.0)(eslint@10.2.0))(@nextcloud/eslint-plugin@2.2.1(eslint@10.2.0))(@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@6.0.2))(eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint@10.2.0))(eslint-import-resolver-exports@1.0.0-beta.5(eslint-plugin-import@2.31.0)(eslint@10.2.0))(eslint-import-resolver-typescript@3.6.3)(eslint-plugin-import@2.31.0)(eslint-plugin-jsdoc@46.10.1(eslint@10.2.0))(eslint-plugin-n@16.6.2(eslint@10.2.0))(eslint-plugin-promise@6.6.0(eslint@10.2.0))(eslint-plugin-vue@9.32.0(eslint@10.2.0))(eslint@10.2.0)(typescript@6.0.2)': dependencies: '@babel/core': 7.26.0 @@ -5282,7 +5328,6 @@ snapshots: is-svg: 6.1.0 typescript-event-target: 1.1.2 webdav: 5.9.0 - optional: true '@nextcloud/initial-state@3.0.0': {} @@ -5298,8 +5343,7 @@ snapshots: dependencies: '@nextcloud/auth': 2.5.3 - '@nextcloud/paths@3.1.0': - optional: true + '@nextcloud/paths@3.1.0': {} '@nextcloud/router@3.1.0': dependencies: @@ -5311,7 +5355,6 @@ snapshots: is-svg: 6.1.0 optionalDependencies: '@nextcloud/files': 3.12.2 - optional: true '@nextcloud/sharing@0.4.0': dependencies: @@ -5748,6 +5791,8 @@ snapshots: '@types/sizzle@2.3.10': {} + '@types/toastify-js@1.12.4': {} + '@types/trusted-types@2.0.7': optional: true @@ -6309,8 +6354,7 @@ snapshots: balanced-match@4.0.4: {} - base-64@1.0.0: - optional: true + base-64@1.0.0: {} base64-js@1.5.1: {} @@ -6425,8 +6469,7 @@ snapshots: dependencies: run-applescript: 7.1.0 - byte-length@1.0.2: - optional: true + byte-length@1.0.2: {} call-bind-apply-helpers@1.0.2: dependencies: @@ -6464,8 +6507,7 @@ snapshots: character-reference-invalid@2.0.1: {} - charenc@0.0.2: - optional: true + charenc@0.0.2: {} chokidar@4.0.3: dependencies: @@ -6590,8 +6632,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - crypt@0.0.2: - optional: true + crypt@0.0.2: {} crypto-browserify@3.12.1: dependencies: @@ -6619,8 +6660,7 @@ snapshots: csstype@3.2.3: {} - data-uri-to-buffer@4.0.1: - optional: true + data-uri-to-buffer@4.0.1: {} data-view-buffer@1.0.2: dependencies: @@ -6782,8 +6822,7 @@ snapshots: entities@4.5.0: {} - entities@6.0.1: - optional: true + entities@6.0.1: {} entities@7.0.1: {} @@ -7217,7 +7256,6 @@ snapshots: fast-xml-builder@1.1.4: dependencies: path-expression-matcher: 1.2.1 - optional: true fast-xml-parser@4.5.6: dependencies: @@ -7228,7 +7266,6 @@ snapshots: fast-xml-builder: 1.1.4 path-expression-matcher: 1.2.1 strnum: 2.2.2 - optional: true fastest-levenshtein@1.0.16: {} @@ -7244,7 +7281,6 @@ snapshots: dependencies: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - optional: true file-entry-cache@8.0.0: dependencies: @@ -7307,7 +7343,6 @@ snapshots: formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 - optional: true fs-extra@11.3.4: dependencies: @@ -7518,8 +7553,7 @@ snapshots: hookable@5.5.3: {} - hot-patcher@2.0.1: - optional: true + hot-patcher@2.0.1: {} html-tags@3.3.1: {} @@ -7604,8 +7638,7 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-buffer@1.1.6: - optional: true + is-buffer@1.1.6: {} is-builtin-module@3.2.1: dependencies: @@ -7807,8 +7840,7 @@ snapshots: kolorist@1.8.0: {} - layerr@3.0.0: - optional: true + layerr@3.0.0: {} levn@0.4.1: dependencies: @@ -7951,7 +7983,6 @@ snapshots: charenc: 0.0.2 crypt: 0.0.2 is-buffer: 1.1.6 - optional: true mdast-squeeze-paragraphs@6.0.0: dependencies: @@ -8257,14 +8288,12 @@ snapshots: natural-compare@1.4.0: {} - nested-property@4.0.0: - optional: true + nested-property@4.0.0: {} node-addon-api@7.1.1: optional: true - node-domexception@1.0.0: - optional: true + node-domexception@1.0.0: {} node-exports-info@1.6.0: dependencies: @@ -8278,7 +8307,6 @@ snapshots: data-uri-to-buffer: 4.0.1 fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - optional: true node-releases@2.0.37: {} @@ -8458,8 +8486,7 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.2.1: - optional: true + path-expression-matcher@1.2.1: {} path-key@3.1.1: {} @@ -8467,8 +8494,7 @@ snapshots: path-parse@1.0.7: {} - path-posix@1.0.0: - optional: true + path-posix@1.0.0: {} path-scurry@1.11.1: dependencies: @@ -8592,8 +8618,7 @@ snapshots: querystring-es3@0.2.1: {} - querystringify@2.2.0: - optional: true + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -8710,8 +8735,7 @@ snapshots: requireindex@1.2.0: {} - requires-port@1.0.0: - optional: true + requires-port@1.0.0: {} resolve-from@4.0.0: {} @@ -9221,8 +9245,7 @@ snapshots: strnum@1.1.2: {} - strnum@2.2.2: - optional: true + strnum@2.2.2: {} strtok3@10.3.5: dependencies: @@ -9385,6 +9408,8 @@ snapshots: dependencies: is-number: 7.0.0 + toastify-js@1.12.0: {} + tributejs@5.1.3: {} trim-lines@3.0.1: {} @@ -9462,8 +9487,7 @@ snapshots: transitivePeerDependencies: - supports-color - typescript-event-target@1.1.2: - optional: true + typescript-event-target@1.1.2: {} typescript@5.9.3: {} @@ -9547,14 +9571,12 @@ snapshots: dependencies: punycode: 2.3.1 - url-join@5.0.0: - optional: true + url-join@5.0.0: {} url-parse@1.5.10: dependencies: querystringify: 2.2.0 requires-port: 1.0.0 - optional: true url@0.11.4: dependencies: @@ -9747,8 +9769,7 @@ snapshots: optionalDependencies: typescript: 6.0.2 - web-streams-polyfill@3.3.3: - optional: true + web-streams-polyfill@3.3.3: {} webdav@5.9.0: dependencies: @@ -9766,7 +9787,6 @@ snapshots: path-posix: 1.0.0 url-join: 5.0.0 url-parse: 1.5.10 - optional: true webpack-virtual-modules@0.6.2: {} diff --git a/src/api/lists.ts b/src/api/lists.ts index 7cf47cb..4e88e93 100644 --- a/src/api/lists.ts +++ b/src/api/lists.ts @@ -86,3 +86,29 @@ export async function toggleItem( export async function deleteItem(houseId: number, listId: number, itemId: number): Promise { await ocs.delete(`/houses/${houseId}/lists/${listId}/items/${itemId}`) } + +export async function uploadItemImage( + houseId: number, + listId: number, + itemId: number, + file: File, +): Promise { + const form = new FormData() + form.append('image', file, file.name) + const resp = await ocs.post( + `/houses/${houseId}/lists/${listId}/items/${itemId}/image`, + form, + ) + return resp.data +} + +export async function clearItemImage( + houseId: number, + listId: number, + itemId: number, +): Promise { + const resp = await ocs.delete( + `/houses/${houseId}/lists/${listId}/items/${itemId}/image`, + ) + return resp.data +} diff --git a/src/api/prefs.ts b/src/api/prefs.ts index 7d06786..d25cf2d 100644 --- a/src/api/prefs.ts +++ b/src/api/prefs.ts @@ -8,3 +8,13 @@ export async function getLastHouse(): Promise { export async function setLastHouse(houseId: number | null): Promise { await ocs.put('/prefs/last-house', { houseId }) } + +export async function getImageFolder(): Promise { + const resp = await ocs.get<{ folder: string }>('/prefs/image-folder') + return resp.data?.folder ?? '/Pantry' +} + +export async function setImageFolder(folder: string): Promise { + const resp = await ocs.put<{ folder: string }>('/prefs/image-folder', { folder }) + return resp.data?.folder ?? folder +} diff --git a/src/api/types.ts b/src/api/types.ts index 1709db6..5fc6040 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -52,6 +52,7 @@ export interface ShoppingListItem { rrule: string | null repeatFromCompletion: boolean nextDueAt: number | null + imageFileId: number | null sortOrder: number createdAt: number updatedAt: number diff --git a/src/components/PantrySettingsDialog.vue b/src/components/PantrySettingsDialog.vue new file mode 100644 index 0000000..926e81e --- /dev/null +++ b/src/components/PantrySettingsDialog.vue @@ -0,0 +1,151 @@ + + emit('update:open', v)" + > + + {{ strings.imagesHint }} + + + + + + + + {{ strings.browse }} + + + + + {{ saving ? strings.saving : strings.save }} + + {{ strings.saved }} + + + + + + + + + diff --git a/src/composables/useShoppingList.ts b/src/composables/useShoppingList.ts index c016763..9adb77c 100644 --- a/src/composables/useShoppingList.ts +++ b/src/composables/useShoppingList.ts @@ -92,5 +92,15 @@ export function useShoppingListItems(houseId: number, listId: number) { items.value = items.value.filter((i) => i.id !== itemId) } - return { items, loading, error, load, add, update, toggle, remove } + async function uploadImage(itemId: number, file: File): Promise { + const updated = await api.uploadItemImage(houseId, listId, itemId, file) + items.value = items.value.map((i) => (i.id === itemId ? updated : i)) + } + + async function clearImage(itemId: number): Promise { + const updated = await api.clearItemImage(houseId, listId, itemId) + items.value = items.value.map((i) => (i.id === itemId ? updated : i)) + } + + return { items, loading, error, load, add, update, toggle, remove, uploadImage, clearImage } } diff --git a/src/views/ShoppingListDetail.vue b/src/views/ShoppingListDetail.vue index 4c49366..4158752 100644 --- a/src/views/ShoppingListDetail.vue +++ b/src/views/ShoppingListDetail.vue @@ -69,7 +69,18 @@ :model-value="item.bought" @update:model-value="handleToggle(item.id)" > - {{ item.name }} + + + + + {{ item.name }} + {{ item.quantity }} @@ -87,6 +98,11 @@ + + + + + + + !v && (editing = null)" + > + + + + + + + + + {{ editRrule ? strings.recurrenceSet : strings.recurrenceButton }} + + + + {{ strings.imageLabel }} + + + + + + + {{ editing?.imageFileId ? strings.replaceImage : strings.uploadImage }} + + + + + + {{ strings.removeImage }} + + + + + + + {{ strings.cancel }} + + {{ strings.save }} + + + + + + + !v && (previewing = null)" + > + + + + @@ -116,18 +238,22 @@ import NcTextField from '@nextcloud/vue/components/NcTextField' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import { generateUrl } from '@nextcloud/router' import PlusIcon from '@icons/Plus.vue' import ArrowLeftIcon from '@icons/ArrowLeft.vue' import DeleteIcon from '@icons/Delete.vue' +import PencilIcon from '@icons/Pencil.vue' import RepeatIcon from '@icons/Repeat.vue' import CartIcon from '@icons/Cart.vue' +import UploadIcon from '@icons/Upload.vue' import RecurrenceEditor from '@/components/RecurrenceEditor.vue' import CategoryPicker from '@/components/CategoryPicker.vue' import { categoryIconComponent } from '@/components/categoryIcons' import { useShoppingListItems } from '@/composables/useShoppingList' import { useCategories } from '@/composables/useCategories' import { getList } from '@/api/lists' -import type { ShoppingList } from '@/api/types' +import type { ShoppingList, ShoppingListItem } from '@/api/types' import { RRule } from 'rrule' const props = defineProps<{ houseId: string; listId: string }>() @@ -136,10 +262,8 @@ const houseIdNum = computed(() => Number(props.houseId)) const listIdNum = computed(() => Number(props.listId)) const list = ref(null) -const { items, loading, load, add, toggle, remove } = useShoppingListItems( - houseIdNum.value, - listIdNum.value, -) +const { items, loading, load, add, update, toggle, remove, uploadImage, clearImage } = + useShoppingListItems(houseIdNum.value, listIdNum.value) const categories = useCategories(houseIdNum.value) function categoryFor(id: number | null) { @@ -207,6 +331,94 @@ async function handleRemove(itemId: number) { await remove(itemId) } +const editing = ref(null) +const editName = ref('') +const editQuantity = ref('') +const editCategoryId = ref(null) +const editRrule = ref(null) +const editRepeatFromCompletion = ref(false) +const showEditRecurrenceEditor = ref(false) +const savingEdit = ref(false) + +function startEdit(item: ShoppingListItem) { + editing.value = item + editName.value = item.name + editQuantity.value = item.quantity ?? '' + editCategoryId.value = item.categoryId ?? null + editRrule.value = item.rrule ?? null + editRepeatFromCompletion.value = item.repeatFromCompletion ?? false +} + +async function submitEdit() { + const target = editing.value + if (!target) return + const name = editName.value.trim() + if (!name) return + savingEdit.value = true + try { + await update(target.id, { + name, + quantity: editQuantity.value.trim() || null, + categoryId: editCategoryId.value, + rrule: editRrule.value, + repeatFromCompletion: editRepeatFromCompletion.value, + }) + editing.value = null + } finally { + savingEdit.value = false + } +} + +const previewing = ref(null) +function openPreview(item: ShoppingListItem) { + previewing.value = item +} + +function thumbUrl(fileId: number, size = 64): string { + const base = generateUrl('/core/preview') + return `${base}?fileId=${fileId}&x=${size}&y=${size}&a=1` +} + +function largeUrl(fileId: number): string { + const base = generateUrl('/core/preview') + return `${base}?fileId=${fileId}&x=1600&y=1600&a=1` +} + +const imageInputRef = ref(null) +const uploadingImage = ref(false) + +function triggerImagePick() { + imageInputRef.value?.click() +} + +async function onImagePicked(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (!file || !editing.value) return + uploadingImage.value = true + try { + await uploadImage(editing.value.id, file) + // Refresh the local editing ref with the updated item so the preview appears. + const refreshed = items.value.find((i) => i.id === editing.value?.id) + if (refreshed) editing.value = refreshed + } finally { + uploadingImage.value = false + input.value = '' + } +} + +async function removeImage() { + if (!editing.value) return + uploadingImage.value = true + try { + await clearImage(editing.value.id) + const refreshed = items.value.find((i) => i.id === editing.value?.id) + if (refreshed) editing.value = refreshed + } finally { + uploadingImage.value = false + } +} + function formatRrule(rrule: string): string { try { const rule = RRule.fromString('RRULE:' + rrule.replace(/^RRULE:/i, '')) @@ -219,6 +431,8 @@ function formatRrule(rrule: string): string { const strings = { back: t('pantry', 'Back to lists'), add: t('pantry', 'Add'), + save: t('pantry', 'Save'), + cancel: t('pantry', 'Cancel'), newItemLabel: t('pantry', 'Item name'), newItemPlaceholder: t('pantry', 'e.g. Milk'), quantityLabel: t('pantry', 'Quantity'), @@ -226,6 +440,13 @@ const strings = { categoryLabel: t('pantry', 'Category'), recurrenceButton: t('pantry', 'Repeat …'), recurrenceSet: t('pantry', 'Repeat: set'), + editItem: t('pantry', 'Edit item'), + editDialogTitle: t('pantry', 'Edit item'), + imageLabel: t('pantry', 'Image'), + uploadImage: t('pantry', 'Upload image'), + replaceImage: t('pantry', 'Replace image'), + removeImage: t('pantry', 'Remove image'), + viewImage: t('pantry', 'View image'), removeItem: t('pantry', 'Remove item'), emptyTitle: t('pantry', 'No items yet'), emptyBody: t('pantry', 'Add items using the form above.'), @@ -276,6 +497,57 @@ const strings = { gap: 0.25rem; } +.pantry-form { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0.5rem 0; + + &__image { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + &__label { + font-size: 0.85rem; + color: var(--color-text-maxcontrast); + } + + &__image-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + } + + &__image-preview { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: var(--border-radius, 6px); + border: 1px solid var(--color-border); + } + + &__image-input { + display: none; + } +} + +.pantry-preview { + display: flex; + justify-content: center; + align-items: center; + padding: 0.5rem; + + img { + max-width: 100%; + max-height: 80vh; + object-fit: contain; + border-radius: var(--border-radius, 8px); + } +} + .pantry-item { display: grid; grid-template-columns: 1fr auto auto; @@ -294,6 +566,36 @@ const strings = { } } + &__label { + display: inline-flex; + align-items: center; + gap: 0.6rem; + } + + &__thumb { + width: 40px; + height: 40px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--border-radius, 6px); + background: var(--color-background-hover); + cursor: zoom-in; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + &:hover, + &:focus-visible { + border-color: var(--color-primary-element); + } + } + &__name { font-weight: 500; } @@ -306,6 +608,12 @@ const strings = { font-size: 0.85rem; } + &__actions { + display: flex; + align-items: center; + gap: 0.25rem; + } + &__quantity, &__category, &__recurrence { diff --git a/src/views/SideNavigation.vue b/src/views/SideNavigation.vue index 245707a..685abc7 100644 --- a/src/views/SideNavigation.vue +++ b/src/views/SideNavigation.vue @@ -58,12 +58,19 @@ - + {{ strings.welcomeHint }} + + + + + + + + +
{{ strings.imagesHint }}