Merge pull request #4265 from BookStackApp/image_manager_responsive

Enhanced Responsive Image Manager
This commit is contained in:
Dan Brown 2023-05-29 16:52:55 +01:00 committed by GitHub
commit 242d23788d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 543 additions and 178 deletions

View File

@ -28,4 +28,12 @@ class ApiDocsController extends ApiController
return response()->json($docs);
}
/**
* Redirect to the API docs page.
*/
public function redirect()
{
return redirect('/api/docs');
}
}

View File

@ -26,7 +26,7 @@ class GalleryImageController extends Controller
$uploadedToFilter = $request->get('uploaded_to', null);
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm);
return view('pages.parts.image-manager-list', [
'images' => $imgData['images'],

View File

@ -14,13 +14,10 @@ use Illuminate\Validation\ValidationException;
class ImageController extends Controller
{
protected ImageRepo $imageRepo;
protected ImageService $imageService;
public function __construct(ImageRepo $imageRepo, ImageService $imageService)
{
$this->imageRepo = $imageRepo;
$this->imageService = $imageService;
public function __construct(
protected ImageRepo $imageRepo,
protected ImageService $imageService
) {
}
/**
@ -65,6 +62,29 @@ class ImageController extends Controller
]);
}
/**
* Update the file for an existing image.
*/
public function updateFile(Request $request, string $id)
{
$this->validate($request, [
'file' => ['required', 'file', ...$this->getImageValidationRules()],
]);
$image = $this->imageRepo->getById($id);
$this->checkImagePermission($image);
$this->checkOwnablePermission('image-update', $image);
$file = $request->file('file');
try {
$this->imageRepo->updateImageFile($image, $file);
} catch (ImageUploadException $exception) {
return $this->jsonError($exception->getMessage());
}
return response('');
}
/**
* Get the form for editing the given image.
*

View File

@ -30,6 +30,7 @@ class ImageGalleryApiController extends ApiController
],
'update' => [
'name' => ['string', 'max:180'],
'image' => ['file', ...$this->getImageValidationRules()],
]
];
}
@ -89,7 +90,8 @@ class ImageGalleryApiController extends ApiController
/**
* Update the details of an existing image in the system.
* Only allows updating of the image name at this time.
* Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a
* new image file. Updated image files should be of the same file type as the original image.
*/
public function update(Request $request, string $id)
{
@ -99,6 +101,9 @@ class ImageGalleryApiController extends ApiController
$this->checkOwnablePermission('image-update', $image);
$this->imageRepo->updateImageDetails($image, $data);
if (isset($data['image'])) {
$this->imageRepo->updateImageFile($image, $data['image']);
}
return response()->json($this->formatForSingleResponse($image));
}

View File

@ -11,16 +11,10 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImageRepo
{
protected ImageService $imageService;
protected PermissionApplicator $permissions;
/**
* ImageRepo constructor.
*/
public function __construct(ImageService $imageService, PermissionApplicator $permissions)
{
$this->imageService = $imageService;
$this->permissions = $permissions;
public function __construct(
protected ImageService $imageService,
protected PermissionApplicator $permissions
) {
}
/**
@ -164,12 +158,30 @@ class ImageRepo
public function updateImageDetails(Image $image, $updateDetails): Image
{
$image->fill($updateDetails);
$image->updated_by = user()->id;
$image->save();
$this->loadThumbs($image);
return $image;
}
/**
* Update the image file of an existing image in the system.
* @throws ImageUploadException
*/
public function updateImageFile(Image $image, UploadedFile $file): void
{
if ($file->getClientOriginalExtension() !== pathinfo($image->path, PATHINFO_EXTENSION)) {
throw new ImageUploadException(trans('errors.image_upload_replace_type'));
}
$image->refresh();
$image->updated_by = user()->id;
$image->save();
$this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
$this->loadThumbs($image, true);
}
/**
* Destroys an Image object along with its revisions, files and thumbnails.
*
@ -202,11 +214,11 @@ class ImageRepo
/**
* Load thumbnails onto an image object.
*/
public function loadThumbs(Image $image): void
public function loadThumbs(Image $image, bool $forceCreate = false): void
{
$image->setAttribute('thumbs', [
'gallery' => $this->getThumbnail($image, 150, 150, false),
'display' => $this->getThumbnail($image, 1680, null, true),
'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate),
'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate),
]);
}
@ -215,10 +227,10 @@ class ImageRepo
* If $keepRatio is true only the width will be used.
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
*/
protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio): ?string
protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $forceCreate): ?string
{
try {
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate);
} catch (Exception $exception) {
return null;
}

View File

@ -194,6 +194,14 @@ class ImageService
return $image;
}
public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void
{
$imageData = file_get_contents($file->getRealPath());
$storage = $this->getStorageDisk($type);
$adjustedPath = $this->adjustPathForStorageDisk($path, $type);
$storage->put($adjustedPath, $imageData);
}
/**
* Save image data for the given path in the public space, if possible,
* for the provided storage mechanism.
@ -262,7 +270,7 @@ class ImageService
* @throws Exception
* @throws InvalidArgumentException
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false, bool $forceCreate = false): string
{
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
@ -277,13 +285,13 @@ class ImageService
// Return path if in cache
$cachedThumbPath = $this->cache->get($thumbCacheKey);
if ($cachedThumbPath) {
if ($cachedThumbPath && !$forceCreate) {
return $this->getPublicUrl($cachedThumbPath);
}
// If thumbnail has already been generated, serve that and cache path
$storage = $this->getStorageDisk($image->type);
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
if (!$forceCreate && $storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);

View File

@ -6,6 +6,7 @@ return [
// Buttons
'cancel' => 'Cancel',
'close' => 'Close',
'confirm' => 'Confirm',
'back' => 'Back',
'save' => 'Save',

View File

@ -6,6 +6,8 @@ return [
// Image Manager
'image_select' => 'Image Select',
'image_list' => 'Image List',
'image_details' => 'Image Details',
'image_upload' => 'Upload Image',
'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.',
'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.',
@ -15,6 +17,9 @@ return [
'image_page_title' => 'View images uploaded to this page',
'image_search_hint' => 'Search by image name',
'image_uploaded' => 'Uploaded :uploadedDate',
'image_uploaded_by' => 'Uploaded by :userName',
'image_uploaded_to' => 'Uploaded to :pageLink',
'image_updated' => 'Updated :updateDate',
'image_load_more' => 'Load More',
'image_image_name' => 'Image Name',
'image_delete_used' => 'This image is used in the pages below.',
@ -27,6 +32,8 @@ return [
'image_upload_success' => 'Image uploaded successfully',
'image_update_success' => 'Image details successfully updated',
'image_delete_success' => 'Image successfully deleted',
'image_replace' => 'Replace Image',
'image_replace_success' => 'Image file successfully updated',
// Code Editor
'code_editor' => 'Edit Code',

View File

@ -49,6 +49,7 @@ return [
// Drawing & Images
'image_upload_error' => 'An error occurred uploading the image',
'image_upload_type_error' => 'The image type being uploaded is invalid',
'image_upload_replace_type' => 'Image file replacements must be of the same type',
'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments

View File

@ -15,6 +15,7 @@ export class Dropzone extends Component {
this.isActive = true;
this.url = this.$opts.url;
this.method = (this.$opts.method || 'post').toUpperCase();
this.successMessage = this.$opts.successMessage;
this.errorMessage = this.$opts.errorMessage;
this.uploadLimitMb = Number(this.$opts.uploadLimit);
@ -167,6 +168,9 @@ export class Dropzone extends Component {
startXhrForUpload(upload) {
const formData = new FormData();
formData.append('file', upload.file, upload.file.name);
if (this.method !== 'POST') {
formData.append('_method', this.method);
}
const component = this;
const req = window.$http.createXMLHttpRequest('POST', this.url, {

View File

@ -23,6 +23,7 @@ export class ImageManager extends Component {
this.formContainer = this.$refs.formContainer;
this.formContainerPlaceholder = this.$refs.formContainerPlaceholder;
this.dropzoneContainer = this.$refs.dropzoneContainer;
this.loadMore = this.$refs.loadMore;
// Instance data
this.type = 'gallery';
@ -40,6 +41,7 @@ export class ImageManager extends Component {
}
setupListeners() {
// Filter tab click
onSelect(this.filterTabs, e => {
this.resetAll();
this.filter = e.target.dataset.filter;
@ -47,32 +49,33 @@ export class ImageManager extends Component {
this.loadGallery();
});
// Search submit
this.searchForm.addEventListener('submit', event => {
this.resetListView();
this.loadGallery();
this.cancelSearch.toggleAttribute('hidden', !this.searchInput.value);
event.preventDefault();
});
// Cancel search button
onSelect(this.cancelSearch, () => {
this.resetListView();
this.resetSearchView();
this.loadGallery();
});
onChildEvent(this.listContainer, '.load-more button', 'click', async event => {
const wrapper = event.target.closest('.load-more');
showLoading(wrapper);
this.page += 1;
await this.loadGallery();
wrapper.remove();
});
// Load more button click
onChildEvent(this.container, '.load-more button', 'click', this.runLoadMore.bind(this));
// Select image event
this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
// Image load error handling
this.listContainer.addEventListener('error', event => {
event.target.src = window.baseUrl('loading_error.png');
}, true);
// Footer select button click
onSelect(this.selectButton, () => {
if (this.callback) {
this.callback(this.lastSelected);
@ -80,17 +83,39 @@ export class ImageManager extends Component {
this.hide();
});
// Delete button click
onChildEvent(this.formContainer, '#image-manager-delete', 'click', () => {
if (this.lastSelected) {
this.loadImageEditForm(this.lastSelected.id, true);
}
});
// Edit form submit
this.formContainer.addEventListener('ajax-form-success', () => {
this.refreshGallery();
this.resetEditForm();
});
// Image upload success
this.container.addEventListener('dropzone-upload-success', this.refreshGallery.bind(this));
// Auto load-more on scroll
const scrollZone = this.listContainer.parentElement;
let scrollEvents = [];
scrollZone.addEventListener('wheel', event => {
const scrollOffset = Math.ceil(scrollZone.scrollHeight - scrollZone.scrollTop);
const bottomedOut = scrollOffset === scrollZone.clientHeight;
if (!bottomedOut || event.deltaY < 1) {
return;
}
const secondAgo = Date.now() - 1000;
scrollEvents.push(Date.now());
scrollEvents = scrollEvents.filter(d => d >= secondAgo);
if (scrollEvents.length > 5 && this.canLoadMore()) {
this.runLoadMore();
}
});
}
show(callback, type = 'gallery') {
@ -145,6 +170,14 @@ export class ImageManager extends Component {
addReturnedHtmlElementsToList(html) {
const el = document.createElement('div');
el.innerHTML = html;
const loadMore = el.querySelector('.load-more');
if (loadMore) {
loadMore.remove();
this.loadMore.innerHTML = loadMore.innerHTML;
}
this.loadMore.toggleAttribute('hidden', !loadMore);
window.$components.init(el);
for (const child of [...el.children]) {
this.listContainer.appendChild(child);
@ -169,6 +202,7 @@ export class ImageManager extends Component {
resetSearchView() {
this.searchInput.value = '';
this.cancelSearch.toggleAttribute('hidden', true);
}
resetEditForm() {
@ -224,4 +258,14 @@ export class ImageManager extends Component {
window.$components.init(this.formContainer);
}
runLoadMore() {
showLoading(this.loadMore);
this.page += 1;
this.loadGallery();
}
canLoadMore() {
return this.loadMore.querySelector('button') && !this.loadMore.hasAttribute('hidden');
}
}

View File

@ -21,15 +21,23 @@ export class Tabs extends Component {
setup() {
this.container = this.$el;
this.tabs = Array.from(this.container.querySelectorAll('[role="tab"]'));
this.panels = Array.from(this.container.querySelectorAll('[role="tabpanel"]'));
this.tabList = this.container.querySelector('[role="tablist"]');
this.tabs = Array.from(this.tabList.querySelectorAll('[role="tab"]'));
this.panels = Array.from(this.container.querySelectorAll(':scope > [role="tabpanel"], :scope > * > [role="tabpanel"]'));
this.activeUnder = this.$opts.activeUnder ? Number(this.$opts.activeUnder) : 10000;
this.active = null;
this.container.addEventListener('click', event => {
const button = event.target.closest('[role="tab"]');
if (button) {
this.show(button.getAttribute('aria-controls'));
const tab = event.target.closest('[role="tab"]');
if (tab && this.tabs.includes(tab)) {
this.show(tab.getAttribute('aria-controls'));
}
});
window.addEventListener('resize', this.updateActiveState.bind(this), {
passive: true,
});
this.updateActiveState();
}
show(sectionId) {
@ -46,4 +54,35 @@ export class Tabs extends Component {
this.$emit('change', {showing: sectionId});
}
updateActiveState() {
const active = window.innerWidth < this.activeUnder;
if (active === this.active) {
return;
}
if (active) {
this.activate();
} else {
this.deactivate();
}
this.active = active;
}
activate() {
const panelToShow = this.panels.find(p => !p.hasAttribute('hidden')) || this.panels[0];
this.show(panelToShow.id);
this.tabList.toggleAttribute('hidden', false);
}
deactivate() {
for (const panel of this.panels) {
panel.removeAttribute('hidden');
}
for (const tab of this.tabs) {
tab.setAttribute('aria-selected', 'false');
}
this.tabList.toggleAttribute('hidden', true);
}
}

View File

@ -2,7 +2,7 @@
.anim.fadeIn {
opacity: 0;
animation-name: fadeIn;
animation-duration: 180ms;
animation-duration: 120ms;
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
}

View File

@ -200,10 +200,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
flex: 1;
}
.image-manager-body {
min-height: 70vh;
}
.dropzone-overlay {
position: absolute;
display: flex;
@ -347,43 +343,99 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
display: none;
}
.image-manager-body {
min-height: 70vh;
}
.image-manager-filter-bar {
position: sticky;
top: 0;
z-index: 5;
@include lightDark(background-color, rgba(255, 255, 255, 0.85), rgba(80, 80, 80, 0.85));
}
.image-manager-filter-bar-bg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: .15;
z-index: -1;
}
.image-manager-filters {
box-shadow: $bs-med;
border-radius: 4px;
overflow: hidden;
border-bottom: 0 !important;
@include whenDark {
border: 1px solid #000 !important;
}
button {
line-height: 0;
@include lightDark(background-color, #FFF, #333);
}
svg {
margin: 0;
}
}
.image-manager-list {
padding: 3px;
display: grid;
grid-template-columns: repeat( auto-fit, minmax(140px, 1fr) );
gap: 3px;
z-index: 3;
> div {
aspect-ratio: 1;
}
}
.image-manager-list .image {
display: block;
position: relative;
border-radius: 0;
float: left;
margin: 0;
width: 100%;
text-align: start;
padding: 0;
cursor: pointer;
width: math.div(100%, 6);
height: auto;
aspect-ratio: 1;
@include lightDark(border-color, #ddd, #000);
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
transition: all cubic-bezier(.4, 0, 1, 1) 160ms;
transition: all linear 80ms;
overflow: hidden;
&.selected {
transform: scale3d(0.92, 0.92, 0.92);
outline: currentColor 2px solid;
background-color: var(--color-primary-light);
outline: currentColor 3px solid;
border-radius: 3px;
transform: scale3d(0.95, 0.95, 0.95);
}
img {
width: 100%;
max-width: 100%;
display: block;
object-fit: cover;
height: auto;
}
.image-meta {
opacity: 0;
position: absolute;
width: 100%;
bottom: 0;
left: 0;
color: #EEE;
background-color: rgba(0, 0, 0, 0.4);
background-color: rgba(0, 0, 0, 0.7);
font-size: 10px;
padding: 3px 4px;
pointer-events: none;
transition: opacity ease-in-out 80ms;
span {
display: block;
}
}
@include smaller-than($xl) {
width: math.div(100%, 4);
&.selected .image-meta,
&:hover .image-meta,
&:focus .image-meta {
opacity: 1;
}
@include smaller-than($m) {
.image-meta {
@ -393,7 +445,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
}
.image-manager .load-more {
display: block;
text-align: center;
padding: $-s $-m;
clear: both;
@ -408,6 +459,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
.image-manager-sidebar {
width: 300px;
margin: 0 auto;
overflow-y: auto;
overflow-x: hidden;
border-inline-start: 1px solid #DDD;
@ -433,16 +485,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
}
}
}
.image-manager-list {
overflow-y: scroll;
flex: 1;
@include smaller-than($m) {
.image-manager-sidebar {
border-inline-start: 0;
}
}
.image-manager-content {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: scroll;
.container {
width: 100%;
}
@ -451,18 +504,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
}
}
.image-manager [role="tablist"] button[role="tab"] {
border-right: 1px solid #DDD;
@include lightDark(border-color, #DDD, #000);
.tab-container.bordered [role="tablist"] button[role="tab"] {
border-inline-end: 1px solid #DDD;
@include lightDark(border-inline-end-color, #DDD, #000);
&:last-child {
border-right: none;
border-inline-end: none;
}
}
.image-manager-header {
z-index: 4;
}
.tab-container [role="tablist"] {
display: flex;
align-items: end;
@ -473,8 +522,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
margin-bottom: $-m;
}
.tab-container [role="tablist"] button[role="tab"],
.image-manager [role="tablist"] button[role="tab"] {
.tab-container [role="tablist"] button[role="tab"] {
display: inline-block;
padding: $-s;
@include lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5));
@ -484,11 +532,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
&[aria-selected="true"] {
color: var(--color-link) !important;
border-bottom-color: var(--color-link) !important;
outline: 0 !important;
}
&:hover, &:focus {
@include lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8));
@include lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2));
}
&:focus {
outline: 1px dotted var(--color-primary);
outline-offset: -2px;
}
}
.tab-container [role="tablist"].controls-card {
margin-bottom: 0;

View File

@ -467,6 +467,58 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
}
}
.contained-search-box {
display: flex;
height: 38px;
z-index: -1;
&.floating {
box-shadow: $bs-med;
border-radius: 4px;
overflow: hidden;
@include whenDark {
border: 1px solid #000;
}
}
input, button {
height: 100%;
border-radius: 0;
border: 1px solid #ddd;
@include lightDark(border-color, #ddd, #000);
margin-inline-start: -1px;
&:last-child {
border-inline-end: 0;
}
}
input {
border: 0;
flex: 5;
padding: $-xs $-s;
&:focus, &:active {
outline: 1px dotted var(--color-primary);
outline-offset: -2px;
border: 0;
}
}
button {
border: 0;
width: 48px;
border-inline-start: 1px solid #DDD;
background-color: #FFF;
@include lightDark(background-color, #FFF, #333);
@include lightDark(color, #444, #AAA);
}
button:focus {
outline: 1px dotted var(--color-primary);
outline-offset: -2px;
}
svg {
margin: 0;
}
@include smaller-than($s) {
width: 180px;
}
}
.outline > input {
border: 0;
border-bottom: 2px solid #DDD;

View File

@ -298,6 +298,10 @@ body.flexbox {
}
}
[hidden] {
display: none !important;
}
/**
* Border radiuses
*/

View File

@ -674,6 +674,10 @@ ul.pagination {
text-align: start !important;
max-height: 500px;
overflow-y: auto;
&.anchor-left {
inset-inline-end: auto;
inset-inline-start: 0;
}
&.wide {
min-width: 220px;
}

View File

@ -117,44 +117,6 @@ $loadingSize: 10px;
}
}
.contained-search-box {
display: flex;
height: 38px;
z-index: -1;
input, button {
height: 100%;
border-radius: 0;
border: 1px solid #ddd;
@include lightDark(border-color, #ddd, #000);
margin-inline-start: -1px;
&:last-child {
border-inline-end: 0;
}
}
input {
flex: 5;
padding: $-xs $-s;
&:focus, &:active {
outline: 1px dotted var(--color-primary);
outline-offset: -2px;
border: 1px solid #ddd;
@include lightDark(border-color, #ddd, #000);
}
}
button {
width: 60px;
}
button.primary-background {
border-color: var(--color-primary);
}
button i {
padding: 0;
}
svg {
margin: 0;
}
}
.entity-selector {
border: 1px solid #DDD;
@include lightDark(border-color, #ddd, #111);

View File

@ -1,4 +1,14 @@
<div class="image-manager-details">
<div component="dropzone"
option:dropzone:url="{{ url("/images/{$image->id}/file") }}"
option:dropzone:method="PUT"
option:dropzone:success-message="{{ trans('components.image_update_success') }}"
option:dropzone:upload-limit="{{ config('app.upload_limit') }}"
option:dropzone:upload-limit-message="{{ trans('errors.server_upload_limit') }}"
option:dropzone:zone-text="{{ trans('entities.attachments_dropzone') }}"
option:dropzone:file-accept="image/*"
class="image-manager-details">
<div refs="dropzone@status-area dropzone@drop-target"></div>
<form component="ajax-form"
option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
@ -18,23 +28,33 @@
<label for="name">{{ trans('components.image_image_name') }}</label>
<input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}">
</div>
<div class="grid half">
<div>
@if(userCan('image-delete', $image))
<button type="button"
id="image-manager-delete"
title="{{ trans('common.delete') }}"
class="button icon outline">@icon('delete')</button>
@endif
</div>
<div class="text-right">
<div class="flex-container-row justify-space-between gap-m">
@if(userCan('image-delete', $image) || userCan('image-update', $image))
<div component="dropdown"
class="dropdown-container">
<button refs="dropdown@toggle" type="button" class="button icon outline">@icon('more')</button>
<div refs="dropdown@menu" class="dropdown-menu anchor-left">
@if(userCan('image-delete', $image))
<button type="button"
id="image-manager-delete"
class="text-item">{{ trans('common.delete') }}</button>
@endif
@if(userCan('image-update', $image))
<button type="button"
id="image-manager-replace"
refs="dropzone@select-button"
class="text-item">{{ trans('components.image_replace') }}</button>
@endif
</div>
</div>
@endif
<button type="submit"
class="button icon outline">{{ trans('common.save') }}</button>
</div>
</div>
</form>
@if(!is_null($dependantPages))
<hr>
@if(count($dependantPages) > 0)
<p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
<ul class="text-neg">
@ -60,4 +80,27 @@
</form>
@endif
<div class="text-muted text-small">
<hr class="my-m">
<div title="{{ $image->created_at->format('Y-m-d H:i:s') }}">
@icon('star') {{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->diffForHumans()]) }}
</div>
@if($image->created_at->valueOf() !== $image->updated_at->valueOf())
<div title="{{ $image->updated_at->format('Y-m-d H:i:s') }}">
@icon('edit') {{ trans('components.image_updated', ['updateDate' => $image->updated_at->diffForHumans()]) }}
</div>
@endif
@if($image->createdBy)
<div>@icon('user') {{ trans('components.image_uploaded_by', ['userName' => $image->createdBy->name]) }}</div>
@endif
@if(($page = $image->getPage()) && userCan('view', $page))
<div>
@icon('page')
{!! trans('components.image_uploaded_to', [
'pageLink' => '<a class="text-page" href="' . e($page->getUrl()) . '" target="_blank">' . e($page->name) . '</a>'
]) !!}
</div>
@endif
</div>
</div>

View File

@ -1,23 +1,26 @@
@foreach($images as $index => $image)
<div>
<div component="event-emit-select"
<button component="event-emit-select"
option:event-emit-select:name="image"
option:event-emit-select:data="{{ json_encode($image) }}"
class="image anim fadeIn text-link"
style="animation-delay: {{ $index > 26 ? '160ms' : ($index * 25) . 'ms' }};">
style="animation-delay: {{ min($index * 10, 260) . 'ms' }};">
<img src="{{ $image->thumbs['gallery'] }}"
alt="{{ $image->name }}"
role="none"
width="150"
height="150"
loading="lazy"
title="{{ $image->name }}">
loading="lazy">
<div class="image-meta">
<span class="name">{{ $image->name }}</span>
<span class="date">{{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->format('Y-m-d H:i:s')]) }}</span>
<span class="date">{{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->format('Y-m-d')]) }}</span>
</div>
</div>
</button>
</div>
@endforeach
@if(count($images) === 0)
<p class="m-m text-bigger italic text-muted">{{ trans('common.no_items') }}</p>
@endif
@if($hasMore)
<div class="load-more">
<button type="button" class="button small outline">{{ trans('components.image_load_more') }}</button>

View File

@ -21,60 +21,98 @@
<span>@icon('upload')</span>
<span>{{ trans('components.image_upload') }}</span>
</button>
<button refs="popup@hide" type="button" class="popup-header-close">@icon('close')</button>
<button refs="popup@hide"
type="button"
title="{{ trans('common.close') }}"
class="popup-header-close">@icon('close')</button>
</div>
<div refs="dropzone@drop-target" class="flex-fill image-manager-body">
<div class="image-manager-content">
<div role="tablist" class="image-manager-header grid third no-gap">
<button refs="image-manager@filterTabs"
data-filter="all"
role="tab"
<div component="tabs"
option:tabs:active-under="880"
refs="dropzone@drop-target"
class="flex-container-column image-manager-body">
<div class="tab-container">
<div role="tablist" class="hide-over-m mb-none">
<button id="image-manager-list-tab"
aria-selected="true"
type="button" class="tab-item" title="{{ trans('components.image_all_title') }}">@icon('images') {{ trans('components.image_all') }}</button>
<button refs="image-manager@filterTabs"
data-filter="book"
role="tab"
aria-selected="false"
type="button" class="tab-item" title="{{ trans('components.image_book_title') }}">@icon('book', ['class' => 'svg-icon']) {{ trans('entities.book') }}</button>
<button refs="image-manager@filterTabs"
data-filter="page"
role="tab"
aria-selected="false"
type="button" class="tab-item" title="{{ trans('components.image_page_title') }}">@icon('page', ['class' => 'svg-icon']) {{ trans('entities.page') }}</button>
</div>
<div>
<form refs="image-manager@searchForm" class="contained-search-box">
<input refs="image-manager@searchInput"
placeholder="{{ trans('components.image_search_hint') }}"
type="text">
<button refs="image-manager@cancelSearch"
title="{{ trans('common.search_clear') }}"
type="button"
class="cancel">@icon('close')</button>
<button type="submit" class="primary-background text-white"
title="{{ trans('common.search') }}">@icon('search')</button>
</form>
</div>
<div refs="image-manager@listContainer" class="image-manager-list"></div>
</div>
<div class="image-manager-sidebar flex-container-column">
<div refs="image-manager@dropzoneContainer">
<div refs="dropzone@status-area"></div>
</div>
<div refs="image-manager@form-container-placeholder" class="p-m text-small text-muted">
<p>{{ trans('components.image_intro') }}</p>
<p refs="image-manager@upload-hint">{{ trans('components.image_intro_upload') }}</p>
</div>
<div refs="image-manager@formContainer" class="inner flex">
aria-controls="image-manager-list"
role="tab">{{ trans('components.image_list') }}</button>
<button id="image-manager-info-tab"
aria-selected="true"
aria-controls="image-manager-info"
role="tab">{{ trans('components.image_details') }}</button>
</div>
</div>
<div class="flex-container-row flex-fill flex">
<div id="image-manager-list"
tabindex="0"
role="tabpanel"
aria-labelledby="image-manager-list-tab"
class="image-manager-content">
<div class="image-manager-filter-bar flex-container-row wrap justify-space-between">
<div class="primary-background image-manager-filter-bar-bg"></div>
<div>
<form refs="image-manager@searchForm" role="search" class="contained-search-box floating mx-m my-s">
<input refs="image-manager@searchInput"
placeholder="{{ trans('components.image_search_hint') }}"
type="search">
<button refs="image-manager@cancelSearch"
title="{{ trans('common.search_clear') }}"
type="button"
hidden="hidden"
class="cancel">@icon('close')</button>
<button type="submit"
title="{{ trans('common.search') }}">@icon('search')</button>
</form>
</div>
<div class="tab-container bordered mx-m my-s">
<div role="tablist" class="image-manager-filters flex-container-row mb-none">
<button refs="image-manager@filterTabs"
data-filter="all"
role="tab"
aria-selected="true"
type="button"
title="{{ trans('components.image_all_title') }}">@icon('images')</button>
<button refs="image-manager@filterTabs"
data-filter="book"
role="tab"
aria-selected="false"
type="button"
title="{{ trans('components.image_book_title') }}">@icon('book', ['class' => 'svg-icon'])</button>
<button refs="image-manager@filterTabs"
data-filter="page"
role="tab"
aria-selected="false"
type="button"
title="{{ trans('components.image_page_title') }}">@icon('page', ['class' => 'svg-icon'])</button>
</div>
</div>
</div>
<div refs="image-manager@listContainer" class="image-manager-list"></div>
<div refs="image-manager@loadMore" class="load-more" hidden>
<button type="button" class="button small outline">Load More</button>
</div>
</div>
<div id="image-manager-info"
tabindex="0"
role="tabpanel"
aria-labelledby="image-manager-info-tab"
class="image-manager-sidebar flex-container-column">
<div refs="image-manager@dropzoneContainer">
<div refs="dropzone@status-area"></div>
</div>
<div refs="image-manager@form-container-placeholder" class="p-m text-small text-muted">
<p>{{ trans('components.image_intro') }}</p>
<p refs="image-manager@upload-hint">{{ trans('components.image_intro_upload') }}</p>
</div>
<div refs="image-manager@formContainer" class="inner flex">
</div>
</div>
</div>
</div>
<div class="popup-footer">

View File

@ -29,7 +29,7 @@ Route::middleware('auth')->group(function () {
->where('path', '.*$');
// API docs routes
Route::redirect('/api', '/api/docs');
Route::get('/api', [ApiDocsController::class, 'redirect']);
Route::get('/api/docs', [ApiDocsController::class, 'display']);
Route::get('/pages/recently-updated', [EntityControllers\PageController::class, 'showRecentlyUpdated']);
@ -140,6 +140,7 @@ Route::middleware('auth')->group(function () {
Route::get('/images/drawio/base64/{id}', [UploadControllers\DrawioImageController::class, 'getAsBase64']);
Route::post('/images/drawio', [UploadControllers\DrawioImageController::class, 'create']);
Route::get('/images/edit/{id}', [UploadControllers\ImageController::class, 'edit']);
Route::put('/images/{id}/file', [UploadControllers\ImageController::class, 'updateFile']);
Route::put('/images/{id}', [UploadControllers\ImageController::class, 'update']);
Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']);

View File

@ -295,7 +295,24 @@ class ImageGalleryApiTest extends TestCase
]);
}
public function test_update_endpoint_requires_image_delete_permission()
public function test_update_existing_image_file()
{
$this->actingAsApiAdmin();
$imagePage = $this->entities->page();
$data = $this->files->uploadGalleryImageToPage($this, $imagePage);
$image = Image::findOrFail($data['response']->id);
$this->assertFileEquals($this->files->testFilePath('test-image.png'), public_path($data['path']));
$resp = $this->call('PUT', $this->baseEndpoint . "/{$image->id}", [], [], [
'image' => $this->files->uploadedImage('my-cool-image.png', 'compressed.png'),
]);
$resp->assertStatus(200);
$this->assertFileEquals($this->files->testFilePath('compressed.png'), public_path($data['path']));
}
public function test_update_endpoint_requires_image_update_permission()
{
$user = $this->users->editor();
$this->actingAsForApi($user);

View File

@ -92,6 +92,45 @@ class ImageTest extends TestCase
]);
}
public function test_image_file_update()
{
$page = $this->entities->page();
$this->asEditor();
$imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
$relPath = $imgDetails['path'];
$newUpload = $this->files->uploadedImage('updated-image.png', 'compressed.png');
$this->assertFileEquals($this->files->testFilePath('test-image.png'), public_path($relPath));
$imageId = $imgDetails['response']->id;
$this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload])
->assertOk();
$this->assertFileEquals($this->files->testFilePath('compressed.png'), public_path($relPath));
$this->files->deleteAtRelativePath($relPath);
}
public function test_image_file_update_does_not_allow_change_in_image_extension()
{
$page = $this->entities->page();
$this->asEditor();
$imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
$relPath = $imgDetails['path'];
$newUpload = $this->files->uploadedImage('updated-image.jpg', 'compressed.png');
$imageId = $imgDetails['response']->id;
$this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload])
->assertJson([
"message" => "Image file replacements must be of the same type",
"status" => "error",
]);
$this->files->deleteAtRelativePath($relPath);
}
public function test_gallery_get_list_format()
{
$this->asEditor();
@ -493,15 +532,15 @@ class ImageTest extends TestCase
$image = Image::first();
$resp = $this->get("/images/edit/{$image->id}");
$this->withHtml($resp)->assertElementExists('button#image-manager-delete[title="Delete"]');
$this->withHtml($resp)->assertElementExists('button#image-manager-delete');
$resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}");
$this->withHtml($resp)->assertElementNotExists('button#image-manager-delete[title="Delete"]');
$this->withHtml($resp)->assertElementNotExists('button#image-manager-delete');
$this->permissions->grantUserRolePermissions($viewer, ['image-delete-all']);
$resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}");
$this->withHtml($resp)->assertElementExists('button#image-manager-delete[title="Delete"]');
$this->withHtml($resp)->assertElementExists('button#image-manager-delete');
$this->files->deleteAtRelativePath($relPath);
}