Finished first version of "pictures for products" (references #58)

This commit is contained in:
Bernd Bestel 2018-10-01 20:20:50 +02:00
parent fcdeb33426
commit f1fc0ee549
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
12 changed files with 252 additions and 56 deletions

View File

@ -14,7 +14,7 @@ class FilesApiController extends BaseApiController
protected $FilesService; protected $FilesService;
public function Upload(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) public function UploadFile(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{ {
try try
{ {
@ -69,4 +69,31 @@ class FilesApiController extends BaseApiController
return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage()); return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage());
} }
} }
public function DeleteFile(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
try
{
if (isset($request->getQueryParams()['file_name']) && !empty($request->getQueryParams()['file_name']) && IsValidFileName($request->getQueryParams()['file_name']))
{
$fileName = $request->getQueryParams()['file_name'];
}
else
{
throw new \Exception('file_name query parameter missing or contains an invalid filename');
}
$filePath = $this->FilesService->GetFilePath($args['group'], $fileName);
if (file_exists($filePath))
{
unlink($filePath);
}
return $this->ApiResponse(array('success' => true));
}
catch (\Exception $ex)
{
return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage());
}
}
} }

View File

@ -431,9 +431,58 @@
} }
} }
}, },
"/files/upload/{group}": { "/file/{group}": {
"post": { "get": {
"description": "Uploads a single file to /data/storage/{group}/{file_name}", "description": "Serves the given file (with proper Content-Type header)",
"tags": [
"Files"
],
"parameters": [
{
"in": "path",
"name": "group",
"required": true,
"description": "The file group",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "file_name",
"required": true,
"description": "The file name (including extension)",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The binary file contents (Content-Type header is automatically set based on the file type)",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"description": "A VoidApiActionResponse object",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse"
}
}
}
}
}
},
"put": {
"description": "Uploads a single file to /data/storage/{group}/{file_name} (you need to remember the group and file name to get or delete it again)",
"tags": [ "tags": [
"Files" "Files"
], ],
@ -489,11 +538,9 @@
} }
} }
} }
} },
}, "delete": {
"/files/get/{group}": { "description": "Deletes the given file",
"get": {
"description": "Serves the given file",
"tags": [ "tags": [
"Files" "Files"
], ],
@ -519,12 +566,11 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "The binary file contents (mime type is set based on file extension)", "description": "A VoidApiActionResponse object",
"content": { "content": {
"application/octet-stream": { "application/json": {
"schema": { "schema": {
"type": "string", "$ref": "#/components/schemas/VoidApiActionResponse"
"format": "binary"
} }
} }
} }
@ -1683,6 +1729,9 @@
"minimum": 0, "minimum": 0,
"default": 0 "default": 0
}, },
"picture_file_name": {
"type": "string"
},
"row_created_timestamp": { "row_created_timestamp": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"

View File

@ -192,7 +192,7 @@ function Pluralize($number, $singularForm, $pluralForm)
function IsValidFileName($fileName) function IsValidFileName($fileName)
{ {
if(preg_match('#^[a-z0-9]+\.[a-z]+?$#i', $fileName)) if(preg_match('=^[^/?*;:{}\\\\]+\.[^/?*;:{}\\\\]+$=', $fileName))
{ {
return true; return true;
} }

View File

@ -54,3 +54,13 @@ BoolVal = function(test)
return false; return false;
} }
} }
GetFileNameFromPath = function(path)
{
return path.split("/").pop().split("\\").pop();
}
GetFileExtension = function(pathOrFileName)
{
return pathOrFileName.split(".").pop();
}

View File

@ -177,15 +177,10 @@ Grocy.Api.Post = function(apiFunction, jsonData, success, error)
xhr.send(JSON.stringify(jsonData)); xhr.send(JSON.stringify(jsonData));
}; };
Grocy.Api.UploadFile = function(fileInput, group, success, error) Grocy.Api.UploadFile = function(file, group, fileName, success, error)
{ {
if (fileInput[0].files.length === 0)
{
return;
}
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
var url = U('/api/files/upload/' + group + '?file_name=' + encodeURIComponent(fileInput[0].files[0].name)); var url = U('/api/file/' + group + '?file_name=' + encodeURIComponent(fileName));
xhr.onreadystatechange = function() xhr.onreadystatechange = function()
{ {
@ -208,9 +203,40 @@ Grocy.Api.UploadFile = function(fileInput, group, success, error)
} }
}; };
xhr.open('POST', url, true); xhr.open('PUT', url, true);
xhr.setRequestHeader('Content-type', 'application/octet-stream'); xhr.setRequestHeader('Content-type', 'application/octet-stream');
xhr.send(fileInput[0].files[0]); xhr.send(file);
};
Grocy.Api.DeleteFile = function(fileName, group, success, error)
{
var xhr = new XMLHttpRequest();
var url = U('/api/file/' + group + '?file_name=' + encodeURIComponent(fileName));
xhr.onreadystatechange = function()
{
if (xhr.readyState === XMLHttpRequest.DONE)
{
if (xhr.status === 200)
{
if (success)
{
success(JSON.parse(xhr.responseText));
}
}
else
{
if (error)
{
error(xhr);
}
}
}
};
xhr.open('DELETE', url, true);
xhr.setRequestHeader('Content-type', 'application/json');
xhr.send();
}; };
Grocy.FrontendHelpers = { }; Grocy.FrontendHelpers = { };
@ -284,3 +310,15 @@ $(".user-setting-control").on("change", function()
} }
); );
}); });
// Show file name Bootstrap custom file input
$('input.custom-file-input').on('change', function()
{
$(this).next('.custom-file-label').html(GetFileNameFromPath($(this).val()));
});
// Translation of "Browse"-button of Bootstrap custom file input
if ($(".custom-file-label").length > 0)
{
$("<style>").html('.custom-file-label::after { content: "' + L("Select file") + '"; }').appendTo("head");
}

View File

@ -24,6 +24,18 @@ Grocy.Components.ProductCard.Refresh = function(productId)
$('#productcard-product-last-price').text(L('Unknown')); $('#productcard-product-last-price').text(L('Unknown'));
} }
if (productDetails.product.picture_file_name !== null && !productDetails.product.picture_file_name.isEmpty())
{
$("#productcard-no-product-picture").addClass("d-none");
$("#productcard-product-picture").removeClass("d-none");
$("#productcard-product-picture").attr("src", U('/api/file/productpictures?file_name=' + productDetails.product.picture_file_name));
}
else
{
$("#productcard-no-product-picture").removeClass("d-none");
$("#productcard-product-picture").addClass("d-none");
}
EmptyElementWhenMatches('#productcard-product-last-purchased-timeago', L('timeago_nan')); EmptyElementWhenMatches('#productcard-product-last-purchased-timeago', L('timeago_nan'));
EmptyElementWhenMatches('#productcard-product-last-used-timeago', L('timeago_nan')); EmptyElementWhenMatches('#productcard-product-last-used-timeago', L('timeago_nan'));
}, },

View File

@ -1,4 +1,4 @@
$('#save-product-button').on('click', function(e) $('#save-product-button').on('click', function (e)
{ {
e.preventDefault(); e.preventDefault();
@ -12,22 +12,28 @@
var jsonData = $('#product-form').serializeJSON(); var jsonData = $('#product-form').serializeJSON();
if ($("#product-picture")[0].files.length > 0) if ($("#product-picture")[0].files.length > 0)
{ {
jsonData.picture_file_name = $("#product-picture")[0].files[0].name; var someRandomStuff = Math.random().toString(36).substring(2, 100) + Math.random().toString(36).substring(2, 100);
jsonData.picture_file_name = someRandomStuff + $("#product-picture")[0].files[0].name;
}
if (Grocy.DeleteProductPictureOnSave)
{
jsonData.picture_file_name = null;
} }
if (Grocy.EditMode === 'create') if (Grocy.EditMode === 'create')
{ {
Grocy.Api.Post('add-object/products', jsonData, Grocy.Api.Post('add-object/products', jsonData,
function(result) function (result)
{ {
if (jsonData.hasOwnProperty("picture_file_name")) if (jsonData.hasOwnProperty("picture_file_name") && !Grocy.DeleteProductPictureOnSave)
{ {
Grocy.Api.UploadFile($("#product-picture"), 'productpictures', Grocy.Api.UploadFile($("#product-picture")[0].files[0], 'productpictures', jsonData.picture_file_name,
function(result) function (result)
{ {
window.location.href = redirectDestination; window.location.href = redirectDestination;
}, },
function(xhr) function (xhr)
{ {
Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
} }
@ -38,7 +44,7 @@
window.location.href = redirectDestination; window.location.href = redirectDestination;
} }
}, },
function(xhr) function (xhr)
{ {
Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response) Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
} }
@ -46,12 +52,26 @@
} }
else else
{ {
if (Grocy.DeleteProductPictureOnSave)
{
Grocy.Api.DeleteFile(Grocy.ProductPictureFileName, 'productpictures',
function(result)
{
// Nothing to do
},
function(xhr)
{
Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
}
);
};
Grocy.Api.Post('edit-object/products/' + Grocy.EditObjectId, jsonData, Grocy.Api.Post('edit-object/products/' + Grocy.EditObjectId, jsonData,
function(result) function(result)
{ {
if (jsonData.hasOwnProperty("picture_file_name")) if (jsonData.hasOwnProperty("picture_file_name") && !Grocy.DeleteProductPictureOnSave)
{ {
Grocy.Api.UploadFile($("#product-picture"), 'productpictures', Grocy.Api.UploadFile($("#product-picture")[0].files[0], 'productpictures', jsonData.picture_file_name,
function(result) function(result)
{ {
window.location.href = redirectDestination; window.location.href = redirectDestination;
@ -150,6 +170,15 @@ $('#product-form input').keydown(function(event)
} }
}); });
Grocy.DeleteProductPictureOnSave = false;
$('#delete-current-product-picture-button').on('click', function (e)
{
Grocy.DeleteProductPictureOnSave = true;
$("#current-product-picture").addClass("d-none");
$("#delete-current-product-picture-on-save-hint").removeClass("d-none");
$("#delete-current-product-picture-button").addClass("disabled");
});
$('#name').focus(); $('#name').focus();
$('.input-group-qu').trigger('change'); $('.input-group-qu').trigger('change');
Grocy.FrontendHelpers.ValidateForm('product-form'); Grocy.FrontendHelpers.ValidateForm('product-form');

View File

@ -141,15 +141,20 @@ $(document).on('click', '.product-consume-button', function(e)
); );
}); });
$(document).on("click", ".show-product-picture-button", function(e) $(document).on("click", ".product-name-cell", function(e)
{ {
var pictureUrl = $(e.currentTarget).attr("data-picture-url"); var productHasPicture = BoolVal($(e.currentTarget).attr("data-product-has-picture"));
var productName = $(e.currentTarget).attr("data-product-name");
bootbox.alert({ if (productHasPicture)
title: L("Image of product #1", productName), {
message: "<img src='" + pictureUrl + "' class='img-fluid'>" var pictureUrl = $(e.currentTarget).attr("data-picture-url");
}); var productName = $(e.currentTarget).attr("data-product-name");
bootbox.alert({
title: L("Image of product #1", productName),
message: "<img src='" + pictureUrl + "' class='img-fluid'>"
});
}
}); });
function RefreshStatistics() function RefreshStatistics()

View File

@ -84,8 +84,9 @@ $app->group('/api', function()
$this->post('/system/log-missing-localization', '\Grocy\Controllers\SystemApiController:LogMissingLocalization'); $this->post('/system/log-missing-localization', '\Grocy\Controllers\SystemApiController:LogMissingLocalization');
// Files // Files
$this->post('/files/upload/{group}', '\Grocy\Controllers\FilesApiController:Upload'); $this->put('/file/{group}', '\Grocy\Controllers\FilesApiController:UploadFile');
$this->get('/files/get/{group}', '\Grocy\Controllers\FilesApiController:ServeFile'); $this->get('/file/{group}', '\Grocy\Controllers\FilesApiController:ServeFile');
$this->delete('/file/{group}', '\Grocy\Controllers\FilesApiController:DeleteFile');
// Users // Users
$this->get('/users/get', '\Grocy\Controllers\UsersApiController:GetUsers'); $this->get('/users/get', '\Grocy\Controllers\UsersApiController:GetUsers');

View File

@ -15,6 +15,10 @@
<strong>{{ $L('Last used') }}:</strong> <span id="productcard-product-last-used"></span> <time id="productcard-product-last-used-timeago" class="timeago timeago-contextual"></time><br> <strong>{{ $L('Last used') }}:</strong> <span id="productcard-product-last-used"></span> <time id="productcard-product-last-used-timeago" class="timeago timeago-contextual"></time><br>
<strong>{{ $L('Last price') }}:</strong> <span id="productcard-product-last-price"></span> <strong>{{ $L('Last price') }}:</strong> <span id="productcard-product-last-price"></span>
<h5 class="mt-3">{{ $L('Product picture') }}</h5>
<img id="productcard-product-picture" src="" class="img-fluid img-thumbnail d-none">
<span id="productcard-no-product-picture" class="font-italic d-none">{{ $L('No picture') }}</span>
<h5 class="mt-3">{{ $L('Price history') }}</h5> <h5 class="mt-3">{{ $L('Price history') }}</h5>
<canvas id="productcard-product-price-history-chart" class="w-100 d-none"></canvas> <canvas id="productcard-product-price-history-chart" class="w-100 d-none"></canvas>
<span id="productcard-no-price-data-hint" class="font-italic d-none">{{ $L('No price history available') }}</span> <span id="productcard-no-price-data-hint" class="font-italic d-none">{{ $L('No price history available') }}</span>

View File

@ -10,6 +10,7 @@
@section('content') @section('content')
<div class="row"> <div class="row">
<div class="col-lg-6 col-xs-12"> <div class="col-lg-6 col-xs-12">
<h1>@yield('title')</h1> <h1>@yield('title')</h1>
@ -17,6 +18,10 @@
@if($mode == 'edit') @if($mode == 'edit')
<script>Grocy.EditObjectId = {{ $product->id }};</script> <script>Grocy.EditObjectId = {{ $product->id }};</script>
@if(!empty($product->picture_file_name))
<script>Grocy.ProductPictureFileName = '{{ $product->picture_file_name }}';</script>
@endif
@endif @endif
<form id="product-form" novalidate> <form id="product-form" novalidate>
@ -109,17 +114,27 @@
)) ))
<div class="form-group"> <div class="form-group">
<label for="product-picture">{{ $L('Product picture') }}</label> <label for="product-pictur">{{ $L('Product picture') }}</label>
<input type="file" class="form-control-file" id="product-picture" accept="image/*"> <div class="custom-file">
<input type="file" class="custom-file-input" id="product-picture">
@if(!empty($product->picture_file_name)) <label class="custom-file-label" for="product-picture">{{ $L('No file selected') }}</label>
<label class="mt-2">{{ $L('Current picture') }}</label> </div>
<img src="{{ $U('/api/files/get/productpictures?file_name=' . $product->picture_file_name) }}" class="img-fluid"> <p class="form-text text-muted small">{{ $L('If you don\'t select a file, the current picture will not be altered') }}</p>
@endif
</div> </div>
<button id="save-product-button" class="btn btn-success">{{ $L('Save') }}</button> <button id="save-product-button" class="btn btn-success">{{ $L('Save') }}</button>
</form> </form>
</div> </div>
<div class="col-lg-6 col-xs-12">
<label class="mt-2">{{ $L('Current picture') }}</label>
<button id="delete-current-product-picture-button" class="btn btn-sm btn-danger @if(empty($product->picture_file_name)) disabled @endif"><i class="fas fa-trash"></i> {{ $L('Delete') }}</button>
@if(!empty($product->picture_file_name))
<img id="current-product-picture" src="{{ $U('/api/file/productpictures?file_name=' . $product->picture_file_name) }}" class="img-fluid img-thumbnail mt-2">
<p id="delete-current-product-picture-on-save-hint" class="form-text text-muted font-italic d-none">{{ $L('The current picture will be deleted when you save the product') }}</p>
@else
<p id="no-current-product-picture-hint" class="form-text text-muted font-italic">{{ $L('No picture') }}</p>
@endif
</div>
</div> </div>
@stop @stop

View File

@ -8,6 +8,14 @@
<script src="{{ $U('/node_modules/jquery-ui-dist/jquery-ui.min.js?v=', true) }}{{ $version }}"></script> <script src="{{ $U('/node_modules/jquery-ui-dist/jquery-ui.min.js?v=', true) }}{{ $version }}"></script>
@endpush @endpush
@push('pageStyles')
<style>
.product-name-cell[data-product-has-picture='true'] {
cursor: pointer;
}
</style>
@endpush
@section('content') @section('content')
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@ -74,14 +82,12 @@
data-consume-amount="{{ $currentStockEntry->amount }}"> data-consume-amount="{{ $currentStockEntry->amount }}">
<i class="fas fa-utensils"></i> {{ $L('All') }} <i class="fas fa-utensils"></i> {{ $L('All') }}
</a> </a>
<a id="product-{{ $currentStockEntry->product_id }}-show-product-picture-button" class="btn btn-info btn-sm show-product-picture-button @if(empty(FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->picture_file_name)) disabled @endif" href="#"
data-picture-url="{{ $U('/api/files/get/productpictures?file_name=' . FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->picture_file_name) }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}">
<i class="fas fa-image"></i>
</a>
</td> </td>
<td> <td class="product-name-cell"
{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }} data-picture-url="{{ $U('/api/file/productpictures?file_name=' . FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->picture_file_name) }}"
data-product-name="{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}"
data-product-has-picture="{{ BoolToString(!empty(FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->picture_file_name)) }}">
{{ FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->name }}@if(!empty(FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->picture_file_name)) <i class="fas fa-image text-muted"></i>@endif
</td> </td>
<td> <td>
<span id="product-{{ $currentStockEntry->product_id }}-amount">{{ $currentStockEntry->amount }}</span> {{ Pluralize($currentStockEntry->amount, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name_plural) }} <span id="product-{{ $currentStockEntry->product_id }}-amount">{{ $currentStockEntry->amount }}</span> {{ Pluralize($currentStockEntry->amount, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name, FindObjectInArrayByPropertyValue($quantityunits, 'id', FindObjectInArrayByPropertyValue($products, 'id', $currentStockEntry->product_id)->qu_id_stock)->name_plural) }}