499 lines
24 KiB
PHP
499 lines
24 KiB
PHP
<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
|