feat: edit lists/items, upload item images + prefs

This commit is contained in:
2026-04-06 09:18:52 +03:00
parent fdf4b006c0
commit 4f760b5b59
22 changed files with 1355 additions and 87 deletions

View File

@@ -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.
]]></description>
<version>1.0.0</version>
<version>0.0.1</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://github.com/chenasraf/nextcloud-pantry">Chen Asraf</author>
<namespace>Pantry</namespace>

View File

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

46
composer.lock generated
View File

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

View File

@@ -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<Http::STATUS_OK, PantryImageFolder, array{}>
*
* 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<Http::STATUS_OK, PantryImageFolder, array{}>
*
* 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) {

View File

@@ -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<Http::STATUS_OK, PantryListItem, array{}>
@@ -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<Http::STATUS_OK, PantryListItem, array{}>
*
* 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<Http::STATUS_OK, PantryListItem, array{}>
*
* 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) {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// 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;
}
}

View File

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

View File

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

View File

@@ -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": []

View File

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

122
pnpm-lock.yaml generated
View File

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

View File

@@ -86,3 +86,29 @@ export async function toggleItem(
export async function deleteItem(houseId: number, listId: number, itemId: number): Promise<void> {
await ocs.delete(`/houses/${houseId}/lists/${listId}/items/${itemId}`)
}
export async function uploadItemImage(
houseId: number,
listId: number,
itemId: number,
file: File,
): Promise<ShoppingListItem> {
const form = new FormData()
form.append('image', file, file.name)
const resp = await ocs.post<ShoppingListItem>(
`/houses/${houseId}/lists/${listId}/items/${itemId}/image`,
form,
)
return resp.data
}
export async function clearItemImage(
houseId: number,
listId: number,
itemId: number,
): Promise<ShoppingListItem> {
const resp = await ocs.delete<ShoppingListItem>(
`/houses/${houseId}/lists/${listId}/items/${itemId}/image`,
)
return resp.data
}

View File

@@ -8,3 +8,13 @@ export async function getLastHouse(): Promise<number | null> {
export async function setLastHouse(houseId: number | null): Promise<void> {
await ocs.put('/prefs/last-house', { houseId })
}
export async function getImageFolder(): Promise<string> {
const resp = await ocs.get<{ folder: string }>('/prefs/image-folder')
return resp.data?.folder ?? '/Pantry'
}
export async function setImageFolder(folder: string): Promise<string> {
const resp = await ocs.put<{ folder: string }>('/prefs/image-folder', { folder })
return resp.data?.folder ?? folder
}

View File

@@ -52,6 +52,7 @@ export interface ShoppingListItem {
rrule: string | null
repeatFromCompletion: boolean
nextDueAt: number | null
imageFileId: number | null
sortOrder: number
createdAt: number
updatedAt: number

View File

@@ -0,0 +1,151 @@
<template>
<NcAppSettingsDialog
:open="open"
:name="strings.title"
:show-navigation="true"
@update:open="(v) => emit('update:open', v)"
>
<NcAppSettingsSection id="pantry-images" :name="strings.imagesSection">
<p class="pantry-settings__hint">{{ strings.imagesHint }}</p>
<form class="pantry-settings__form" @submit.prevent="save">
<div class="pantry-settings__folder-row">
<NcTextField v-model="folder" :label="strings.folderLabel" placeholder="/Pantry" />
<NcButton type="button" variant="secondary" @click="browseFolder">
<template #icon>
<FolderIcon :size="20" />
</template>
{{ strings.browse }}
</NcButton>
</div>
<div class="pantry-settings__actions">
<NcButton type="submit" variant="primary" :disabled="saving || !folder.trim()">
{{ saving ? strings.saving : strings.save }}
</NcButton>
<span v-if="saved" class="pantry-settings__saved">{{ strings.saved }}</span>
</div>
</form>
</NcAppSettingsSection>
</NcAppSettingsDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog'
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcButton from '@nextcloud/vue/components/NcButton'
import { getFilePickerBuilder } from '@nextcloud/dialogs'
import FolderIcon from '@icons/Folder.vue'
import { getImageFolder, setImageFolder } from '@/api/prefs'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
const folder = ref('/Pantry')
const saving = ref(false)
const saved = ref(false)
async function loadFolder() {
try {
folder.value = await getImageFolder()
} catch {
// Keep default.
}
}
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
saved.value = false
void loadFolder()
}
},
{ immediate: true },
)
async function browseFolder() {
const picker = getFilePickerBuilder(strings.pickerTitle)
.setMultiSelect(false)
.setMimeTypeFilter([])
.allowDirectories(true)
.setType(1) // Choose
.startAt(folder.value || '/')
.build()
try {
const picked = await picker.pick()
const path = Array.isArray(picked) ? picked[0] : picked
if (typeof path === 'string' && path.length > 0) {
folder.value = path
saved.value = false
}
} catch {
// User cancelled — no-op.
}
}
async function save() {
const value = folder.value.trim()
if (!value) return
saving.value = true
saved.value = false
try {
folder.value = await setImageFolder(value)
saved.value = true
} finally {
saving.value = false
}
}
const strings = {
title: t('pantry', 'Pantry settings'),
imagesSection: t('pantry', 'Images'),
imagesHint: t(
'pantry',
'Pick the base folder where Pantry will store uploaded images. Shopping list item images go into a "Shopping list items" subfolder inside it, created automatically.',
),
folderLabel: t('pantry', 'Upload folder'),
browse: t('pantry', 'Browse …'),
pickerTitle: t('pantry', 'Pick an upload folder'),
save: t('pantry', 'Save'),
saving: t('pantry', 'Saving …'),
saved: t('pantry', 'Saved.'),
}
</script>
<style scoped lang="scss">
.pantry-settings__hint {
color: var(--color-text-maxcontrast);
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
}
.pantry-settings__form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pantry-settings__folder-row {
display: flex;
align-items: end;
gap: 0.5rem;
:deep(.input-field) {
flex: 1;
min-width: 0;
}
}
.pantry-settings__actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.pantry-settings__saved {
color: var(--color-success);
font-size: 0.85rem;
}
</style>

View File

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

View File

@@ -69,7 +69,18 @@
:model-value="item.bought"
@update:model-value="handleToggle(item.id)"
>
<span class="pantry-item__name">{{ item.name }}</span>
<span class="pantry-item__label">
<button
v-if="item.imageFileId"
type="button"
class="pantry-item__thumb"
:aria-label="strings.viewImage"
@click.stop.prevent="openPreview(item)"
>
<img :src="thumbUrl(item.imageFileId)" :alt="item.name" />
</button>
<span class="pantry-item__name">{{ item.name }}</span>
</span>
</NcCheckboxRadioSwitch>
<div class="pantry-item__meta">
<span v-if="item.quantity" class="pantry-item__quantity">{{ item.quantity }}</span>
@@ -87,6 +98,11 @@
</span>
</div>
<div class="pantry-item__actions">
<NcButton variant="tertiary" :aria-label="strings.editItem" @click="startEdit(item)">
<template #icon>
<PencilIcon :size="18" />
</template>
</NcButton>
<NcButton
variant="tertiary"
:aria-label="strings.removeItem"
@@ -105,6 +121,112 @@
v-model="newRrule"
v-model:from-completion="newRepeatFromCompletion"
/>
<NcDialog
v-if="editing"
:name="strings.editDialogTitle"
:open="!!editing"
@update:open="(v) => !v && (editing = null)"
>
<form id="pantry-edit-item-form" class="pantry-form" @submit.prevent="submitEdit">
<NcTextField
v-model="editName"
:label="strings.newItemLabel"
:placeholder="strings.newItemPlaceholder"
/>
<NcTextField
v-model="editQuantity"
:label="strings.quantityLabel"
:placeholder="strings.quantityPlaceholder"
/>
<CategoryPicker
v-model="editCategoryId"
:house-id="houseIdNum"
:label="strings.categoryLabel"
/>
<NcButton variant="tertiary" type="button" @click="showEditRecurrenceEditor = true">
<template #icon>
<RepeatIcon :size="20" />
</template>
{{ editRrule ? strings.recurrenceSet : strings.recurrenceButton }}
</NcButton>
<div class="pantry-form__image">
<span class="pantry-form__label">{{ strings.imageLabel }}</span>
<div class="pantry-form__image-row">
<img
v-if="editing?.imageFileId"
class="pantry-form__image-preview"
:src="thumbUrl(editing.imageFileId, 96)"
:alt="editing.name"
/>
<NcButton
variant="tertiary"
type="button"
:disabled="uploadingImage"
@click="triggerImagePick"
>
<template #icon>
<UploadIcon :size="20" />
</template>
{{ editing?.imageFileId ? strings.replaceImage : strings.uploadImage }}
</NcButton>
<NcButton
v-if="editing?.imageFileId"
variant="tertiary"
type="button"
:disabled="uploadingImage"
@click="removeImage"
>
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.removeImage }}
</NcButton>
<input
ref="imageInputRef"
type="file"
accept="image/*"
class="pantry-form__image-input"
@change="onImagePicked"
/>
</div>
</div>
</form>
<template #actions>
<NcButton @click="editing = null">{{ strings.cancel }}</NcButton>
<NcButton
form="pantry-edit-item-form"
type="submit"
variant="primary"
:disabled="!editName.trim() || savingEdit"
>
{{ strings.save }}
</NcButton>
</template>
</NcDialog>
<RecurrenceEditor
v-model:open="showEditRecurrenceEditor"
v-model="editRrule"
v-model:from-completion="editRepeatFromCompletion"
/>
<NcDialog
v-if="previewing"
:name="previewing.name"
:open="!!previewing"
size="large"
@update:open="(v) => !v && (previewing = null)"
>
<div class="pantry-preview">
<img
v-if="previewing.imageFileId"
:src="largeUrl(previewing.imageFileId)"
:alt="previewing.name"
/>
</div>
</NcDialog>
</div>
</template>
@@ -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<ShoppingList | null>(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<ShoppingListItem | null>(null)
const editName = ref('')
const editQuantity = ref('')
const editCategoryId = ref<number | null>(null)
const editRrule = ref<string | null>(null)
const editRepeatFromCompletion = ref<boolean>(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<ShoppingListItem | null>(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<HTMLInputElement | null>(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 {

View File

@@ -58,12 +58,19 @@
</NcAppNavigationItem>
</template>
<li v-else class="pantry-nav__welcome">
<li v-if="currentHouseId === null" class="pantry-nav__welcome">
{{ strings.welcomeHint }}
</li>
</template>
<template #footer>
<ul class="pantry-nav__footer-list">
<NcAppNavigationItem :name="strings.appSettings" @click="showSettings = true">
<template #icon>
<CogOutlineIcon :size="20" />
</template>
</NcAppNavigationItem>
</ul>
<div class="pantry-switcher">
<button
ref="triggerRef"
@@ -110,6 +117,8 @@
</template>
</NcAppNavigation>
<PantrySettingsDialog v-model:open="showSettings" />
<NcDialog
v-if="showCreate"
:name="strings.createDialogTitle"
@@ -158,11 +167,13 @@ import ImageIcon from '@icons/Image.vue'
import NoteIcon from '@icons/Note.vue'
import AccountGroupIcon from '@icons/AccountGroup.vue'
import CogIcon from '@icons/Cog.vue'
import CogOutlineIcon from '@icons/CogOutline.vue'
import ChevronUpIcon from '@icons/ChevronUp.vue'
import ChevronDownIcon from '@icons/ChevronDown.vue'
import CheckIcon from '@icons/Check.vue'
import PlusIcon from '@icons/Plus.vue'
import { useHouses } from '@/composables/useHouses'
import PantrySettingsDialog from '@/components/PantrySettingsDialog.vue'
const route = useRoute()
const router = useRouter()
@@ -226,6 +237,9 @@ async function pickHouse(id: number) {
await router.push({ name: 'lists', params: { houseId: String(id) } })
}
// -------- App settings dialog --------
const showSettings = ref(false)
// -------- Create house dialog --------
const showCreate = ref(false)
const newName = ref('')
@@ -265,6 +279,7 @@ const strings = {
notes: t('pantry', 'Notes wall'),
members: t('pantry', 'Members'),
houseSettings: t('pantry', 'House settings'),
appSettings: t('pantry', 'Pantry settings'),
pickHouse: t('pantry', 'Pick a house'),
createHouse: t('pantry', 'New house …'),
welcomeHint: t('pantry', 'Pick or create a house to get started.'),
@@ -299,6 +314,12 @@ const strings = {
line-height: 1.4;
}
.pantry-nav__footer-list {
list-style: none;
padding: 8px 8px 0;
margin: 0;
}
.pantry-switcher {
position: relative;
padding: 8px;

View File

@@ -167,5 +167,5 @@
"platform-overrides": {
"php": "8.1"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}