Converted image-manager to be component/HTML based

Instead of vue based.
This commit is contained in:
Dan Brown 2020-07-25 00:20:58 +01:00
parent b6aa232205
commit 02dc3154e3
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
23 changed files with 483 additions and 392 deletions

View File

@ -30,7 +30,10 @@ class DrawioImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
return response()->json($imgData);
return view('components.image-manager-list', [
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
]);
}
/**
@ -72,6 +75,7 @@ class DrawioImageController extends Controller
if ($imageData === null) {
return $this->jsonError("Image data could not be found");
}
return response()->json([
'content' => base64_encode($imageData)
]);

View File

@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request;
use BookStack\Http\Controllers\Controller;
use Illuminate\Validation\ValidationException;
class GalleryImageController extends Controller
{
@ -13,7 +14,6 @@ class GalleryImageController extends Controller
/**
* GalleryImageController constructor.
* @param ImageRepo $imageRepo
*/
public function __construct(ImageRepo $imageRepo)
{
@ -24,8 +24,6 @@ class GalleryImageController extends Controller
/**
* Get a list of gallery images, in a list.
* Can be paged and filtered by entity.
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function list(Request $request)
{
@ -35,14 +33,15 @@ class GalleryImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
return response()->json($imgData);
return view('components.image-manager-list', [
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
]);
}
/**
* Store a new gallery image in the system.
* @param Request $request
* @return Illuminate\Http\JsonResponse
* @throws \Exception
* @throws ValidationException
*/
public function create(Request $request)
{

View File

@ -6,8 +6,11 @@ use BookStack\Http\Controllers\Controller;
use BookStack\Repos\PageRepo;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Filesystem\Filesystem as File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class ImageController extends Controller
{
@ -17,9 +20,6 @@ class ImageController extends Controller
/**
* ImageController constructor.
* @param Image $image
* @param File $file
* @param ImageRepo $imageRepo
*/
public function __construct(Image $image, File $file, ImageRepo $imageRepo)
{
@ -31,8 +31,6 @@ class ImageController extends Controller
/**
* Provide an image file from storage.
* @param string $path
* @return mixed
*/
public function showImage(string $path)
{
@ -47,13 +45,10 @@ class ImageController extends Controller
/**
* Update image details
* @param Request $request
* @param integer $id
* @return \Illuminate\Http\JsonResponse
* @throws ImageUploadException
* @throws \Exception
* @throws ValidationException
*/
public function update(Request $request, $id)
public function update(Request $request, string $id)
{
$this->validate($request, [
'name' => 'required|min:2|string'
@ -64,47 +59,50 @@ class ImageController extends Controller
$this->checkOwnablePermission('image-update', $image);
$image = $this->imageRepo->updateImageDetails($image, $request->all());
return response()->json($image);
$this->imageRepo->loadThumbs($image);
return view('components.image-manager-form', [
'image' => $image,
'dependantPages' => null,
]);
}
/**
* Show the usage of an image on pages.
* Get the form for editing the given image.
* @throws Exception
*/
public function usage(int $id)
public function edit(Request $request, string $id)
{
$image = $this->imageRepo->getById($id);
$this->checkImagePermission($image);
$pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']);
foreach ($pages as $page) {
$page->url = $page->getUrl();
$page->html = '';
$page->text = '';
if ($request->has('delete')) {
$dependantPages = $this->imageRepo->getPagesUsingImage($image);
}
$result = count($pages) > 0 ? $pages : false;
return response()->json($result);
$this->imageRepo->loadThumbs($image);
return view('components.image-manager-form', [
'image' => $image,
'dependantPages' => $dependantPages ?? null,
]);
}
/**
* Deletes an image and all thumbnail/image files
* @param int $id
* @return \Illuminate\Http\JsonResponse
* @throws \Exception
* @throws Exception
*/
public function destroy($id)
public function destroy(string $id)
{
$image = $this->imageRepo->getById($id);
$this->checkOwnablePermission('image-delete', $image);
$this->checkImagePermission($image);
$this->imageRepo->destroyImage($image);
return response()->json(trans('components.images_deleted'));
return response('');
}
/**
* Check related page permission and ensure type is drawio or gallery.
* @param Image $image
*/
protected function checkImagePermission(Image $image)
{

View File

@ -185,7 +185,7 @@ class ImageRepo
* Load thumbnails onto an image object.
* @throws Exception
*/
protected function loadThumbs(Image $image)
public function loadThumbs(Image $image)
{
$image->thumbs = [
'gallery' => $this->getThumbnail($image, 150, 150, false),
@ -219,4 +219,20 @@ class ImageRepo
return null;
}
}
/**
* Get the user visible pages using the given image.
*/
public function getPagesUsingImage(Image $image): array
{
$pages = Page::visible()
->where('html', 'like', '%' . $image->url . '%')
->get(['id', 'name', 'slug', 'book_id']);
foreach ($pages as $page) {
$page->url = $page->getUrl();
}
return $pages->all();
}
}

View File

@ -5,52 +5,76 @@ import {onEnterPress, onSelect} from "../services/dom";
* Will handle button clicks or input enter press events and submit
* the data over ajax. Will always expect a partial HTML view to be returned.
* Fires an 'ajax-form-success' event when submitted successfully.
*
* Will handle a real form if that's what the component is added to
* otherwise will act as a fake form element.
*
* @extends {Component}
*/
class AjaxForm {
setup() {
this.container = this.$el;
this.responseContainer = this.container;
this.url = this.$opts.url;
this.method = this.$opts.method || 'post';
this.successMessage = this.$opts.successMessage;
this.submitButtons = this.$manyRefs.submit || [];
if (this.$opts.responseContainer) {
this.responseContainer = this.container.closest(this.$opts.responseContainer);
}
this.setupListeners();
}
setupListeners() {
if (this.container.tagName === 'FORM') {
this.container.addEventListener('submit', this.submitRealForm.bind(this));
return;
}
onEnterPress(this.container, event => {
this.submit();
this.submitFakeForm();
event.preventDefault();
});
this.submitButtons.forEach(button => onSelect(button, this.submit.bind(this)));
this.submitButtons.forEach(button => onSelect(button, this.submitFakeForm.bind(this)));
}
async submit() {
submitFakeForm() {
const fd = new FormData();
const inputs = this.container.querySelectorAll(`[name]`);
console.log(inputs);
for (const input of inputs) {
fd.append(input.getAttribute('name'), input.value);
}
this.submit(fd);
}
submitRealForm(event) {
event.preventDefault();
const fd = new FormData(this.container);
this.submit(fd);
}
async submit(formData) {
this.responseContainer.style.opacity = '0.7';
this.responseContainer.style.pointerEvents = 'none';
this.container.style.opacity = '0.7';
this.container.style.pointerEvents = 'none';
try {
const resp = await window.$http[this.method.toLowerCase()](this.url, fd);
this.container.innerHTML = resp.data;
this.$emit('success', {formData: fd});
const resp = await window.$http[this.method.toLowerCase()](this.url, formData);
this.$emit('success', {formData});
this.responseContainer.innerHTML = resp.data;
if (this.successMessage) {
window.$events.emit('success', this.successMessage);
}
} catch (err) {
this.container.innerHTML = err.data;
this.responseContainer.innerHTML = err.data;
}
window.components.init(this.container);
this.container.style.opacity = null;
this.container.style.pointerEvents = null;
window.components.init(this.responseContainer);
this.responseContainer.style.opacity = null;
this.responseContainer.style.pointerEvents = null;
}
}

View File

@ -43,7 +43,6 @@ class Dropzone {
}
onSuccess(file, data) {
this.container.dispatchEvent(new Event('dropzone'))
this.$emit('success', {file, data});
if (this.successMessage) {

View File

@ -0,0 +1,201 @@
import {onChildEvent, onSelect, removeLoading, showLoading} from "../services/dom";
/**
* ImageManager
* @extends {Component}
*/
class ImageManager {
setup() {
// Options
this.uploadedTo = this.$opts.uploadedTo;
// Element References
this.container = this.$el;
this.popupEl = this.$refs.popup;
this.searchForm = this.$refs.searchForm;
this.searchInput = this.$refs.searchInput;
this.cancelSearch = this.$refs.cancelSearch;
this.listContainer = this.$refs.listContainer;
this.filterTabs = this.$manyRefs.filterTabs;
this.selectButton = this.$refs.selectButton;
this.formContainer = this.$refs.formContainer;
this.dropzoneContainer = this.$refs.dropzoneContainer;
// Instance data
this.type = 'gallery';
this.lastSelected = {};
this.lastSelectedTime = 0;
this.resetState = () => {
this.callback = null;
this.hasData = false;
this.page = 1;
this.filter = 'all';
};
this.resetState();
this.setupListeners();
window.ImageManager = this;
}
setupListeners() {
onSelect(this.filterTabs, e => {
this.resetAll();
this.filter = e.target.dataset.filter;
this.setActiveFilterTab(this.filter);
this.loadGallery();
});
this.searchForm.addEventListener('submit', event => {
this.resetListView();
this.loadGallery();
event.preventDefault();
});
onSelect(this.cancelSearch, event => {
this.resetListView();
this.resetSearchView();
this.loadGallery();
this.cancelSearch.classList.remove('active');
});
this.searchInput.addEventListener('input', event => {
this.cancelSearch.classList.toggle('active', this.searchInput.value.trim());
});
onChildEvent(this.listContainer, '.load-more', 'click', async event => {
showLoading(event.target);
this.page++;
await this.loadGallery();
event.target.remove();
});
this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
onSelect(this.selectButton, () => {
if (this.callback) {
this.callback(this.lastSelected);
}
this.hide();
});
onChildEvent(this.formContainer, '#image-manager-delete', 'click', event => {
if (this.lastSelected) {
this.loadImageEditForm(this.lastSelected.id, true);
}
});
this.formContainer.addEventListener('ajax-form-success', this.refreshGallery.bind(this));
this.container.addEventListener('dropzone-success', this.refreshGallery.bind(this));
}
show(callback, type = 'gallery') {
this.resetAll();
this.callback = callback;
this.type = type;
this.popupEl.components.popup.show();
this.dropzoneContainer.classList.toggle('hidden', type !== 'gallery');
if (!this.hasData) {
this.loadGallery();
this.hasData = true;
}
}
hide() {
this.popupEl.components.popup.hide();
}
async loadGallery() {
const params = {
page: this.page,
search: this.searchInput.value || null,
uploaded_to: this.uploadedTo,
filter_type: this.filter === 'all' ? null : this.filter,
};
const {data: html} = await window.$http.get(`images/${this.type}`, params);
this.addReturnedHtmlElementsToList(html);
removeLoading(this.listContainer);
}
addReturnedHtmlElementsToList(html) {
const el = document.createElement('div');
el.innerHTML = html;
window.components.init(el);
for (const child of [...el.children]) {
this.listContainer.appendChild(child);
}
}
setActiveFilterTab(filterName) {
this.filterTabs.forEach(t => t.classList.remove('selected'));
const activeTab = this.filterTabs.find(t => t.dataset.filter === filterName);
if (activeTab) {
activeTab.classList.add('selected');
}
}
resetAll() {
this.resetState();
this.resetListView();
this.resetSearchView();
this.formContainer.innerHTML = '';
this.setActiveFilterTab('all');
}
resetSearchView() {
this.searchInput.value = '';
}
resetListView() {
showLoading(this.listContainer);
this.page = 1;
}
refreshGallery() {
this.resetListView();
this.loadGallery();
}
onImageSelectEvent(event) {
const image = JSON.parse(event.detail.data);
const isDblClick = ((image && image.id === this.lastSelected.id)
&& Date.now() - this.lastSelectedTime < 400);
const alreadySelected = event.target.classList.contains('selected');
[...this.listContainer.querySelectorAll('.selected')].forEach(el => {
el.classList.remove('selected');
});
if (!alreadySelected) {
event.target.classList.add('selected');
this.loadImageEditForm(image.id);
}
this.selectButton.classList.toggle('hidden', alreadySelected);
if (isDblClick && this.callback) {
this.callback(image);
this.hide();
}
this.lastSelected = image;
this.lastSelectedTime = Date.now();
}
async loadImageEditForm(imageId, requestDelete = false) {
if (!requestDelete) {
this.formContainer.innerHTML = '';
}
const params = requestDelete ? {delete: true} : {};
const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params);
this.formContainer.innerHTML = formHtml;
window.components.init(this.formContainer);
}
}
export default ImageManager;

View File

@ -106,4 +106,15 @@ export function findText(selector, text) {
*/
export function showLoading(element) {
element.innerHTML = `<div class="loading-container"><div></div><div></div><div></div></div>`;
}
/**
* Remove any loading indicators within the given element.
* @param {Element} element
*/
export function removeLoading(element) {
const loadingEls = element.querySelectorAll('.loading-container');
for (const el of loadingEls) {
el.remove();
}
}

View File

@ -1,204 +0,0 @@
import * as Dates from "../services/dates";
import dropzone from "./components/dropzone";
let page = 1;
let previousClickTime = 0;
let previousClickImage = 0;
let dataLoaded = false;
let callback = false;
let baseUrl = '';
let preSearchImages = [];
let preSearchHasMore = false;
const data = {
images: [],
imageType: false,
uploadedTo: false,
selectedImage: false,
dependantPages: false,
showing: false,
filter: null,
hasMore: false,
searching: false,
searchTerm: '',
imageUpdateSuccess: false,
imageDeleteSuccess: false,
deleteConfirm: false,
};
const methods = {
show(providedCallback, imageType = null) {
callback = providedCallback;
this.showing = true;
this.$el.children[0].components.popup.show();
// Get initial images if they have not yet been loaded in.
if (dataLoaded && imageType === this.imageType) return;
if (imageType) {
this.imageType = imageType;
this.resetState();
}
this.fetchData();
dataLoaded = true;
},
hide() {
if (this.$refs.dropzone) {
this.$refs.dropzone.onClose();
}
this.showing = false;
this.selectedImage = false;
this.$el.children[0].components.popup.hide();
},
async fetchData() {
const params = {
page,
search: this.searching ? this.searchTerm : null,
uploaded_to: this.uploadedTo || null,
filter_type: this.filter,
};
const {data} = await this.$http.get(baseUrl, params);
this.images = this.images.concat(data.images);
this.hasMore = data.has_more;
page++;
},
setFilterType(filterType) {
this.filter = filterType;
this.resetState();
this.fetchData();
},
resetState() {
this.cancelSearch();
this.resetListView();
this.deleteConfirm = false;
baseUrl = window.baseUrl(`/images/${this.imageType}`);
},
resetListView() {
this.images = [];
this.hasMore = false;
page = 1;
},
searchImages() {
if (this.searchTerm === '') return this.cancelSearch();
// Cache current settings for later
if (!this.searching) {
preSearchImages = this.images;
preSearchHasMore = this.hasMore;
}
this.searching = true;
this.resetListView();
this.fetchData();
},
cancelSearch() {
if (!this.searching) return;
this.searching = false;
this.searchTerm = '';
this.images = preSearchImages;
this.hasMore = preSearchHasMore;
},
imageSelect(image) {
const dblClickTime = 300;
const currentTime = Date.now();
const timeDiff = currentTime - previousClickTime;
const isDblClick = timeDiff < dblClickTime && image.id === previousClickImage;
if (isDblClick) {
this.callbackAndHide(image);
} else {
this.selectedImage = image;
this.deleteConfirm = false;
this.dependantPages = false;
}
previousClickTime = currentTime;
previousClickImage = image.id;
},
callbackAndHide(imageResult) {
if (callback) callback(imageResult);
this.hide();
},
async saveImageDetails() {
let url = window.baseUrl(`/images/${this.selectedImage.id}`);
try {
await this.$http.put(url, this.selectedImage)
} catch (error) {
if (error.response.status === 422) {
let errors = error.response.data;
let message = '';
Object.keys(errors).forEach((key) => {
message += errors[key].join('\n');
});
this.$events.emit('error', message);
}
}
},
async deleteImage() {
if (!this.deleteConfirm) {
const url = window.baseUrl(`/images/usage/${this.selectedImage.id}`);
try {
const {data} = await this.$http.get(url);
this.dependantPages = data;
} catch (error) {
console.error(error);
}
this.deleteConfirm = true;
return;
}
const url = window.baseUrl(`/images/${this.selectedImage.id}`);
await this.$http.delete(url);
this.images.splice(this.images.indexOf(this.selectedImage), 1);
this.selectedImage = false;
this.$events.emit('success', trans('components.image_delete_success'));
this.deleteConfirm = false;
},
getDate(stringDate) {
return Dates.formatDateTime(new Date(stringDate));
},
uploadSuccess(event) {
this.images.unshift(event.data);
this.$events.emit('success', trans('components.image_upload_success'));
},
};
const computed = {
uploadUrl() {
return window.baseUrl(`/images/${this.imageType}`);
}
};
function mounted() {
window.ImageManager = this;
this.imageType = this.$el.getAttribute('image-type');
this.uploadedTo = this.$el.getAttribute('uploaded-to');
baseUrl = window.baseUrl('/images/' + this.imageType)
}
export default {
mounted,
methods,
data,
computed,
components: {dropzone},
};

View File

@ -4,10 +4,7 @@ function exists(id) {
return document.getElementById(id) !== null;
}
import imageManager from "./image-manager";
let vueMapping = {
'image-manager': imageManager,
};
window.vues = {};

View File

@ -33,6 +33,7 @@ return [
'copy' => 'Copy',
'reply' => 'Reply',
'delete' => 'Delete',
'delete_confirm' => 'Confirm Deletion',
'search' => 'Search',
'search_clear' => 'Clear Search',
'reset' => 'Reset',

View File

@ -15,7 +15,7 @@ return [
'image_load_more' => 'Load More',
'image_image_name' => 'Image Name',
'image_delete_used' => 'This image is used in the pages below.',
'image_delete_confirm' => 'Click delete again to confirm you want to delete this image.',
'image_delete_confirm_text' => 'Are you sure you want to delete this image?',
'image_select_image' => 'Select Image',
'image_dropzone' => 'Drop images or click here to upload',
'images_deleted' => 'Images Deleted',

View File

@ -51,6 +51,11 @@
fill: currentColor !important;
}
.text-white {
color: #fff;
fill: currentColor !important;
}
/*
* Entity text colors
*/

View File

@ -197,11 +197,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
transition: all cubic-bezier(.4, 0, 1, 1) 160ms;
overflow: hidden;
&.selected {
//transform: scale3d(0.92, 0.92, 0.92);
border: 4px solid #FFF;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
transform: scale3d(0.92, 0.92, 0.92);
outline: currentColor 2px solid;
}
img {
width: 100%;
@ -231,7 +228,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
}
}
#image-manager .load-more {
.image-manager .load-more {
display: block;
text-align: center;
@include lightDark(background-color, #EEE, #444);
@ -243,6 +240,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
font-style: italic;
}
.image-manager .loading-container {
text-align: center;
}
.image-manager-sidebar {
width: 300px;
overflow-y: auto;
@ -250,6 +251,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
border-inline-start: 1px solid #DDD;
@include lightDark(border-color, #ddd, #000);
.inner {
min-height: auto;
padding: $-m;
}
img {
@ -291,6 +293,12 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
}
}
.image-manager .corner-button {
margin: 0;
border-radius: 0;
padding: $-m;
}
// Dropzone
/*
* The MIT License
@ -298,7 +306,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
*/
.dz-message {
font-size: 1em;
line-height: 2.35;
line-height: 2.85;
font-style: italic;
color: #888;
text-align: center;
@ -601,9 +609,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
display: inline-block;
@include lightDark(color, #666, #999);
cursor: pointer;
border-right: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 2px solid transparent;
&.selected {
border-bottom: 2px solid var(--color-primary);
}
&:last-child {
border-right: 0;
}
}
}

View File

@ -121,6 +121,11 @@ body.flexbox {
position: relative;
}
.flex-container-column {
display: flex;
flex-direction: column;
}
.flex {
min-height: 0;
flex: 1;

View File

@ -140,8 +140,10 @@ $btt-size: 40px;
.contained-search-box {
display: flex;
height: 38px;
input, button {
border-radius: 0;
border: 1px solid #ddd;
@include lightDark(border-color, #ddd, #000);
margin-inline-start: -1px;
}
@ -162,6 +164,9 @@ $btt-size: 40px;
background-color: $negative;
color: #EEE;
}
svg {
margin: 0;
}
}
.entity-selector {

View File

@ -0,0 +1,60 @@
<div class="image-manager-details">
<form component="ajax-form"
option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
option:ajax-form:method="put"
option:ajax-form:response-container=".image-manager-details"
option:ajax-form:url="{{ url('images/' . $image->id) }}">
<div class="image-manager-viewer">
<a href="{{ $image->url }}" target="_blank" class="block">
<img src="{{ $image->thumbs['display'] }}"
alt="{{ $image->name }}"
class="anim fadeIn"
title="{{ $image->name }}">
</a>
</div>
<div class="form-group stretch-inputs">
<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>
<button type="button"
id="image-manager-delete"
title="{{ trans('common.delete') }}"
class="button icon outline">@icon('delete')</button>
</div>
<div class="text-right">
<button type="submit"
class="button icon outline">{{ trans('common.save') }}</button>
</div>
</div>
</form>
@if(!is_null($dependantPages))
@if(count($dependantPages) > 0)
<p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
<ul class="text-neg">
@foreach($dependantPages as $page)
<li>
<a href="{{ $page->url }}"
target="_blank"
class="text-neg">{{ $page->name }}</a>
</li>
@endforeach
</ul>
@endif
<p class="text-neg mb-xs">{{ trans('components.image_delete_confirm_text') }}</p>
<form component="ajax-form"
option:ajax-form:success-message="{{ trans('components.image_delete_success') }}"
option:ajax-form:method="delete"
option:ajax-form:response-container=".image-manager-details"
option:ajax-form:url="{{ url('images/' . $image->id) }}">
<button type="submit" class="button neg">
{{ trans('common.delete_confirm') }}
</button>
</form>
@endif
</div>

View File

@ -0,0 +1,23 @@
@foreach($images as $index => $image)
<div>
<div component="event-emit-select"
option:event-emit-select:name="image"
option:event-emit-select:data="{{ json_encode($image) }}"
class="image anim fadeIn text-primary"
style="animation-delay: {{ $index > 26 ? '160ms' : ($index * 25) . 'ms' }};">
<img src="{{ $image->thumbs['gallery'] }}"
alt="{{ $image->name }}"
width="150"
height="150"
loading="lazy"
title="{{ $image->name }}">
<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>
</div>
</div>
</div>
@endforeach
@if($hasMore)
<div class="load-more">{{ trans('components.image_load_more') }}</div>
@endif

View File

@ -1,101 +1,62 @@
<div id="image-manager" image-type="{{ $imageType }}" uploaded-to="{{ $uploaded_to ?? 0 }}">
<div component="image-manager"
option:image-manager:uploaded-to="{{ $uploaded_to ?? 0 }}"
class="image-manager">
@exposeTranslations([
'components.image_delete_success',
'components.image_upload_success',
'errors.server_upload_limit',
'components.image_upload_remove',
'components.file_upload_timeout',
])
<div component="popup" class="popup-background" v-cloak @click="hide">
<div class="popup-body" tabindex="-1" @click.stop>
<div component="popup"
refs="image-manager@popup"
class="popup-background">
<div class="popup-body" tabindex="-1">
<div class="popup-header primary-background">
<div class="popup-title">{{ trans('components.image_select') }}</div>
<button class="popup-header-close" @click="hide()">x</button>
<button refs="popup@hide" type="button" class="popup-header-close">x</button>
</div>
<div class="flex-fill image-manager-body">
<div class="image-manager-content">
<div v-if="imageType === 'gallery' || imageType === 'drawio'" class="image-manager-header primary-background-light nav-tabs grid third">
<div class="tab-item" title="{{ trans('components.image_all_title') }}" :class="{selected: !filter}" @click="setFilterType(null)">@icon('images') {{ trans('components.image_all') }}</div>
<div class="tab-item" title="{{ trans('components.image_book_title') }}" :class="{selected: (filter=='book')}" @click="setFilterType('book')">@icon('book', ['class' => 'text-book svg-icon']) {{ trans('entities.book') }}</div>
<div class="tab-item" title="{{ trans('components.image_page_title') }}" :class="{selected: (filter=='page')}" @click="setFilterType('page')">@icon('page', ['class' => 'text-page svg-icon']) {{ trans('entities.page') }}</div>
<div class="image-manager-header primary-background-light nav-tabs grid third no-gap">
<button refs="image-manager@filterTabs"
data-filter="all"
type="button" class="tab-item selected" title="{{ trans('components.image_all_title') }}">@icon('images') {{ trans('components.image_all') }}</button>
<button refs="image-manager@filterTabs"
data-filter="book"
type="button" class="tab-item" title="{{ trans('components.image_book_title') }}">@icon('book', ['class' => 'text-book svg-icon']) {{ trans('entities.book') }}</button>
<button refs="image-manager@filterTabs"
data-filter="page"
type="button" class="tab-item" title="{{ trans('components.image_page_title') }}">@icon('page', ['class' => 'text-page svg-icon']) {{ trans('entities.page') }}</button>
</div>
<div>
<form @submit.prevent="searchImages" class="contained-search-box">
<input placeholder="{{ trans('components.image_search_hint') }}" v-model="searchTerm" type="text">
<button :class="{active: searching}" title="{{ trans('common.search_clear') }}" type="button" @click="cancelSearch()" class="text-button cancel">@icon('close')</button>
<button title="{{ trans('common.search') }}" class="text-button">@icon('search')</button>
<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 class="image-manager-list">
<div v-if="images.length > 0" v-for="(image, idx) in images">
<div class="image anim fadeIn" :style="{animationDelay: (idx > 26) ? '160ms' : ((idx * 25) + 'ms')}"
:class="{selected: (image==selectedImage)}" @click="imageSelect(image)">
<img :src="image.thumbs.gallery" :alt="image.title" :title="image.name">
<div class="image-meta">
<span class="name" v-text="image.name"></span>
<span class="date">{{ trans('components.image_uploaded', ['uploadedDate' => "{{ getDate(image.created_at) }" . "}"]) }}</span>
</div>
</div>
</div>
<div class="load-more" v-show="hasMore" @click="fetchData">{{ trans('components.image_load_more') }}</div>
</div>
<div refs="image-manager@listContainer" class="image-manager-list"></div>
</div>
<div class="image-manager-sidebar">
<dropzone v-if="imageType !== 'drawio'" ref="dropzone" placeholder="{{ trans('components.image_dropzone') }}" :upload-url="uploadUrl" :uploaded-to="uploadedTo" @success="uploadSuccess"></dropzone>
<div class="inner">
<div class="image-manager-details anim fadeIn" v-if="selectedImage">
<form @submit.prevent="saveImageDetails">
<div class="image-manager-viewer">
<a :href="selectedImage.url" target="_blank" style="display: block;">
<img :src="selectedImage.thumbs.display" :alt="selectedImage.name"
:title="selectedImage.name">
</a>
</div>
<div class="form-group">
<label for="name">{{ trans('components.image_image_name') }}</label>
<input id="name" class="input-base" name="name" v-model="selectedImage.name">
</div>
</form>
<div class="clearfix">
<div class="float left">
<button type="button" class="button icon outline" @click="deleteImage">@icon('delete')</button>
</div>
<button class="button anim fadeIn float right" v-show="selectedImage" @click="callbackAndHide(selectedImage)">
{{ trans('components.image_select_image') }}
</button>
<div class="clearfix"></div>
<div v-show="dependantPages">
<p class="text-neg text-small">
{{ trans('components.image_delete_used') }}
</p>
<ul class="text-neg">
<li v-for="page in dependantPages">
<a :href="page.url" target="_blank" class="text-neg" v-text="page.name"></a>
</li>
</ul>
</div>
<div v-show="deleteConfirm" class="text-neg text-small">
{{ trans('components.image_delete_confirm') }}
</div>
</div>
</div>
<div class="image-manager-sidebar flex-container-column">
<div refs="image-manager@dropzoneContainer">
@include('components.dropzone', [
'placeholder' => trans('components.image_dropzone'),
'successMessage' => trans('components.image_upload_success'),
'url' => url('/images/gallery?' . http_build_query(['uploaded_to' => $uploaded_to ?? 0]))
])
</div>
<div refs="image-manager@formContainer" class="inner flex"></div>
<button refs="image-manager@selectButton" type="button" class="hidden button corner-button">
{{ trans('components.image_select_image') }}
</button>
</div>
</div>

View File

@ -20,8 +20,7 @@
</form>
</div>
@include('components.image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id])
@include('components.image-manager', ['uploaded_to' => $page->id])
@include('components.code-editor')
@include('components.entity-selector-popup')
@stop

View File

@ -275,6 +275,5 @@
</div>
@include('components.image-manager', ['imageType' => 'system'])
@include('components.entity-selector-popup', ['entityTypes' => 'page'])
@stop

View File

@ -101,22 +101,14 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/user/{userId}', 'UserController@showProfilePage');
// Image routes
Route::group(['prefix' => 'images'], function () {
// Gallery
Route::get('/gallery', 'Images\GalleryImageController@list');
Route::post('/gallery', 'Images\GalleryImageController@create');
// Drawio
Route::get('/drawio', 'Images\DrawioImageController@list');
Route::get('/drawio/base64/{id}', 'Images\DrawioImageController@getAsBase64');
Route::post('/drawio', 'Images\DrawioImageController@create');
// Shared gallery & draw.io endpoint
Route::get('/usage/{id}', 'Images\ImageController@usage');
Route::put('/{id}', 'Images\ImageController@update');
Route::delete('/{id}', 'Images\ImageController@destroy');
});
Route::get('/images/gallery', 'Images\GalleryImageController@list');
Route::post('/images/gallery', 'Images\GalleryImageController@create');
Route::get('/images/drawio', 'Images\DrawioImageController@list');
Route::get('/images/drawio/base64/{id}', 'Images\DrawioImageController@getAsBase64');
Route::post('/images/drawio', 'Images\DrawioImageController@create');
Route::get('/images/edit/{id}', 'Images\ImageController@edit');
Route::put('/images/{id}', 'Images\ImageController@update');
Route::delete('/images/{id}', 'Images\ImageController@destroy');
// Attachments routes
Route::get('/attachments/{id}', 'AttachmentController@get');

View File

@ -71,11 +71,7 @@ class ImageTest extends TestCase
$newName = Str::random();
$update = $this->put('/images/' . $image->id, ['name' => $newName]);
$update->assertSuccessful();
$update->assertJson([
'id' => $image->id,
'name' => $newName,
'type' => 'gallery',
]);
$update->assertSee($newName);
$this->deleteImage($imgDetails['path']);
@ -92,31 +88,22 @@ class ImageTest extends TestCase
$imgDetails = $this->uploadGalleryImage();
$image = Image::query()->first();
$emptyJson = ['images' => [], 'has_more' => false];
$resultJson = [
'images' => [
[
'id' => $image->id,
'name' => $imgDetails['name'],
]
],
'has_more' => false,
];
$pageId = $imgDetails['page']->id;
$firstPageRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}");
$firstPageRequest->assertSuccessful()->assertJson($resultJson);
$firstPageRequest->assertSuccessful()->assertElementExists('div');
$firstPageRequest->assertSuccessful()->assertSeeText($image->name);
$secondPageRequest = $this->get("/images/gallery?page=2&uploaded_to={$pageId}");
$secondPageRequest->assertSuccessful()->assertExactJson($emptyJson);
$secondPageRequest->assertSuccessful()->assertElementNotExists('div');
$namePartial = substr($imgDetails['name'], 0, 3);
$searchHitRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}");
$searchHitRequest->assertSuccessful()->assertJson($resultJson);
$searchHitRequest->assertSuccessful()->assertSee($imgDetails['name']);
$namePartial = Str::random(16);
$searchHitRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}");
$searchHitRequest->assertSuccessful()->assertExactJson($emptyJson);
$searchFailRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}");
$searchFailRequest->assertSuccessful()->assertDontSee($imgDetails['name']);
$searchFailRequest->assertSuccessful()->assertElementNotExists('div');
}
public function test_image_usage()
@ -131,14 +118,10 @@ class ImageTest extends TestCase
$page->html = '<img src="'.$image->url.'">';
$page->save();
$usage = $this->get('/images/usage/' . $image->id);
$usage = $this->get('/images/edit/' . $image->id . '?delete=true');
$usage->assertSuccessful();
$usage->assertJson([
[
'id' => $page->id,
'name' => $page->name
]
]);
$usage->assertSeeText($page->name);
$usage->assertSee($page->getUrl());
$this->deleteImage($imgDetails['path']);
}