return response()->json($docs);
}
+
+ /**
+ * Redirect to the API docs page.
+ */
+ public function redirect()
+ {
+ return redirect('/api/docs');
+ }
}
$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'],
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
+ ) {
}
/**
]);
}
+ /**
+ * 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.
*
],
'update' => [
'name' => ['string', 'max:180'],
+ 'image' => ['file', ...$this->getImageValidationRules()],
]
];
}
/**
* 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)
{
$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));
}
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
+ ) {
}
/**
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.
*
/**
* 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),
]);
}
* 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;
}
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.
* @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)) {
// 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);
// Buttons
'cancel' => 'Cancel',
+ 'close' => 'Close',
'confirm' => 'Confirm',
'back' => 'Back',
'save' => 'Save',
// 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.',
'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.',
'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',
// 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
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);
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, {
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';
}
setupListeners() {
+ // Filter tab click
onSelect(this.filterTabs, e => {
this.resetAll();
this.filter = e.target.dataset.filter;
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);
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') {
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);
resetSearchView() {
this.searchInput.value = '';
+ this.cancelSearch.toggleAttribute('hidden', true);
}
resetEditForm() {
window.$components.init(this.formContainer);
}
+ runLoadMore() {
+ showLoading(this.loadMore);
+ this.page += 1;
+ this.loadGallery();
+ }
+
+ canLoadMore() {
+ return this.loadMore.querySelector('button') && !this.loadMore.hasAttribute('hidden');
+ }
+
}
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) {
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);
+ }
+
}
.anim.fadeIn {
opacity: 0;
animation-name: fadeIn;
- animation-duration: 180ms;
+ animation-duration: 120ms;
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
}
flex: 1;
}
-.image-manager-body {
- min-height: 70vh;
-}
-
.dropzone-overlay {
position: absolute;
display: flex;
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 {
}
.image-manager .load-more {
- display: block;
text-align: center;
padding: $-s $-m;
clear: both;
.image-manager-sidebar {
width: 300px;
+ margin: 0 auto;
overflow-y: auto;
overflow-x: hidden;
border-inline-start: 1px solid #DDD;
}
}
}
-
-.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%;
}
}
}
-.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;
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));
&[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;
}
}
+.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;
}
}
+[hidden] {
+ display: none !important;
+}
+
/**
* Border radiuses
*/
text-align: start !important;
max-height: 500px;
overflow-y: auto;
+ &.anchor-left {
+ inset-inline-end: auto;
+ inset-inline-start: 0;
+ }
&.wide {
min-width: 220px;
}
}
}
-.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);
-<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') }}"
<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">
</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>
\ No newline at end of file
@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>
<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>
+ 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 refs="image-manager@listContainer" class="image-manager-list"></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 class="image-manager-sidebar flex-container-column">
+ <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@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@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 refs="image-manager@formContainer" class="inner flex">
+ </div>
</div>
</div>
-
</div>
<div class="popup-footer">
->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']);
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']);
]);
}
- 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);
]);
}
+ 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();
$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);
}