From d4d7934c8910a7a7b9be9c210c64ed35e7c8d2e9 Mon Sep 17 00:00:00 2001 From: tracer Date: Sat, 16 May 2026 16:33:22 +0200 Subject: [PATCH] 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 --- app/Http/Controllers/AssetController.php | 101 ++++++++++++++++++++ package-lock.json | 4 + package.json | 2 + resources/js/api/client.js | 42 ++++++++ resources/js/pages/Acp.jsx | 116 +++++++++++++++++++++++ routes/api.php | 3 + vite.config.js | 12 +++ 7 files changed, 280 insertions(+) create mode 100644 app/Http/Controllers/AssetController.php diff --git a/app/Http/Controllers/AssetController.php b/app/Http/Controllers/AssetController.php new file mode 100644 index 0000000..f181f67 --- /dev/null +++ b/app/Http/Controllers/AssetController.php @@ -0,0 +1,101 @@ +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); + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 63d9a1b..3db6507 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,10 @@ "globals": "^16.5.0", "laravel-vite-plugin": "^2.0.0", "vite": "^7.0.7" + }, + "engines": { + "node": ">=20", + "npm": ">=10" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 65022a1..dbd29bb 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "scripts": { "build": "vite build", "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", "lint": "eslint ." }, diff --git a/resources/js/api/client.js b/resources/js/api/client.js index 1444a91..390bcd7 100644 --- a/resources/js/api/client.js +++ b/resources/js/api/client.js @@ -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, + }) +} diff --git a/resources/js/pages/Acp.jsx b/resources/js/pages/Acp.jsx index e2042d0..5aa48f8 100644 --- a/resources/js/pages/Acp.jsx +++ b/resources/js/pages/Acp.jsx @@ -43,6 +43,8 @@ import { createAttachmentExtension, updateAttachmentExtension, deleteAttachmentExtension, + exportAssets, + importAssets, } from '../api/client' const StatusIcon = ({ status = 'bad', tooltip }) => { @@ -222,6 +224,10 @@ function Acp({ isAdmin }) { const [systemCliChecking, setSystemCliChecking] = useState(false) const [systemCliError, setSystemCliError] = useState('') 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 = { forum_name: 'forumName', 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({ accept: { 'image/png': ['.png'], @@ -4337,6 +4381,78 @@ function Acp({ isAdmin }) { + + + +
+
+
Asset Management
+
+
+ {assetToast.show && ( +
+ {assetToast.message} +
+ )} + {assetError && ( +
+ {assetError} +
+ )} +
+
+
Export Assets
+

+ Download all logos, favicons, and other assets as a ZIP file. +

+ +
+
+
Import Assets
+

+ Upload a ZIP file containing assets to import them. +

+
+ + {assetImporting ? ( +

Importing assets...

+ ) : ( + <> + +

+ Drag and drop a ZIP file here, or click to select +

+ + )} +
+
+
+
+
+ +
+
diff --git a/routes/api.php b/routes/api.php index f3e808b..deacf36 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ middlewa Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum'); Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->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('/users', [UserController::class, 'index'])->middleware('auth:sanctum'); Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum'); diff --git a/vite.config.js b/vite.config.js index 19ccafd..73c26e5 100644 --- a/vite.config.js +++ b/vite.config.js @@ -63,6 +63,18 @@ export default defineConfig({ }, }, 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: { ignored: ['**/storage/framework/views/**'], },