Initial commit
This commit is contained in:
498
resources/views/livewire/posts/backup-restore.blade.php
Normal file
498
resources/views/livewire/posts/backup-restore.blade.php
Normal file
@@ -0,0 +1,498 @@
|
||||
<div class="mb-6 flex items-center gap-3" x-data="backupRestoreUploader()">
|
||||
<!-- Backup All Button -->
|
||||
<x-jetstream.secondary-button wire:click="backup" wire:loading.attr="disabled">
|
||||
<x-icon class="mr-2 h-4 w-4" name="archive-box" />
|
||||
<span wire:loading.remove wire:target="backup">{{ __('Backup all') }}</span>
|
||||
<span wire:loading wire:target="backup">{{ __('Creating backup...') }}</span>
|
||||
</x-jetstream.secondary-button>
|
||||
|
||||
<!-- Backup Selected Button (optional) -->
|
||||
@if ($showBackupSelected)
|
||||
<x-jetstream.secondary-button
|
||||
wire:click="backupSelected"
|
||||
wire:loading.attr="disabled"
|
||||
:disabled="empty($selectedTranslationIds)"
|
||||
class="{{ empty($selectedTranslationIds) ? 'opacity-50 cursor-not-allowed' : '' }}">
|
||||
<x-icon class="mr-2 h-4 w-4" name="arrow-down-tray" />
|
||||
<span wire:loading.remove wire:target="backupSelected">
|
||||
{{ __('Backup selected') }}
|
||||
@if (!empty($selectedTranslationIds))
|
||||
({{ count($selectedTranslationIds) }})
|
||||
@endif
|
||||
</span>
|
||||
<span wire:loading wire:target="backupSelected">{{ __('Creating backup...') }}</span>
|
||||
</x-jetstream.secondary-button>
|
||||
@endif
|
||||
|
||||
<!-- Restore Button -->
|
||||
<x-jetstream.secondary-button wire:click="openRestoreModal">
|
||||
<x-icon class="mr-2 h-4 w-4" name="arrow-up-tray" />
|
||||
{{ __('Restore') }}
|
||||
</x-jetstream.secondary-button>
|
||||
|
||||
<!-- Restore Modal -->
|
||||
<x-jetstream.dialog-modal wire:model.live="showRestoreModal" maxWidth="2xl">
|
||||
<x-slot name="title">
|
||||
{{ __('Restore Posts') }}
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
@if (empty($restoreStats))
|
||||
<!-- File Upload -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{{ __('Select backup file') }}
|
||||
</label>
|
||||
<input type="file"
|
||||
x-ref="fileInput"
|
||||
x-on:change="handleFileSelected($event)"
|
||||
accept=".json,.zip"
|
||||
:disabled="parsing || uploading"
|
||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-theme-brand file:text-white hover:file:bg-opacity-80">
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ __('Accepts ZIP archives (with media) or JSON files (without media)') }}
|
||||
</p>
|
||||
@error('restoreFile')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
<!-- Parsing indicator -->
|
||||
<div x-show="parsing" x-cloak class="mt-2 text-sm text-gray-500">
|
||||
{{ __('Reading file...') }}
|
||||
</div>
|
||||
|
||||
<!-- Parse error -->
|
||||
<template x-if="parseError">
|
||||
<p class="mt-1 text-sm text-red-600" x-text="parseError"></p>
|
||||
</template>
|
||||
|
||||
<!-- Upload progress bar -->
|
||||
<div x-show="uploading" x-cloak class="mt-3">
|
||||
<div class="flex items-center justify-between text-sm text-gray-600 mb-1">
|
||||
<span>{{ __('Uploading...') }}</span>
|
||||
<span x-text="uploadProgress + '%'"></span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-theme-brand h-2 rounded-full transition-all duration-300"
|
||||
:style="'width: ' + uploadProgress + '%'"></div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500" x-text="uploadStatus"></p>
|
||||
</div>
|
||||
|
||||
<!-- Upload error -->
|
||||
<template x-if="uploadError">
|
||||
<p class="mt-2 text-sm text-red-600" x-text="uploadError"></p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
@if (!empty($restorePreview))
|
||||
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-semibold text-gray-700 mb-3">{{ __('Backup file info') }}</h4>
|
||||
<dl class="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt class="text-gray-500">{{ __('Source') }}:</dt>
|
||||
<dd class="text-gray-900">{{ $restorePreview['source_database'] }}</dd>
|
||||
|
||||
<dt class="text-gray-500">{{ __('Created') }}:</dt>
|
||||
<dd class="text-gray-900">{{ \Carbon\Carbon::parse($restorePreview['created_at'])->format('Y-m-d H:i') }}</dd>
|
||||
|
||||
<dt class="text-gray-500">{{ __('Posts') }}:</dt>
|
||||
<dd class="text-gray-900">{{ $restorePreview['posts'] }}</dd>
|
||||
|
||||
<dt class="text-gray-500">{{ __('Translations') }}:</dt>
|
||||
<dd class="text-gray-900">{{ $restorePreview['translations'] }}</dd>
|
||||
|
||||
<dt class="text-gray-500">{{ __('Meetings') }}:</dt>
|
||||
<dd class="text-gray-900">{{ $restorePreview['meetings'] }}</dd>
|
||||
|
||||
@if (!empty($restorePreview['includes_media']))
|
||||
<dt class="text-gray-500">{{ __('Media files') }}:</dt>
|
||||
<dd class="text-gray-900">{{ $restorePreview['media_files'] ?? 0 }}</dd>
|
||||
@endif
|
||||
|
||||
<dt class="text-gray-500">{{ __('Format') }}:</dt>
|
||||
<dd class="text-gray-900">
|
||||
@if (!empty($restorePreview['is_zip']))
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
ZIP {{ __('with media') }}
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
|
||||
JSON {{ __('without media') }}
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@if ($restorePreview['duplicates'] > 0)
|
||||
<div class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div class="flex items-start">
|
||||
<x-icon name="exclamation-triangle" class="h-5 w-5 text-yellow-600 mr-2 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800">
|
||||
{{ __(':count duplicate slug(s) found', ['count' => $restorePreview['duplicates']]) }}
|
||||
</p>
|
||||
@if (!empty($restorePreview['duplicate_slugs']))
|
||||
<p class="mt-1 text-xs text-yellow-700">
|
||||
{{ implode(', ', $restorePreview['duplicate_slugs']) }}
|
||||
@if ($restorePreview['duplicates'] > 10)
|
||||
...
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="block text-sm font-medium text-yellow-800 mb-2">
|
||||
{{ __('How to handle duplicates?') }}
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="radio" wire:model="duplicateAction" value="skip"
|
||||
class="text-theme-brand focus:ring-theme-brand">
|
||||
<span class="ml-2 text-sm text-yellow-800">{{ __('Skip duplicates') }}</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio" wire:model="duplicateAction" value="overwrite"
|
||||
class="text-theme-brand focus:ring-theme-brand">
|
||||
<span class="ml-2 text-sm text-yellow-800">{{ __('Overwrite existing') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Post selection list --}}
|
||||
@if (!empty($restorePostList))
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-semibold text-gray-700">{{ __('Select posts to restore') }}</h4>
|
||||
<label class="flex items-center space-x-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
wire:model.live="selectAllPosts"
|
||||
wire:change="toggleSelectAll"
|
||||
class="rounded border-gray-300 text-theme-brand focus:ring-theme-brand">
|
||||
<span>{{ __('Select all') }} ({{ count($selectedPostIndices) }}/{{ count($restorePostList) }})</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto border border-gray-200 rounded-lg">
|
||||
@foreach ($restorePostList as $postItem)
|
||||
<label class="flex items-start px-4 py-3 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0">
|
||||
<input type="checkbox"
|
||||
wire:model.live="selectedPostIndices"
|
||||
value="{{ $postItem['index'] }}"
|
||||
class="mt-0.5 rounded border-gray-300 text-theme-brand focus:ring-theme-brand">
|
||||
<div class="ml-3 min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-gray-900 truncate">
|
||||
{{ $postItem['title'] }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 flex items-center flex-wrap gap-x-3 gap-y-1 mt-1">
|
||||
<span class="truncate">{{ $postItem['slug'] }}</span>
|
||||
<span>{{ implode(', ', $postItem['locales']) }}</span>
|
||||
@if ($postItem['has_meeting'])
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-700">
|
||||
{{ __('Meeting') }}
|
||||
</span>
|
||||
@endif
|
||||
@if ($postItem['has_media'])
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700">
|
||||
{{ __('Media') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ __('Posts will be assigned to your current active profile.') }}
|
||||
</p>
|
||||
@endif
|
||||
@else
|
||||
<!-- Restore Results -->
|
||||
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center mb-3">
|
||||
<x-icon name="check-circle" class="h-6 w-6 text-green-600 mr-2" />
|
||||
<h4 class="font-semibold text-green-800">{{ __('Restore completed') }}</h4>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt class="text-green-700">{{ __('Posts created') }}:</dt>
|
||||
<dd class="text-green-900 font-medium">{{ $restoreStats['posts_created'] ?? 0 }}</dd>
|
||||
|
||||
@if (($restoreStats['posts_skipped'] ?? 0) > 0)
|
||||
<dt class="text-yellow-700">{{ __('Posts skipped') }}:</dt>
|
||||
<dd class="text-yellow-900 font-medium">{{ $restoreStats['posts_skipped'] }}</dd>
|
||||
@endif
|
||||
|
||||
@if (($restoreStats['posts_overwritten'] ?? 0) > 0)
|
||||
<dt class="text-orange-700">{{ __('Posts overwritten') }}:</dt>
|
||||
<dd class="text-orange-900 font-medium">{{ $restoreStats['posts_overwritten'] }}</dd>
|
||||
@endif
|
||||
|
||||
<dt class="text-green-700">{{ __('Translations created') }}:</dt>
|
||||
<dd class="text-green-900 font-medium">{{ $restoreStats['translations_created'] ?? 0 }}</dd>
|
||||
|
||||
<dt class="text-green-700">{{ __('Meetings created') }}:</dt>
|
||||
<dd class="text-green-900 font-medium">{{ $restoreStats['meetings_created'] ?? 0 }}</dd>
|
||||
|
||||
@if (($restoreStats['media_restored'] ?? 0) > 0)
|
||||
<dt class="text-green-700">{{ __('Media restored') }}:</dt>
|
||||
<dd class="text-green-900 font-medium">{{ $restoreStats['media_restored'] }}</dd>
|
||||
@endif
|
||||
|
||||
@if (($restoreStats['media_skipped'] ?? 0) > 0)
|
||||
<dt class="text-yellow-700">{{ __('Media skipped') }}:</dt>
|
||||
<dd class="text-yellow-900 font-medium">{{ $restoreStats['media_skipped'] }}</dd>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
@endif
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="footer">
|
||||
<x-jetstream.secondary-button wire:click="closeRestoreModal">
|
||||
{{ empty($restoreStats) ? __('Cancel') : __('Close') }}
|
||||
</x-jetstream.secondary-button>
|
||||
|
||||
@if (empty($restoreStats) && !empty($restorePreview))
|
||||
<x-jetstream.button
|
||||
class="ml-3"
|
||||
x-on:click="startChunkedUpload()"
|
||||
x-bind:disabled="uploading || restoring"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="restore"
|
||||
:disabled="$isRestoring || empty($selectedPostIndices)">
|
||||
<span x-show="!uploading && !restoring" wire:loading.remove wire:target="restore">
|
||||
{{ __('Restore') }} ({{ count($selectedPostIndices) }})
|
||||
</span>
|
||||
<span x-show="uploading" x-cloak>{{ __('Uploading...') }}</span>
|
||||
<span x-show="restoring" x-cloak wire:loading wire:target="restore">{{ __('Restoring...') }}</span>
|
||||
</x-jetstream.button>
|
||||
@endif
|
||||
</x-slot>
|
||||
</x-jetstream.dialog-modal>
|
||||
</div>
|
||||
|
||||
@script
|
||||
<script>
|
||||
Alpine.data('backupRestoreUploader', () => ({
|
||||
file: null,
|
||||
isZip: false,
|
||||
parsing: false,
|
||||
parseError: null,
|
||||
uploading: false,
|
||||
uploadProgress: 0,
|
||||
uploadStatus: '',
|
||||
uploadError: null,
|
||||
restoring: false,
|
||||
|
||||
resetState() {
|
||||
this.file = null;
|
||||
this.isZip = false;
|
||||
this.parsing = false;
|
||||
this.parseError = null;
|
||||
this.uploading = false;
|
||||
this.uploadProgress = 0;
|
||||
this.uploadStatus = '';
|
||||
this.uploadError = null;
|
||||
this.restoring = false;
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = '';
|
||||
}
|
||||
},
|
||||
|
||||
async handleFileSelected(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
this.file = file;
|
||||
this.parseError = null;
|
||||
this.uploadError = null;
|
||||
this.parsing = true;
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (ext !== 'json' && ext !== 'zip') {
|
||||
this.parseError = '{{ __("Only .json and .zip files are accepted") }}';
|
||||
this.parsing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isZip = ext === 'zip';
|
||||
|
||||
try {
|
||||
let jsonString;
|
||||
|
||||
if (this.isZip) {
|
||||
// Use JSZip to extract backup.json client-side
|
||||
const JSZip = await window.loadJSZip();
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const backupEntry = zip.file('backup.json');
|
||||
|
||||
if (!backupEntry) {
|
||||
this.parseError = '{{ __("Invalid ZIP archive: missing backup.json") }}';
|
||||
this.parsing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
jsonString = await backupEntry.async('string');
|
||||
} else {
|
||||
// Read JSON file directly
|
||||
jsonString = await file.text();
|
||||
}
|
||||
|
||||
// Parse JSON client-side and extract only lightweight summary data
|
||||
const data = JSON.parse(jsonString);
|
||||
|
||||
if (!data.meta || !data.posts) {
|
||||
this.parseError = '{{ __("Invalid backup file format") }}';
|
||||
this.parsing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseLocale = '{{ config("app.locale") }}';
|
||||
const postSummaries = data.posts.map((post, index) => {
|
||||
let title = null;
|
||||
let slug = null;
|
||||
const locales = [];
|
||||
const slugs = [];
|
||||
|
||||
(post.translations || []).forEach(t => {
|
||||
locales.push(t.locale);
|
||||
slugs.push(t.slug);
|
||||
if (t.locale === baseLocale) {
|
||||
title = t.title;
|
||||
slug = t.slug;
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback to first translation
|
||||
if (title === null && post.translations && post.translations.length > 0) {
|
||||
title = post.translations[0].title;
|
||||
slug = post.translations[0].slug;
|
||||
}
|
||||
|
||||
return {
|
||||
index: index,
|
||||
title: title || '{{ __("Untitled") }}',
|
||||
slug: slug || '',
|
||||
locales: locales,
|
||||
slugs: slugs,
|
||||
has_meeting: !!post.meeting,
|
||||
has_media: !!post.media,
|
||||
category_type: post.category_type || null,
|
||||
};
|
||||
});
|
||||
|
||||
// Send only meta + lightweight summaries to Livewire (a few KB, not MB)
|
||||
await $wire.parseBackupPreview(data.meta, postSummaries, file.name, this.isZip);
|
||||
|
||||
} catch (e) {
|
||||
this.parseError = '{{ __("Error reading file: ") }}' + e.message;
|
||||
}
|
||||
|
||||
this.parsing = false;
|
||||
},
|
||||
|
||||
async startChunkedUpload() {
|
||||
if (!this.file || this.uploading || this.restoring) return;
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadError = null;
|
||||
this.uploadProgress = 0;
|
||||
|
||||
const chunkSize = 2 * 1024 * 1024; // 2MB chunks
|
||||
const totalChunks = Math.ceil(this.file.size / chunkSize);
|
||||
const uploadId = crypto.randomUUID();
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
|
||||
try {
|
||||
// Upload chunks sequentially
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, this.file.size);
|
||||
const chunk = this.file.slice(start, end);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('uploadId', uploadId);
|
||||
formData.append('chunkIndex', i);
|
||||
formData.append('totalChunks', totalChunks);
|
||||
formData.append('chunk', chunk, 'chunk');
|
||||
|
||||
this.uploadStatus = `{{ __("Chunk") }} ${i + 1} / ${totalChunks}`;
|
||||
|
||||
const response = await window.axios.post(
|
||||
'{{ route("posts.backup-upload-chunk") }}',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Chunk upload failed');
|
||||
}
|
||||
|
||||
this.uploadProgress = Math.round(((i + 1) / totalChunks) * 90);
|
||||
}
|
||||
|
||||
// Finalize - reassemble chunks on server
|
||||
this.uploadStatus = '{{ __("Finalizing...") }}';
|
||||
const finalizeResponse = await window.axios.post(
|
||||
'{{ route("posts.backup-upload-finalize") }}',
|
||||
{
|
||||
uploadId: uploadId,
|
||||
totalChunks: totalChunks,
|
||||
fileName: this.file.name,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!finalizeResponse.data.success) {
|
||||
throw new Error(finalizeResponse.data.error || 'Finalization failed');
|
||||
}
|
||||
|
||||
this.uploadProgress = 95;
|
||||
this.uploadStatus = '{{ __("Restoring posts...") }}';
|
||||
|
||||
// Tell Livewire to pick up the assembled file
|
||||
await $wire.setUploadedFilePath(uploadId);
|
||||
|
||||
this.uploading = false;
|
||||
this.restoring = true;
|
||||
|
||||
// Trigger the actual restore
|
||||
await $wire.restore();
|
||||
|
||||
this.restoring = false;
|
||||
this.uploadProgress = 100;
|
||||
|
||||
} catch (e) {
|
||||
this.uploading = false;
|
||||
this.restoring = false;
|
||||
|
||||
const msg = e.response?.data?.error || e.response?.data?.message || e.message;
|
||||
this.uploadError = '{{ __("Upload failed: ") }}' + msg;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
$wire.on('backup-ready', ({ filename }) => {
|
||||
// Redirect browser to the dedicated download route (bypasses Livewire response buffering)
|
||||
window.location.href = '{{ route("posts.backup-download", ["filename" => "__FILENAME__"]) }}'.replace('__FILENAME__', filename);
|
||||
});
|
||||
</script>
|
||||
@endscript
|
||||
7
resources/views/livewire/posts/create.blade.php
Normal file
7
resources/views/livewire/posts/create.blade.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<x-jetstream.button wire:click.prevent="$dispatch('openCreateModal')" class="bg-theme-brand hover:bg-opacity-80">
|
||||
{{ __('New post') }}
|
||||
</x-jetstream.button>
|
||||
</div>
|
||||
</div>
|
||||
19
resources/views/livewire/posts/manage-actions.blade.php
Normal file
19
resources/views/livewire/posts/manage-actions.blade.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<div>
|
||||
@php
|
||||
$translation = $post->translations->first();
|
||||
$isPublished = $translation ? ($translation->from <= now() && ($translation->till === null || $translation->till > now())) : false;
|
||||
@endphp
|
||||
|
||||
@usercan('manage posts')
|
||||
@profile('admin')
|
||||
<div class="text-xs lg:text-sm mb-6 text-theme-danger-dark flex items-center justify-end gap-2">
|
||||
@if (!$isPublished)
|
||||
<span>{{ __('This post is not published.') }}</span>
|
||||
@endif
|
||||
<a href="{{ route('posts.manage', ['search' => $post->id, 'postTypeFilter' => '']) }}" class="text-theme-danger-dark hover:text-theme-danger">
|
||||
<x-icon name="pencil-square" class="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
@endprofile
|
||||
@endusercan
|
||||
</div>
|
||||
1050
resources/views/livewire/posts/manage.blade.php
Normal file
1050
resources/views/livewire/posts/manage.blade.php
Normal file
File diff suppressed because it is too large
Load Diff
75
resources/views/livewire/posts/select-author.blade.php
Normal file
75
resources/views/livewire/posts/select-author.blade.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<div x-data class="mt-4 max-w-md">
|
||||
|
||||
<label for="toAuthor" class="my-2 block text-sm font-medium text-theme-primary"> {{ __('Written by') . ' ' . '(' . __('all languages') . ')'}} </label>
|
||||
|
||||
<div class="relative">
|
||||
|
||||
|
||||
<!----- When nothing is selected ---->
|
||||
@if (!isset($selectedId) || $search != '')
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex pl-3 pt-2">
|
||||
<svg class="h-5 w-5 text-theme-muted" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<input wire:model.live.debounce.300ms="search" x-on:blur="$wire.inputBlur()"
|
||||
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-10 pr-3 leading-5 placeholder-gray-400 shadow-sm transition duration-150 ease-in-out focus:border-theme-accent focus:ring-1 focus:ring-theme-accent focus:placeholder-gray-700 sm:text-sm"
|
||||
placeholder="{{ __('Search author name') }}" type="search" autocomplete="off">
|
||||
|
||||
@if (strlen($search) > 2)
|
||||
<ul
|
||||
class="absolute z-[60] mt-0 w-full rounded-md border border-theme-border bg-white text-sm text-theme-primary shadow-lg">
|
||||
@forelse ($searchResults as $result)
|
||||
<li>
|
||||
<a wire:click="authorSelected({{ $result['id'] }})"
|
||||
class="flex items-center px-2 py-2 hover:bg-gray-100 cursor-pointer">
|
||||
<img src="{{ $result['profile_photo_path'] }}" class="w-10 rounded-full profile-photo">
|
||||
<div class="ml-3 leading-tight">
|
||||
<div class="font-semibold text-theme-primary">
|
||||
@if (array_key_exists('name', $result))
|
||||
{{ $result['name'] }}
|
||||
@else
|
||||
{{ __('No results found') }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-theme-secondary">
|
||||
@if (array_key_exists('description', $result))
|
||||
{{ $result['description'] }}
|
||||
@else
|
||||
{{ __('No results found') }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-4">{{ __('No results found for') }} "{{ $search }}"</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
@endif
|
||||
@else
|
||||
<!----- When selected ---->
|
||||
<div
|
||||
class=" focus:shadow-outline-gray my-3 w-full rounded-md border border-theme-border bg-white pl-0 pr-3 leading-5 transition duration-150 ease-in-out focus:border-theme-border focus:outline-none sm:text-sm">
|
||||
<div class="flex items-center px-2 py-2">
|
||||
<img src="{{ $selected['profile_photo_path'] }}" class="w-10 rounded-full profile-photo">
|
||||
<div class="ml-3 leading-tight">
|
||||
<div class="font-semibold">
|
||||
{{ $selected['name'] }}
|
||||
</div>
|
||||
<div class="text-theme-secondary">
|
||||
{{ $selected['description'] }}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ml-auto text-theme-secondary hover:text-red-600" wire:click="removeSelectedProfile">
|
||||
<x-icon mini name="x-circle" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
76
resources/views/livewire/posts/select-organizer.blade.php
Normal file
76
resources/views/livewire/posts/select-organizer.blade.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<div x-data class="mt-4 max-w-md">
|
||||
|
||||
<label for="toAccount" class="my-2 block text-sm font-medium text-theme-primary"> {{ __('Event organizer') }} </label>
|
||||
|
||||
<div class="relative">
|
||||
|
||||
|
||||
|
||||
<!----- When nothing is selected ---->
|
||||
@if (!isset($selectedId) || $search != '')
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex pl-3 pt-2">
|
||||
<svg class="h-5 w-5 text-theme-muted" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<input wire:model.live.debounce.300ms="search" x-on:blur="$wire.inputBlur()"
|
||||
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-10 pr-3 leading-5 placeholder-gray-400 shadow-sm transition duration-150 ease-in-out focus:border-theme-accent focus:ring-1 focus:ring-theme-accent focus:placeholder-gray-700 sm:text-sm"
|
||||
placeholder="{{ __('Search organizer name') }}" type="search" autocomplete="off">
|
||||
|
||||
@if (strlen($search) > 2)
|
||||
<ul
|
||||
class="absolute z-[60] mt-0 w-full rounded-md border border-theme-border bg-white text-sm text-theme-primary shadow-lg">
|
||||
@forelse ($searchResults as $result)
|
||||
<li>
|
||||
<a wire:click="orgSelected({{ $result['id'] }})"
|
||||
class="flex items-center px-2 py-2 hover:bg-gray-100 cursor-pointer">
|
||||
<img src="{{ $result['profile_photo_path'] }}" class="w-10 rounded-full profile-photo">
|
||||
<div class="ml-3 leading-tight">
|
||||
<div class="font-semibold text-theme-primary">
|
||||
@if (array_key_exists('name', $result))
|
||||
{{ $result['name'] }}
|
||||
@else
|
||||
{{ __('No results found') }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-theme-secondary">
|
||||
@if (array_key_exists('description', $result))
|
||||
{{ $result['description'] }}
|
||||
@else
|
||||
{{ __('No results found') }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-4">{{ __('No results found for') }} "{{ $search }}"</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
@endif
|
||||
@else
|
||||
<!----- When selected ---->
|
||||
<div
|
||||
class=" focus:shadow-outline-gray my-3 w-full rounded-md border border-theme-border bg-white pl-0 pr-3 leading-5 transition duration-150 ease-in-out focus:border-theme-border focus:outline-none sm:text-sm">
|
||||
<div class="flex items-center px-2 py-2">
|
||||
<img src="{{ $selected['profile_photo_path'] }}" class="w-10 rounded-full profile-photo">
|
||||
<div class="ml-3 leading-tight">
|
||||
<div class="font-semibold">
|
||||
{{ $selected['name'] }}
|
||||
</div>
|
||||
<div class="text-theme-secondary">
|
||||
{{ $selected['description'] }}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ml-auto text-theme-secondary hover:text-red-600" wire:click="removeSelectedProfile">
|
||||
<x-icon mini name="x-circle" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user