Add asset import/export and local dev server setup
- Configure Vite dev server with localhost binding and public asset proxy - Add npm scripts for concurrent Laravel/Vite development (dev:local, dev:test) - Implement asset import/export in ACP via ZIP file upload/download - Create AssetController for asset management endpoints - Add asset management UI tab in admin panel
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class AssetController extends Controller
|
||||||
|
{
|
||||||
|
public function export(): \Symfony\Component\HttpFoundation\StreamedResponse
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
|
abort(403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$storagePublic = storage_path('app/public');
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zipPath = storage_path('exports/assets-'.date('YmdHis').'.zip');
|
||||||
|
|
||||||
|
if (!is_dir(storage_path('exports'))) {
|
||||||
|
mkdir(storage_path('exports'), 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($zip->open($zipPath, ZipArchive::CREATE) === true) {
|
||||||
|
$this->addDirectoryToZip($zip, $storagePublic, '');
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->download($zipPath)->deleteFileAfterSend(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function import(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'file' => ['required', 'file', 'mimes:zip', 'max:104857600'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$file = $data['file'];
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zipPath = $file->getPathname();
|
||||||
|
|
||||||
|
if ($zip->open($zipPath) !== true) {
|
||||||
|
return response()->json(['message' => 'Invalid zip file'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$storagePublic = storage_path('app/public');
|
||||||
|
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$filename = $zip->getNameIndex($i);
|
||||||
|
$fileinfo = $zip->statIndex($i);
|
||||||
|
|
||||||
|
if ($fileinfo['crc'] == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPath = $storagePublic.DIRECTORY_SEPARATOR.$filename;
|
||||||
|
$targetDir = dirname($targetPath);
|
||||||
|
|
||||||
|
if (!is_dir($targetDir)) {
|
||||||
|
mkdir($targetDir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
copy('zip://'.$zipPath.'#'.$filename, $targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Assets imported successfully',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addDirectoryToZip(ZipArchive $zip, string $dir, string $zipPath): void
|
||||||
|
{
|
||||||
|
$files = scandir($dir);
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if ($file === '.' || $file === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $dir.DIRECTORY_SEPARATOR.$file;
|
||||||
|
$zipPath = $zipPath === '' ? $file : $zipPath.'/'.$file;
|
||||||
|
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$zip->addEmptyDir($zipPath);
|
||||||
|
$this->addDirectoryToZip($zip, $path, $zipPath);
|
||||||
|
} else {
|
||||||
|
$zip->addFile($path, $zipPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4
@@ -30,6 +30,10 @@
|
|||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"laravel-vite-plugin": "^2.0.0",
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
"vite": "^7.0.7"
|
"vite": "^7.0.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20",
|
||||||
|
"npm": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:local": "concurrently -n app,vite -c blue,magenta \"php artisan serve --host=127.0.0.1\" \"vite\"",
|
||||||
|
"dev:test": "concurrently -n app,vite -c blue,magenta \"php artisan serve --host=127.0.0.1\" \"vite --host 127.0.0.1 --port 5173\"",
|
||||||
"watch": "vite build --watch",
|
"watch": "vite build --watch",
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -536,3 +536,45 @@ export async function createPost({ body, threadId }) {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportAssets() {
|
||||||
|
const token = localStorage.getItem('speedbb_token')
|
||||||
|
const headers = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_BASE}/assets/export`, {
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('speedbb_token')
|
||||||
|
localStorage.removeItem('speedbb_email')
|
||||||
|
localStorage.removeItem('speedbb_user_id')
|
||||||
|
localStorage.removeItem('speedbb_roles')
|
||||||
|
window.dispatchEvent(new Event('speedbb-unauthorized'))
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText)
|
||||||
|
}
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `assets-${new Date().toISOString().slice(0, 10)}.zip`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importAssets(file) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return apiFetch('/assets/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import {
|
|||||||
createAttachmentExtension,
|
createAttachmentExtension,
|
||||||
updateAttachmentExtension,
|
updateAttachmentExtension,
|
||||||
deleteAttachmentExtension,
|
deleteAttachmentExtension,
|
||||||
|
exportAssets,
|
||||||
|
importAssets,
|
||||||
} from '../api/client'
|
} from '../api/client'
|
||||||
|
|
||||||
const StatusIcon = ({ status = 'bad', tooltip }) => {
|
const StatusIcon = ({ status = 'bad', tooltip }) => {
|
||||||
@@ -222,6 +224,10 @@ function Acp({ isAdmin }) {
|
|||||||
const [systemCliChecking, setSystemCliChecking] = useState(false)
|
const [systemCliChecking, setSystemCliChecking] = useState(false)
|
||||||
const [systemCliError, setSystemCliError] = useState('')
|
const [systemCliError, setSystemCliError] = useState('')
|
||||||
const [systemCliToast, setSystemCliToast] = useState({ show: false, variant: 'success', message: '' })
|
const [systemCliToast, setSystemCliToast] = useState({ show: false, variant: 'success', message: '' })
|
||||||
|
const [assetExporting, setAssetExporting] = useState(false)
|
||||||
|
const [assetImporting, setAssetImporting] = useState(false)
|
||||||
|
const [assetError, setAssetError] = useState('')
|
||||||
|
const [assetToast, setAssetToast] = useState({ show: false, variant: 'success', message: '' })
|
||||||
const settingsDetailMap = {
|
const settingsDetailMap = {
|
||||||
forum_name: 'forumName',
|
forum_name: 'forumName',
|
||||||
default_theme: 'defaultTheme',
|
default_theme: 'defaultTheme',
|
||||||
@@ -787,6 +793,44 @@ function Acp({ isAdmin }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAssetExport = async () => {
|
||||||
|
setAssetExporting(true)
|
||||||
|
setAssetError('')
|
||||||
|
try {
|
||||||
|
await exportAssets()
|
||||||
|
setAssetToast({ show: true, variant: 'success', message: 'Assets exported successfully' })
|
||||||
|
} catch (err) {
|
||||||
|
setAssetError(err.message)
|
||||||
|
setAssetToast({ show: true, variant: 'danger', message: 'Failed to export assets' })
|
||||||
|
} finally {
|
||||||
|
setAssetExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssetImport = async (file) => {
|
||||||
|
if (!file) return
|
||||||
|
setAssetImporting(true)
|
||||||
|
setAssetError('')
|
||||||
|
try {
|
||||||
|
await importAssets(file)
|
||||||
|
setAssetToast({ show: true, variant: 'success', message: 'Assets imported successfully' })
|
||||||
|
window.location.reload()
|
||||||
|
} catch (err) {
|
||||||
|
setAssetError(err.message)
|
||||||
|
setAssetToast({ show: true, variant: 'danger', message: 'Failed to import assets' })
|
||||||
|
} finally {
|
||||||
|
setAssetImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetImportDropzone = useDropzone({
|
||||||
|
accept: {
|
||||||
|
'application/zip': ['.zip'],
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
onDrop: (files) => handleAssetImport(files[0]),
|
||||||
|
})
|
||||||
|
|
||||||
const faviconIcoDropzone = useDropzone({
|
const faviconIcoDropzone = useDropzone({
|
||||||
accept: {
|
accept: {
|
||||||
'image/png': ['.png'],
|
'image/png': ['.png'],
|
||||||
@@ -4337,6 +4381,78 @@ function Acp({ isAdmin }) {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab eventKey="assets" title="Assets">
|
||||||
|
<Row className="g-4">
|
||||||
|
<Col xs={12}>
|
||||||
|
<div className="bb-acp-panel">
|
||||||
|
<div className="bb-acp-panel-header">
|
||||||
|
<h5 className="mb-0">Asset Management</h5>
|
||||||
|
</div>
|
||||||
|
<div className="bb-acp-panel-body">
|
||||||
|
{assetToast.show && (
|
||||||
|
<div
|
||||||
|
className={`alert alert-${assetToast.variant} mb-3`}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{assetToast.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{assetError && (
|
||||||
|
<div className="alert alert-danger mb-3" role="alert">
|
||||||
|
{assetError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<h6 className="mb-2">Export Assets</h6>
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Download all logos, favicons, and other assets as a ZIP file.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleAssetExport}
|
||||||
|
disabled={assetExporting}
|
||||||
|
>
|
||||||
|
{assetExporting ? 'Exporting...' : 'Export Assets'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<h6 className="mb-2">Import Assets</h6>
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Upload a ZIP file containing assets to import them.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
{...assetImportDropzone.getRootProps()}
|
||||||
|
className="border border-dashed rounded p-4 text-center"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: assetImportDropzone.isDragActive
|
||||||
|
? 'rgba(0, 0, 0, 0.05)'
|
||||||
|
: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...assetImportDropzone.getInputProps()} />
|
||||||
|
{assetImporting ? (
|
||||||
|
<p className="mb-0">Importing assets...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i
|
||||||
|
className="bi bi-cloud-upload"
|
||||||
|
style={{ fontSize: '2rem', marginBottom: '0.5rem' }}
|
||||||
|
></i>
|
||||||
|
<p className="mb-0">
|
||||||
|
Drag and drop a ZIP file here, or click to select
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Modal show={showModal} onHide={handleReset} centered size="lg">
|
<Modal show={showModal} onHide={handleReset} centered size="lg">
|
||||||
<Modal.Header closeButton closeVariant="white">
|
<Modal.Header closeButton closeVariant="white">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\AssetController;
|
||||||
use App\Http\Controllers\AttachmentController;
|
use App\Http\Controllers\AttachmentController;
|
||||||
use App\Http\Controllers\AttachmentExtensionController;
|
use App\Http\Controllers\AttachmentExtensionController;
|
||||||
use App\Http\Controllers\AttachmentGroupController;
|
use App\Http\Controllers\AttachmentGroupController;
|
||||||
@@ -53,6 +54,8 @@ Route::post('/user-settings', [UserSettingController::class, 'store'])->middlewa
|
|||||||
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
||||||
Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->middleware('auth:sanctum');
|
Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->middleware('auth:sanctum');
|
||||||
Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum');
|
Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middleware('auth:sanctum');
|
||||||
|
Route::get('/assets/export', [AssetController::class, 'export'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/assets/import', [AssetController::class, 'import'])->middleware('auth:sanctum');
|
||||||
Route::get('/i18n/{locale}', I18nController::class);
|
Route::get('/i18n/{locale}', I18nController::class);
|
||||||
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
||||||
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum');
|
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum');
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/images': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/storage': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ['**/storage/framework/views/**'],
|
ignored: ['**/storage/framework/views/**'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user