mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: edit lists/items, upload item images + prefs
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
46
composer.lock
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
92
lib/Service/ImageService.php
Normal file
92
lib/Service/ImageService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
456
openapi.json
456
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": []
|
||||
|
||||
@@ -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
122
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface ShoppingListItem {
|
||||
rrule: string | null
|
||||
repeatFromCompletion: boolean
|
||||
nextDueAt: number | null
|
||||
imageFileId: number | null
|
||||
sortOrder: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
|
||||
151
src/components/PantrySettingsDialog.vue
Normal file
151
src/components/PantrySettingsDialog.vue
Normal 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>
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
2
vendor-bin/cs-fixer/composer.lock
generated
2
vendor-bin/cs-fixer/composer.lock
generated
@@ -167,5 +167,5 @@
|
||||
"platform-overrides": {
|
||||
"php": "8.1"
|
||||
},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user