제보(https://github.com/gnuboard/g7/issues/126)는 "패키지가 낡았다" 로 읽히지만 본질은 감사 신호가 거짓이었다는 데 있다. 실행에 쓰이는 axios·laravel-echo·pusher-js 가 devDependencies 에 있어 `npm audit --omit=dev` 가 이들을 통째로 건너뛰었고, 동봉 자산은 어떤 잠금파일에도 없어 감사 도구가 원리상 볼 수 없었다. 버전만 올리면 같은 사각이 남으므로 분류를 바로잡고 전수 점검 통로와 회귀 차단을 함께 둔다. 사용자 템플릿 정화기를 3.4.14 로, 코드 편집기를 monaco 0.56.0 으로 올렸다. 0.56.0 은 `editor.main` 의 `m` export 제거와 JSON 언어 서비스 이전이라는 파괴 변경을 얹었으므로 로더를 1.7.0 으로 올리고 진입점을 버전 관용적으로 해석한다. 상류가 못 고치는 잔여(monaco 내장 정화기 3.4.8)는 overrides 로 가리지 않고 하한과 사유를 기록해 그 아래로 내려가는 것만 막는다. 가리면 감사만 조용해지고 브라우저가 받는 자산은 그대로여서, 이 이슈가 고치려던 거짓 신호를 재생산한다.
2323 lines
143 KiB
PHP
2323 lines
143 KiB
PHP
<?php
|
||
// 모듈/템플릿/플러그인 폴더 스캔 (활성 + _bundled 항목 통합, 중복 제거)
|
||
function scanExtensions($path) {
|
||
$extensions = [];
|
||
if (is_dir($path)) {
|
||
// 1) 활성 디렉토리 (루트에 직접 설치된 확장)
|
||
$dirs = array_filter(glob($path . '/*'), 'is_dir');
|
||
foreach ($dirs as $dir) {
|
||
$name = basename($dir);
|
||
if (str_starts_with($name, '_')) {
|
||
continue;
|
||
}
|
||
$extensions[$name] = $name;
|
||
}
|
||
// 2) _bundled 디렉토리 (Git 번들 확장)
|
||
$bundledPath = $path . '/_bundled';
|
||
if (is_dir($bundledPath)) {
|
||
$bundledDirs = array_filter(glob($bundledPath . '/*'), 'is_dir');
|
||
foreach ($bundledDirs as $dir) {
|
||
$name = basename($dir);
|
||
if (!isset($extensions[$name])) {
|
||
$extensions[$name] = $name;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
sort($extensions);
|
||
return array_values($extensions);
|
||
}
|
||
|
||
/**
|
||
* 디렉토리 하위 항목을 삭제합니다 (디렉토리 자체는 보존).
|
||
*
|
||
* 인스톨러 초기화에서 "설정/업로드 내용물만 비우고 디렉토리 구조는 남긴다" 는
|
||
* 용도로 사용합니다. 디렉토리 자체를 지우면 권한/소유권이 재생성 시점의 실행
|
||
* 계정으로 바뀌므로 컨테이너를 보존합니다.
|
||
*
|
||
* @param string $dir 대상 디렉토리 절대경로
|
||
* @param array $keep 보존할 항목명 목록 (basename 기준, 예: ['settings', '.preserve-ownership'])
|
||
* @return array{deleted: int, failed: int, names: array} 삭제 건수 / 실패 건수 / 삭제된 항목명
|
||
*/
|
||
function devDashboardClearDirectory(string $dir, array $keep = []): array
|
||
{
|
||
$result = ['deleted' => 0, 'failed' => 0, 'names' => []];
|
||
|
||
if (!is_dir($dir)) {
|
||
return $result;
|
||
}
|
||
|
||
$isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
|
||
$keep = array_merge(['.', '..', '.gitignore', '.gitkeep'], $keep);
|
||
|
||
foreach (scandir($dir) as $item) {
|
||
if (in_array($item, $keep, true)) {
|
||
continue;
|
||
}
|
||
|
||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||
|
||
if (is_dir($path)) {
|
||
$escaped = escapeshellarg($path);
|
||
if ($isWindows) {
|
||
exec("rmdir /s /q $escaped 2>nul");
|
||
} else {
|
||
exec("rm -rf $escaped");
|
||
}
|
||
if (is_dir($path)) {
|
||
$result['failed']++;
|
||
} else {
|
||
$result['deleted']++;
|
||
$result['names'][] = $item;
|
||
}
|
||
} elseif (@unlink($path)) {
|
||
$result['deleted']++;
|
||
$result['names'][] = $item;
|
||
} else {
|
||
$result['failed']++;
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
$modules = scanExtensions(base_path('modules'));
|
||
$templates = scanExtensions(base_path('templates'));
|
||
$plugins = scanExtensions(base_path('plugins'));
|
||
|
||
// 언어팩은 활성 디렉토리(lang-packs/{identifier}) 와 번들(lang-packs/_bundled/{identifier})
|
||
// 양쪽 모두 식별자 기반이므로 동일 헬퍼 사용 가능
|
||
$languagePacks = scanExtensions(base_path('lang-packs'));
|
||
|
||
// AJAX 요청 처리: GET 방식으로 처리
|
||
if (isset($_GET['ajax_action'])) {
|
||
header('Content-Type: application/json');
|
||
$action = $_GET['ajax_action'];
|
||
|
||
// Artisan 명령어 실행
|
||
if ($action === 'artisan') {
|
||
$command = $_GET['command'] ?? '';
|
||
try {
|
||
$parts = preg_split('/\s+/', trim($command));
|
||
$cmdName = array_shift($parts);
|
||
|
||
$arguments = [];
|
||
foreach ($parts as $part) {
|
||
// 빈 문자열 스킵 (연속 공백 처리)
|
||
if (empty($part)) {
|
||
continue;
|
||
}
|
||
|
||
if (str_starts_with($part, '--')) {
|
||
$optPart = substr($part, 2);
|
||
if (str_contains($optPart, '=')) {
|
||
[$optName, $optValue] = explode('=', $optPart, 2);
|
||
$arguments['--' . $optName] = $optValue;
|
||
} else {
|
||
$arguments['--' . $optPart] = true;
|
||
}
|
||
} else {
|
||
if (!isset($arguments['identifier'])) {
|
||
$arguments['identifier'] = $part;
|
||
}
|
||
}
|
||
}
|
||
|
||
$exitCode = \Illuminate\Support\Facades\Artisan::call($cmdName, $arguments);
|
||
$output = \Illuminate\Support\Facades\Artisan::output();
|
||
|
||
// 출력 메시지 정리 (전체 출력 유지)
|
||
$cleanOutput = trim($output);
|
||
|
||
// 출력이 비어있으면 기본 메시지
|
||
if (empty($cleanOutput)) {
|
||
$cleanOutput = $exitCode === 0 ? '명령이 성공적으로 실행되었습니다.' : '명령 실행 중 오류가 발생했습니다.';
|
||
}
|
||
|
||
// 종료 코드 표시
|
||
//
|
||
// 비-0 종료가 곧 "명령 실패" 는 아니다. 점검 계열 커맨드(예: search:index)는
|
||
// 점검 자체는 정상 수행하고 "이상을 발견했다" 는 뜻으로 비-0 을 돌려준다.
|
||
// 이것을 "실행 실패" 로 적으면 운영자가 도구가 고장난 것으로 읽고
|
||
// 정작 발견된 이상(색인 누락 등)을 놓친다.
|
||
if ($exitCode === 0) {
|
||
$cleanOutput = "✅ 완료\n\n" . $cleanOutput;
|
||
} else {
|
||
$cleanOutput = "⚠️ 종료 코드 {$exitCode} — 아래 출력을 확인하세요\n\n" . $cleanOutput;
|
||
}
|
||
|
||
echo json_encode([
|
||
'success' => $exitCode === 0,
|
||
'output' => $cleanOutput,
|
||
'code' => $exitCode
|
||
]);
|
||
} catch (\Exception $e) {
|
||
echo json_encode([
|
||
'success' => false,
|
||
'output' => '❌ ' . $e->getMessage(),
|
||
'code' => 1
|
||
]);
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// 템플릿 삭제 (Manager 직접 사용 - 확인 프롬프트 우회)
|
||
if ($action === 'template_uninstall') {
|
||
$identifier = $_GET['identifier'] ?? '';
|
||
try {
|
||
$manager = app(\App\Extension\TemplateManager::class);
|
||
$manager->loadTemplates();
|
||
$result = $manager->uninstallTemplate($identifier);
|
||
echo json_encode([
|
||
'success' => $result,
|
||
'output' => $result ? "✅ 템플릿 '{$identifier}' 삭제 완료" : "❌ 삭제 실패",
|
||
'code' => $result ? 0 : 1
|
||
]);
|
||
} catch (\Exception $e) {
|
||
echo json_encode(['success' => false, 'output' => '❌ ' . $e->getMessage(), 'code' => 1]);
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// 모듈 삭제 (Manager 직접 사용)
|
||
if ($action === 'module_uninstall') {
|
||
$identifier = $_GET['identifier'] ?? '';
|
||
try {
|
||
$manager = app(\App\Extension\ModuleManager::class);
|
||
$manager->loadModules();
|
||
$result = $manager->uninstallModule($identifier);
|
||
echo json_encode([
|
||
'success' => $result,
|
||
'output' => $result ? "✅ 모듈 '{$identifier}' 삭제 완료" : "❌ 삭제 실패",
|
||
'code' => $result ? 0 : 1
|
||
]);
|
||
} catch (\Exception $e) {
|
||
echo json_encode(['success' => false, 'output' => '❌ ' . $e->getMessage(), 'code' => 1]);
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// 플러그인 삭제 (Manager 직접 사용)
|
||
if ($action === 'plugin_uninstall') {
|
||
$identifier = $_GET['identifier'] ?? '';
|
||
try {
|
||
$manager = app(\App\Extension\PluginManager::class);
|
||
$manager->loadPlugins();
|
||
$result = $manager->uninstallPlugin($identifier);
|
||
echo json_encode([
|
||
'success' => $result,
|
||
'output' => $result ? "✅ 플러그인 '{$identifier}' 삭제 완료" : "❌ 삭제 실패",
|
||
'code' => $result ? 0 : 1
|
||
]);
|
||
} catch (\Exception $e) {
|
||
echo json_encode(['success' => false, 'output' => '❌ ' . $e->getMessage(), 'code' => 1]);
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// 템플릿 빌드 (npm run build)
|
||
if ($action === 'template_build') {
|
||
$identifier = $_GET['identifier'] ?? '';
|
||
try {
|
||
$templatePath = base_path('templates/' . $identifier);
|
||
|
||
if (!is_dir($templatePath)) {
|
||
echo json_encode([
|
||
'success' => false,
|
||
'output' => "❌ 템플릿 '{$identifier}' 디렉토리를 찾을 수 없습니다.",
|
||
'code' => 1
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
// package.json 존재 확인
|
||
if (!file_exists($templatePath . '/package.json')) {
|
||
echo json_encode([
|
||
'success' => false,
|
||
'output' => "❌ package.json 파일이 없습니다.",
|
||
'code' => 1
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
// Windows 환경에서 npm run build 실행
|
||
$command = "cd " . escapeshellarg($templatePath) . " && npm run build 2>&1";
|
||
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
||
$command = "cd /d " . escapeshellarg($templatePath) . " && npm run build 2>&1";
|
||
}
|
||
|
||
$output = [];
|
||
$returnCode = 0;
|
||
exec($command, $output, $returnCode);
|
||
|
||
// ANSI 색상 코드 제거 및 로그 필터링
|
||
$filteredOutput = [];
|
||
foreach ($output as $line) {
|
||
// ANSI 색상 코드 제거
|
||
$cleanLine = preg_replace('/\x1b\[[0-9;]*m/', '', $line);
|
||
|
||
// 필터링할 패턴 (상세 진행 로그 제외)
|
||
$skipPatterns = [
|
||
'/^transforming\.\.\./', // transforming...
|
||
'/^✓ \d+ modules transformed/', // ✓ 194 modules transformed
|
||
'/^rendering chunks\.\.\./', // rendering chunks...
|
||
'/^computing gzip size\.\.\./', // computing gzip size...
|
||
'/^You are using Node\.js/', // Node.js 버전 경고
|
||
'/^vite v[\d\.]+ building/', // vite 버전 정보
|
||
];
|
||
|
||
$shouldSkip = false;
|
||
foreach ($skipPatterns as $pattern) {
|
||
if (preg_match($pattern, $cleanLine)) {
|
||
$shouldSkip = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!$shouldSkip && trim($cleanLine) !== '') {
|
||
$filteredOutput[] = $cleanLine;
|
||
}
|
||
}
|
||
|
||
$outputText = implode("\n", $filteredOutput);
|
||
|
||
// 성공/실패 여부 표시
|
||
if ($returnCode === 0) {
|
||
$outputText = "✅ 빌드 완료\n\n" . $outputText;
|
||
} else {
|
||
$outputText = "❌ 빌드 실패\n\n" . $outputText;
|
||
}
|
||
|
||
echo json_encode([
|
||
'success' => $returnCode === 0,
|
||
'output' => $outputText,
|
||
'code' => $returnCode
|
||
]);
|
||
} catch (\Exception $e) {
|
||
echo json_encode(['success' => false, 'output' => '❌ ' . $e->getMessage(), 'code' => 1]);
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// 인스톨러 초기화
|
||
if ($action === 'reset') {
|
||
$results = [];
|
||
$deleteVendor = isset($_GET['delete_vendor']) && $_GET['delete_vendor'] === '1';
|
||
|
||
// DB 테이블 전체 삭제 (.env 삭제 전에 실행해야 DB 연결 유지)
|
||
$dropTables = isset($_GET['drop_tables']) && $_GET['drop_tables'] === '1';
|
||
if ($dropTables) {
|
||
try {
|
||
$tables = \Illuminate\Support\Facades\DB::select('SHOW TABLES');
|
||
|
||
if (empty($tables)) {
|
||
$results[] = ['name' => 'DB 테이블', 'status' => 'notfound'];
|
||
} else {
|
||
// SHOW TABLES 결과의 첫 번째 컬럼명을 동적으로 가져옴
|
||
$columnKey = array_key_first((array) $tables[0]);
|
||
$tableNames = array_map(fn($t) => ((array) $t)[$columnKey], $tables);
|
||
\Illuminate\Support\Facades\DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||
$droppedCount = 0;
|
||
foreach ($tableNames as $tableName) {
|
||
\Illuminate\Support\Facades\DB::statement("DROP TABLE IF EXISTS `{$tableName}`");
|
||
$droppedCount++;
|
||
}
|
||
\Illuminate\Support\Facades\DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||
$results[] = [
|
||
'name' => "DB 테이블 ({$droppedCount}개 삭제)",
|
||
'status' => 'deleted'
|
||
];
|
||
}
|
||
} catch (\Exception $e) {
|
||
$results[] = ['name' => 'DB 테이블 (' . $e->getMessage() . ')', 'status' => 'failed'];
|
||
}
|
||
}
|
||
|
||
// 삭제 대상 파일 목록 (표시명 => 경로)
|
||
$filesToDelete = [
|
||
'.env' => base_path('.env'),
|
||
'storage/app/g7_installed' => storage_path('app/g7_installed'),
|
||
'storage/installer-state.json' => storage_path('installer-state.json')
|
||
];
|
||
|
||
foreach ($filesToDelete as $displayName => $file) {
|
||
if (file_exists($file)) {
|
||
if (@unlink($file)) {
|
||
$results[] = ['name' => $displayName, 'status' => 'deleted'];
|
||
} else {
|
||
$results[] = ['name' => $displayName, 'status' => 'failed'];
|
||
}
|
||
} else {
|
||
$results[] = ['name' => $displayName, 'status' => 'notfound'];
|
||
}
|
||
}
|
||
|
||
if ($deleteVendor) {
|
||
$vendorPath = base_path('vendor');
|
||
if (is_dir($vendorPath)) {
|
||
// .gitkeep 보존: vendor 내부 항목만 개별 삭제
|
||
$vendorItems = array_diff(scandir($vendorPath), ['.', '..', '.gitkeep']);
|
||
$deletedCount = 0;
|
||
$failedCount = 0;
|
||
foreach ($vendorItems as $item) {
|
||
$itemPath = $vendorPath . DIRECTORY_SEPARATOR . $item;
|
||
$escapedItem = escapeshellarg($itemPath);
|
||
if (is_dir($itemPath)) {
|
||
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
||
exec("rmdir /s /q $escapedItem 2>nul", $output, $returnCode);
|
||
} else {
|
||
exec("rm -rf $escapedItem", $output, $returnCode);
|
||
}
|
||
is_dir($itemPath) ? $failedCount++ : $deletedCount++;
|
||
} else {
|
||
@unlink($itemPath) ? $deletedCount++ : $failedCount++;
|
||
}
|
||
}
|
||
$results[] = [
|
||
'name' => 'vendor/ (.gitkeep 보존)',
|
||
'status' => $failedCount === 0 ? 'deleted' : 'failed'
|
||
];
|
||
} else {
|
||
$results[] = ['name' => 'vendor/', 'status' => 'notfound'];
|
||
}
|
||
}
|
||
|
||
// 확장 설치 디렉토리 삭제 (_bundled, _pending 보존)
|
||
$deleteExtensions = isset($_GET['delete_extensions']) && $_GET['delete_extensions'] === '1';
|
||
if ($deleteExtensions) {
|
||
$extensionTypes = ['modules', 'plugins', 'templates'];
|
||
$protectedDirs = ['_bundled', '_pending'];
|
||
foreach ($extensionTypes as $type) {
|
||
$basePath = base_path($type);
|
||
if (!is_dir($basePath)) {
|
||
$results[] = ['name' => "$type/", 'status' => 'notfound'];
|
||
continue;
|
||
}
|
||
$dirs = array_filter(glob($basePath . '/*'), 'is_dir');
|
||
$deletedDirs = [];
|
||
$failedDirs = [];
|
||
foreach ($dirs as $dir) {
|
||
$dirName = basename($dir);
|
||
if (in_array($dirName, $protectedDirs)) {
|
||
continue; // _bundled, _pending 보호
|
||
}
|
||
$escapedDir = escapeshellarg($dir);
|
||
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
||
exec("rmdir /s /q $escapedDir 2>nul", $output, $returnCode);
|
||
} else {
|
||
exec("rm -rf $escapedDir", $output, $returnCode);
|
||
}
|
||
is_dir($dir) ? $failedDirs[] = $dirName : $deletedDirs[] = $dirName;
|
||
}
|
||
if (empty($deletedDirs) && empty($failedDirs)) {
|
||
$results[] = ['name' => "$type/", 'status' => 'notfound'];
|
||
} else {
|
||
$status = empty($failedDirs) ? 'deleted' : 'failed';
|
||
$detail = implode(', ', $deletedDirs);
|
||
$results[] = ['name' => "$type/ ($detail)", 'status' => $status];
|
||
}
|
||
}
|
||
}
|
||
|
||
// 환경설정 파일 삭제 (코어 + 모듈 + 플러그인 + 템플릿)
|
||
$deleteSettings = isset($_GET['delete_settings']) && $_GET['delete_settings'] === '1';
|
||
if ($deleteSettings) {
|
||
// 코어: storage/app/settings/*.json (SettingsService 의 SSoT)
|
||
$coreSettingsPath = storage_path('app/settings');
|
||
$cleared = devDashboardClearDirectory($coreSettingsPath);
|
||
if ($cleared['deleted'] === 0 && $cleared['failed'] === 0) {
|
||
$results[] = ['name' => '코어 환경설정 (storage/app/settings)', 'status' => 'notfound'];
|
||
} else {
|
||
$results[] = [
|
||
'name' => "코어 환경설정 (storage/app/settings, {$cleared['deleted']}개)",
|
||
'status' => $cleared['failed'] === 0 ? 'deleted' : 'failed'
|
||
];
|
||
}
|
||
|
||
// 모듈/플러그인: storage/app/{modules,plugins}/{identifier}/settings/
|
||
// (ModuleSettingsService / PluginSettingsService 가 StorageInterface 로 기록하는 경로)
|
||
foreach (['modules' => '모듈', 'plugins' => '플러그인'] as $storageDir => $label) {
|
||
$basePath = storage_path('app/' . $storageDir);
|
||
$clearedIds = [];
|
||
$failedIds = [];
|
||
if (is_dir($basePath)) {
|
||
foreach (array_filter(glob($basePath . '/*'), 'is_dir') as $extDir) {
|
||
$settingsDir = $extDir . DIRECTORY_SEPARATOR . 'settings';
|
||
if (!is_dir($settingsDir)) {
|
||
continue;
|
||
}
|
||
$cleared = devDashboardClearDirectory($settingsDir);
|
||
if ($cleared['failed'] > 0) {
|
||
$failedIds[] = basename($extDir);
|
||
} elseif ($cleared['deleted'] > 0) {
|
||
$clearedIds[] = basename($extDir);
|
||
}
|
||
}
|
||
}
|
||
if (empty($clearedIds) && empty($failedIds)) {
|
||
$results[] = ['name' => "{$label} 환경설정 (storage/app/{$storageDir})", 'status' => 'notfound'];
|
||
} else {
|
||
$detail = implode(', ', array_merge($clearedIds, $failedIds));
|
||
$results[] = [
|
||
'name' => "{$label} 환경설정 ({$detail})",
|
||
'status' => empty($failedIds) ? 'deleted' : 'failed'
|
||
];
|
||
}
|
||
}
|
||
|
||
// 템플릿은 파일 기반 환경설정 저장소를 갖지 않는다.
|
||
// 활성 상태/레이아웃 편집분은 DB(templates, template_layouts), 나머지는 템플릿 패키지 파일이므로
|
||
// 각각 "DB 테이블 전체 삭제" 와 "확장 설치 디렉토리 삭제" 가 담당한다.
|
||
// 삭제 대상이 0 건인 것과 대상 자체가 없는 것은 다르므로 그 사실을 그대로 표기한다.
|
||
$results[] = [
|
||
'name' => '템플릿 환경설정 (파일 저장소 없음 — DB/패키지 보관)',
|
||
'status' => 'notfound'
|
||
];
|
||
}
|
||
|
||
// 첨부파일 스토리지 삭제
|
||
$deleteAttachments = isset($_GET['delete_attachments']) && $_GET['delete_attachments'] === '1';
|
||
if ($deleteAttachments) {
|
||
// 코어 첨부파일 디스크(config/attachment.php) + 공개 업로드 디스크
|
||
$attachmentPaths = [
|
||
'storage/app/attachments' => storage_path('app/attachments'),
|
||
'storage/app/public' => storage_path('app/public')
|
||
];
|
||
foreach ($attachmentPaths as $displayName => $path) {
|
||
$cleared = devDashboardClearDirectory($path);
|
||
if ($cleared['deleted'] === 0 && $cleared['failed'] === 0) {
|
||
$results[] = ['name' => $displayName, 'status' => 'notfound'];
|
||
} else {
|
||
$results[] = [
|
||
'name' => "{$displayName} ({$cleared['deleted']}개)",
|
||
'status' => $cleared['failed'] === 0 ? 'deleted' : 'failed'
|
||
];
|
||
}
|
||
}
|
||
|
||
// 모듈/플러그인 업로드 파일 (예: storage/app/modules/{id}/images/products)
|
||
// settings/ 와 .preserve-ownership 마커는 보존 — 환경설정 삭제 옵션과 서로 간섭하지 않도록 분리한다.
|
||
foreach (['modules' => '모듈', 'plugins' => '플러그인'] as $storageDir => $label) {
|
||
$basePath = storage_path('app/' . $storageDir);
|
||
$clearedIds = [];
|
||
$failedIds = [];
|
||
if (is_dir($basePath)) {
|
||
foreach (array_filter(glob($basePath . '/*'), 'is_dir') as $extDir) {
|
||
$cleared = devDashboardClearDirectory($extDir, ['settings', '.preserve-ownership']);
|
||
if ($cleared['failed'] > 0) {
|
||
$failedIds[] = basename($extDir);
|
||
} elseif ($cleared['deleted'] > 0) {
|
||
$clearedIds[] = basename($extDir);
|
||
}
|
||
}
|
||
}
|
||
if (empty($clearedIds) && empty($failedIds)) {
|
||
$results[] = ['name' => "{$label} 업로드 파일 (storage/app/{$storageDir})", 'status' => 'notfound'];
|
||
} else {
|
||
$detail = implode(', ', array_merge($clearedIds, $failedIds));
|
||
$results[] = [
|
||
'name' => "{$label} 업로드 파일 ({$detail})",
|
||
'status' => empty($failedIds) ? 'deleted' : 'failed'
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
echo json_encode(['success' => true, 'results' => $results]);
|
||
exit;
|
||
}
|
||
}
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{{ config('app.name', '그누보드7') }}</title>
|
||
{{-- 스타일: 자체 빌드 CSS (종전 Tailwind Play CDN 대체 — 외부 도달 실패 시 화면이 무너졌다) --}}
|
||
<link rel="stylesheet" href="{{ asset('build/core/dev-dashboard.css') }}">
|
||
<style>
|
||
.card-hover { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
|
||
.card-hover:hover { transform: translateY(-8px); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); }
|
||
.card-icon { transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
|
||
.card-hover:hover .card-icon { transform: scale(1.15) rotate(-5deg); }
|
||
.selectable-item { transition: all 0.2s; border: 2px solid transparent; }
|
||
.selectable-item:has(input:checked) { border-color: var(--select-color, #10b981); background: var(--select-bg, rgba(16, 185, 129, 0.1)); }
|
||
.selectable-item:has(input:checked) .item-text { color: var(--select-color, #10b981); font-weight: 500; }
|
||
.selectable-item:has(input:checked) .select-icon { color: var(--select-color, #10b981); opacity: 1; }
|
||
</style>
|
||
</head>
|
||
<body class="min-h-screen bg-slate-900 flex flex-col">
|
||
<!-- 메인 콘텐츠 -->
|
||
<main class="flex-1 flex items-center justify-center px-6 pt-2 pb-4">
|
||
<div class="w-full max-w-6xl text-center">
|
||
|
||
<!-- 로고 및 타이틀 -->
|
||
<div class="mb-6 flex items-center justify-center gap-4">
|
||
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-cyan-400 rounded-xl flex items-center justify-center shadow-xl shrink-0">
|
||
<span class="text-3xl font-bold text-white">G7</span>
|
||
</div>
|
||
<div class="text-left">
|
||
<h1 class="text-2xl font-light text-slate-50">{{ config('app.name', '그누보드7') }}</h1>
|
||
<p class="text-sm text-slate-400">개발 환경 대시보드</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 진행 상황 패널 -->
|
||
<div id="progressPanel" class="hidden mb-4 bg-slate-800/80 rounded-xl p-4 border border-slate-700/30 text-left">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div class="text-sm font-medium text-white" id="progressTitle">재설치 진행 중...</div>
|
||
<button onclick="closeProgress()" class="text-slate-400 hover:text-white text-xs">✕ 닫기</button>
|
||
</div>
|
||
<div id="progressContent" class="space-y-2 text-xs max-h-96 overflow-y-auto"></div>
|
||
</div>
|
||
|
||
<!-- Artisan 커맨드 패널 (아코디언) -->
|
||
<div class="mb-6 bg-gradient-to-br from-slate-800/50 to-slate-800/30 backdrop-blur-lg rounded-2xl border border-slate-700/40 shadow-xl relative overflow-hidden">
|
||
<div class="absolute inset-0 bg-gradient-to-br from-emerald-500/0 to-teal-500/0 transition-all duration-300"></div>
|
||
<div class="relative z-10">
|
||
<!-- 아코디언 헤더 -->
|
||
<button type="button" onclick="toggleCommandPanel()" class="w-full flex items-center gap-3 p-4 text-left hover:bg-slate-700/20 transition-colors rounded-2xl">
|
||
<div class="text-2xl shrink-0">⚡</div>
|
||
<div class="flex-1">
|
||
<div class="text-base font-semibold text-slate-100">Artisan 커맨드</div>
|
||
<p class="text-xs text-slate-400">모듈/템플릿 관리, 빌드, 캐시 초기화</p>
|
||
</div>
|
||
<svg id="commandPanelArrow" class="w-5 h-5 text-slate-400 transition-transform duration-200 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 아코디언 콘텐츠 (기본 열림) -->
|
||
<div id="commandPanelContent" class="border-t border-slate-700/30 p-4">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
|
||
<!-- 모듈 관리 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">📦</span>
|
||
<span class="text-xs font-medium text-slate-200">모듈 관리</span>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div class="flex gap-2">
|
||
<select id="moduleSelect" class="flex-1 px-3 py-2 bg-slate-800 text-slate-200 text-sm rounded border border-slate-600 focus:border-emerald-500 outline-none">
|
||
<option value="">모듈 선택</option>
|
||
@foreach ($modules as $module)
|
||
<option value="{{ $module }}">{{ $module }}</option>
|
||
@endforeach
|
||
</select>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('module:list')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>목록 조회</span>
|
||
<span class="text-[10px] opacity-60">(module:list)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('install')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>설치</span>
|
||
<span class="text-[10px] opacity-60">(install)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('activate')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>활성화</span>
|
||
<span class="text-[10px] opacity-60">(activate)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('deactivate')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-orange-600 hover:bg-orange-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>비활성화</span>
|
||
<span class="text-[10px] opacity-60">(deactivate)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('uninstall')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>제거</span>
|
||
<span class="text-[10px] opacity-60">(uninstall)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('build')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-cyan-600 hover:bg-cyan-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>빌드</span>
|
||
<span class="text-[10px] opacity-60">(build)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('refresh-layout')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-teal-600 hover:bg-teal-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>레이아웃 갱신</span>
|
||
<span class="text-[10px] opacity-60">(refresh-layout)</span>
|
||
</button>
|
||
<button onclick="runCommand('module:cache-clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>캐시 초기화</span>
|
||
<span class="text-[10px] opacity-60">(cache-clear)</span>
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 mt-2 pt-2 border-t border-slate-700/20">
|
||
<button onclick="runCommand('module:check-updates')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트 확인</span>
|
||
<span class="text-[10px] opacity-60">(check-updates)</span>
|
||
</button>
|
||
<button onclick="runExtensionCommand('module', 'update')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-sky-600 hover:bg-sky-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트</span>
|
||
<span class="text-[10px] opacity-60">(update)</span>
|
||
</button>
|
||
<button onclick="runExtensionCommand('module', 'update', '--force')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-sky-700 hover:bg-sky-800 text-white text-xs font-medium rounded transition-colors">
|
||
<span>강제 업데이트</span>
|
||
<span class="text-[10px] opacity-60">(update --force)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('seed')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-lime-600 hover:bg-lime-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>시더 실행</span>
|
||
<span class="text-[10px] opacity-60">(seed)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('composer-install')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-violet-600 hover:bg-violet-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>Composer 설치</span>
|
||
<span class="text-[10px] opacity-60">(composer-install)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('vendor-bundle')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-fuchsia-600 hover:bg-fuchsia-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>Vendor 번들</span>
|
||
<span class="text-[10px] opacity-60">(vendor-bundle)</span>
|
||
</button>
|
||
<button onclick="runModuleCommand('vendor-verify')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-fuchsia-700 hover:bg-fuchsia-800 text-white text-xs font-medium rounded transition-colors">
|
||
<span>Vendor 검증</span>
|
||
<span class="text-[10px] opacity-60">(vendor-verify)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 템플릿 관리 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🎨</span>
|
||
<span class="text-xs font-medium text-slate-200">템플릿 관리</span>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div class="flex gap-2">
|
||
<select id="templateSelect" class="flex-1 px-3 py-2 bg-slate-800 text-slate-200 text-sm rounded border border-slate-600 focus:border-emerald-500 outline-none">
|
||
<option value="">템플릿 선택</option>
|
||
@foreach ($templates as $template)
|
||
<option value="{{ $template }}">{{ $template }}</option>
|
||
@endforeach
|
||
</select>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('template:list')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>목록 조회</span>
|
||
<span class="text-[10px] opacity-60">(template:list)</span>
|
||
</button>
|
||
<button onclick="runTemplateCommand('install')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>설치</span>
|
||
<span class="text-[10px] opacity-60">(install)</span>
|
||
</button>
|
||
<button onclick="runTemplateCommand('activate')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>활성화</span>
|
||
<span class="text-[10px] opacity-60">(activate)</span>
|
||
</button>
|
||
<button onclick="runTemplateCommand('deactivate')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-orange-600 hover:bg-orange-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>비활성화</span>
|
||
<span class="text-[10px] opacity-60">(deactivate)</span>
|
||
</button>
|
||
<button onclick="runTemplateCommand('uninstall')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>제거</span>
|
||
<span class="text-[10px] opacity-60">(uninstall)</span>
|
||
</button>
|
||
<button onclick="runTemplateCommand('build')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-cyan-600 hover:bg-cyan-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>빌드</span>
|
||
<span class="text-[10px] opacity-60">(build)</span>
|
||
</button>
|
||
<button onclick="runTemplateCommand('refresh-layout')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-teal-600 hover:bg-teal-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>레이아웃 갱신</span>
|
||
<span class="text-[10px] opacity-60">(refresh-layout)</span>
|
||
</button>
|
||
<button onclick="runCommand('template:cache-clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>캐시 초기화</span>
|
||
<span class="text-[10px] opacity-60">(cache-clear)</span>
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 mt-2 pt-2 border-t border-slate-700/20">
|
||
<button onclick="runCommand('template:check-updates')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트 확인</span>
|
||
<span class="text-[10px] opacity-60">(check-updates)</span>
|
||
</button>
|
||
<button onclick="runExtensionCommand('template', 'update', '--layout-strategy=overwrite')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-sky-600 hover:bg-sky-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트 (덮어쓰기)</span>
|
||
<span class="text-[10px] opacity-60">(update --overwrite)</span>
|
||
</button>
|
||
<button onclick="runExtensionCommand('template', 'update', '--layout-strategy=keep')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-sky-600 hover:bg-sky-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트 (유지)</span>
|
||
<span class="text-[10px] opacity-60">(update --keep)</span>
|
||
</button>
|
||
<button onclick="runExtensionCommand('template', 'update', '--layout-strategy=overwrite --force')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-sky-700 hover:bg-sky-800 text-white text-xs font-medium rounded transition-colors">
|
||
<span>강제 업데이트</span>
|
||
<span class="text-[10px] opacity-60">(update --force)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 플러그인 관리 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🔌</span>
|
||
<span class="text-xs font-medium text-slate-200">플러그인 관리</span>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div class="flex gap-2">
|
||
<select id="pluginSelect" class="flex-1 px-3 py-2 bg-slate-800 text-slate-200 text-sm rounded border border-slate-600 focus:border-emerald-500 outline-none">
|
||
<option value="">플러그인 선택</option>
|
||
@foreach ($plugins as $plugin)
|
||
<option value="{{ $plugin }}">{{ $plugin }}</option>
|
||
@endforeach
|
||
</select>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('plugin:list')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>목록 조회</span>
|
||
<span class="text-[10px] opacity-60">(plugin:list)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('install')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>설치</span>
|
||
<span class="text-[10px] opacity-60">(install)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('activate')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>활성화</span>
|
||
<span class="text-[10px] opacity-60">(activate)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('deactivate')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-orange-600 hover:bg-orange-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>비활성화</span>
|
||
<span class="text-[10px] opacity-60">(deactivate)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('uninstall')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>제거</span>
|
||
<span class="text-[10px] opacity-60">(uninstall)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('build')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-cyan-600 hover:bg-cyan-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>빌드</span>
|
||
<span class="text-[10px] opacity-60">(build)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('refresh-layout')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-teal-600 hover:bg-teal-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>레이아웃 갱신</span>
|
||
<span class="text-[10px] opacity-60">(refresh-layout)</span>
|
||
</button>
|
||
<button onclick="runCommand('plugin:cache-clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>캐시 초기화</span>
|
||
<span class="text-[10px] opacity-60">(cache-clear)</span>
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 mt-2 pt-2 border-t border-slate-700/20">
|
||
<button onclick="runCommand('plugin:check-updates')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트 확인</span>
|
||
<span class="text-[10px] opacity-60">(check-updates)</span>
|
||
</button>
|
||
<button onclick="runExtensionCommand('plugin', 'update')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-sky-600 hover:bg-sky-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트</span>
|
||
<span class="text-[10px] opacity-60">(update)</span>
|
||
</button>
|
||
<button onclick="runExtensionCommand('plugin', 'update', '--force')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-sky-700 hover:bg-sky-800 text-white text-xs font-medium rounded transition-colors">
|
||
<span>강제 업데이트</span>
|
||
<span class="text-[10px] opacity-60">(update --force)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('seed')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-lime-600 hover:bg-lime-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>시더 실행</span>
|
||
<span class="text-[10px] opacity-60">(seed)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('composer-install')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-violet-600 hover:bg-violet-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>Composer 설치</span>
|
||
<span class="text-[10px] opacity-60">(composer-install)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('vendor-bundle')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-fuchsia-600 hover:bg-fuchsia-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>Vendor 번들</span>
|
||
<span class="text-[10px] opacity-60">(vendor-bundle)</span>
|
||
</button>
|
||
<button onclick="runPluginCommand('vendor-verify')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-fuchsia-700 hover:bg-fuchsia-800 text-white text-xs font-medium rounded transition-colors">
|
||
<span>Vendor 검증</span>
|
||
<span class="text-[10px] opacity-60">(vendor-verify)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 언어팩 관리 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🌐</span>
|
||
<span class="text-xs font-medium text-slate-200">언어팩 관리</span>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div class="flex gap-2">
|
||
<select id="languagePackSelect" class="flex-1 px-3 py-2 bg-slate-800 text-slate-200 text-sm rounded border border-slate-600 focus:border-emerald-500 outline-none">
|
||
<option value="">언어팩 선택</option>
|
||
@foreach ($languagePacks as $languagePack)
|
||
<option value="{{ $languagePack }}">{{ $languagePack }}</option>
|
||
@endforeach
|
||
</select>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('language-pack:list')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>목록 조회</span>
|
||
<span class="text-[10px] opacity-60">(language-pack:list)</span>
|
||
</button>
|
||
<button onclick="runCommand('language-pack:provision')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-teal-600 hover:bg-teal-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>프로비저닝</span>
|
||
<span class="text-[10px] opacity-60">(provision)</span>
|
||
</button>
|
||
<button onclick="runLanguagePackCommand('install')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>설치</span>
|
||
<span class="text-[10px] opacity-60">(install)</span>
|
||
</button>
|
||
<button onclick="runLanguagePackCommand('activate')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>활성화</span>
|
||
<span class="text-[10px] opacity-60">(activate)</span>
|
||
</button>
|
||
<button onclick="runLanguagePackCommand('deactivate')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-orange-600 hover:bg-orange-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>비활성화</span>
|
||
<span class="text-[10px] opacity-60">(deactivate)</span>
|
||
</button>
|
||
<button onclick="runLanguagePackCommand('uninstall')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>제거</span>
|
||
<span class="text-[10px] opacity-60">(uninstall)</span>
|
||
</button>
|
||
<button onclick="runCommand('language-pack:cache-clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>캐시 초기화</span>
|
||
<span class="text-[10px] opacity-60">(cache-clear)</span>
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 mt-2 pt-2 border-t border-slate-700/20">
|
||
<button onclick="runCommand('language-pack:check-updates')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트 확인</span>
|
||
<span class="text-[10px] opacity-60">(check-updates)</span>
|
||
</button>
|
||
<button onclick="runLanguagePackCommand('update')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-sky-600 hover:bg-sky-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트</span>
|
||
<span class="text-[10px] opacity-60">(update)</span>
|
||
</button>
|
||
<button onclick="runLanguagePackCommand('update', '--force')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-sky-700 hover:bg-sky-800 text-white text-xs font-medium rounded transition-colors">
|
||
<span>강제 업데이트</span>
|
||
<span class="text-[10px] opacity-60">(update --force)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 코어 빌드 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🔨</span>
|
||
<span class="text-xs font-medium text-slate-200">코어 빌드</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('core:build')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-cyan-600 hover:bg-cyan-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>템플릿 엔진 빌드</span>
|
||
<span class="text-[10px] opacity-60">(core:build)</span>
|
||
</button>
|
||
<button onclick="runCommand('core:build --full')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-cyan-700 hover:bg-cyan-800 text-white text-xs font-medium rounded transition-colors">
|
||
<span>전체 빌드</span>
|
||
<span class="text-[10px] opacity-60">(core:build --full)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 확장 공통 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🔗</span>
|
||
<span class="text-xs font-medium text-slate-200">확장 공통</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('extension:composer-install')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-violet-600 hover:bg-violet-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>Composer 전체 설치</span>
|
||
<span class="text-[10px] opacity-60">(extension:composer-install)</span>
|
||
</button>
|
||
<button onclick="runCommand('extension:update-autoload')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-violet-600 hover:bg-violet-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>오토로드 갱신</span>
|
||
<span class="text-[10px] opacity-60">(extension:update-autoload)</span>
|
||
</button>
|
||
<button onclick="runCommand('extension:clear-version-cache')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>버전 캐시 초기화</span>
|
||
<span class="text-[10px] opacity-60">(extension:clear-version-cache)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 캐시 & DB -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🧹</span>
|
||
<span class="text-xs font-medium text-slate-200">캐시 & DB</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('cache:clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>캐시 초기화</span>
|
||
<span class="text-[10px] opacity-60">(cache:clear)</span>
|
||
</button>
|
||
<button onclick="runCommand('config:clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>설정 캐시</span>
|
||
<span class="text-[10px] opacity-60">(config:clear)</span>
|
||
</button>
|
||
<button onclick="runCommand('route:clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>라우트 캐시</span>
|
||
<span class="text-[10px] opacity-60">(route:clear)</span>
|
||
</button>
|
||
<button onclick="runCommand('view:clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>뷰 캐시</span>
|
||
<span class="text-[10px] opacity-60">(view:clear)</span>
|
||
</button>
|
||
<button onclick="runCommand('optimize:clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-700 hover:bg-amber-800 text-white text-xs font-medium rounded transition-colors">
|
||
<span>전체 최적화</span>
|
||
<span class="text-[10px] opacity-60">(optimize:clear)</span>
|
||
</button>
|
||
<button onclick="runCommand('hooks:cache')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>훅 캐시 생성</span>
|
||
<span class="text-[10px] opacity-60">(hooks:cache)</span>
|
||
</button>
|
||
<button onclick="runCommand('hooks:clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>훅 캐시 삭제</span>
|
||
<span class="text-[10px] opacity-60">(hooks:clear)</span>
|
||
</button>
|
||
<button onclick="runCommand('migrate:fresh --seed')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>DB 초기화</span>
|
||
<span class="text-[10px] opacity-60">(migrate:fresh --seed)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 코어 업데이트 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🔄</span>
|
||
<span class="text-xs font-medium text-slate-200">코어 업데이트</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('core:check-updates')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>업데이트 확인</span>
|
||
<span class="text-[10px] opacity-60">(core:check-updates)</span>
|
||
</button>
|
||
<button onclick="if(confirm('코어 업데이트는 DB 마이그레이션과 파일 갱신을 수반합니다. 백업 후 진행하셨나요?')) runCommand('core:update --force')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>코어 업데이트</span>
|
||
<span class="text-[10px] opacity-60">(core:update --force) ⚠️</span>
|
||
</button>
|
||
<button onclick="runCommand('core:vendor-verify')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-fuchsia-600 hover:bg-fuchsia-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>코어 Vendor 검증</span>
|
||
<span class="text-[10px] opacity-60">(core:vendor-verify)</span>
|
||
</button>
|
||
<button onclick="runCommand('vendor-bundle:verify-all')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-fuchsia-700 hover:bg-fuchsia-800 text-white text-xs font-medium rounded transition-colors">
|
||
<span>전체 Vendor 검증</span>
|
||
<span class="text-[10px] opacity-60">(vendor-bundle:verify-all)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SEO -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🔍</span>
|
||
<span class="text-xs font-medium text-slate-200">SEO</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('seo:warmup')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>SEO 워밍업</span>
|
||
<span class="text-[10px] opacity-60">(seo:warmup)</span>
|
||
</button>
|
||
<button onclick="runCommand('seo:clear')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>SEO 캐시 삭제</span>
|
||
<span class="text-[10px] opacity-60">(seo:clear)</span>
|
||
</button>
|
||
<button onclick="runCommand('g7:asset-url-mode')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-slate-600 hover:bg-slate-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>자산 URL 방식 확인</span>
|
||
<span class="text-[10px] opacity-60">(g7:asset-url-mode)</span>
|
||
</button>
|
||
<button onclick="runCommand('seo:stats')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>SEO 통계</span>
|
||
<span class="text-[10px] opacity-60">(seo:stats)</span>
|
||
</button>
|
||
<button onclick="runCommand('seo:generate-sitemap --sync')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-teal-600 hover:bg-teal-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>사이트맵 생성</span>
|
||
<span class="text-[10px] opacity-60">(seo:generate-sitemap)</span>
|
||
</button>
|
||
<button onclick="runCommand('search:index')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>검색 인덱스 점검</span>
|
||
<span class="text-[10px] opacity-60">(search:index)</span>
|
||
</button>
|
||
<button onclick="runCommand('search:index --repair')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors" title="색인이 누락된 인덱스를 재생성합니다. 대상 인덱스가 잠기거나 재색인되므로 운영 중에는 유지보수 시간에 수행하세요.">
|
||
<span>검색 인덱스 재생성</span>
|
||
<span class="text-[10px] opacity-60">(search:index --repair)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- API 문서 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">📘</span>
|
||
<span class="text-xs font-medium text-slate-200">API 문서</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('api:docgen --scope=core --seed')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>코어 API 문서 생성(실측)</span>
|
||
<span class="text-[10px] opacity-60">(api:docgen --scope=core --seed)</span>
|
||
</button>
|
||
<button onclick="runCommand('api:docgen --scope=core --check')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>API 문서 drift 검사</span>
|
||
<span class="text-[10px] opacity-60">(api:docgen --check)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 유지보수 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🛠️</span>
|
||
<span class="text-xs font-medium text-slate-200">유지보수</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button onclick="runCommand('notification:cleanup')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>알림 정리</span>
|
||
<span class="text-[10px] opacity-60">(notification:cleanup)</span>
|
||
</button>
|
||
<button onclick="runCommand('layout-previews:cleanup')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>레이아웃 미리보기 정리</span>
|
||
<span class="text-[10px] opacity-60">(layout-previews:cleanup)</span>
|
||
</button>
|
||
<button onclick="runCommand('ext-bundles:cleanup')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>확장 번들 정리</span>
|
||
<span class="text-[10px] opacity-60">(ext-bundles:cleanup)</span>
|
||
</button>
|
||
<button onclick="runCommand('ext-static:publish --force')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>정적 게시 재생성</span>
|
||
<span class="text-[10px] opacity-60">(ext-static:publish)</span>
|
||
</button>
|
||
<button onclick="runCommand('ext-static:cleanup')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>정적 게시 정리</span>
|
||
<span class="text-[10px] opacity-60">(ext-static:cleanup)</span>
|
||
</button>
|
||
<button onclick="runCommand('ext-static:status')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>정적 게시 점검</span>
|
||
<span class="text-[10px] opacity-60">(ext-static:status)</span>
|
||
</button>
|
||
<button onclick="runCommand('trusted-proxy:status')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>신뢰 프록시 점검</span>
|
||
<span class="text-[10px] opacity-60">(trusted-proxy:status)</span>
|
||
</button>
|
||
<button onclick="runCommand('security:audit-dependencies')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>의존성 취약점 점검</span>
|
||
<span class="text-[10px] opacity-60">(security:audit-dependencies)</span>
|
||
</button>
|
||
<button onclick="runCommand('seo:prune-stats')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>SEO 캐시 통계 정리</span>
|
||
<span class="text-[10px] opacity-60">(seo:prune-stats)</span>
|
||
</button>
|
||
<button onclick="runCommand('schedules:prune-history')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>스케줄 이력 정리</span>
|
||
<span class="text-[10px] opacity-60">(schedules:prune-history)</span>
|
||
</button>
|
||
<button onclick="runCommand('activity-log:prune')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>활동 로그 정리</span>
|
||
<span class="text-[10px] opacity-60">(activity-log:prune)</span>
|
||
</button>
|
||
<button onclick="runCommand('notification-log:prune')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>알림 발송 이력 정리</span>
|
||
<span class="text-[10px] opacity-60">(notification-log:prune)</span>
|
||
</button>
|
||
<button onclick="runCommand('attachments:prune-orphans --dry-run')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>고아 첨부 정리 (미리보기)</span>
|
||
<span class="text-[10px] opacity-60">(attachments:prune-orphans --dry-run)</span>
|
||
</button>
|
||
<button onclick="runCommand('attachments:prune-orphans')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>고아 첨부 정리 (실삭제)</span>
|
||
<span class="text-[10px] opacity-60">(attachments:prune-orphans)</span>
|
||
</button>
|
||
<button onclick="runCommand('storage:prune-leftovers --dry-run')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>스토리지 잔존물 정리 (미리보기)</span>
|
||
<span class="text-[10px] opacity-60">(storage:prune-leftovers --dry-run)</span>
|
||
</button>
|
||
<button onclick="runCommand('storage:prune-leftovers')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>스토리지 잔존물 정리 (실행)</span>
|
||
<span class="text-[10px] opacity-60">(storage:prune-leftovers)</span>
|
||
</button>
|
||
<button onclick="runCommand('identity:expire-challenges')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>본인인증 challenge 만료 처리</span>
|
||
<span class="text-[10px] opacity-60">(identity:expire-challenges)</span>
|
||
</button>
|
||
<button onclick="runCommand('identity:prune-logs')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>본인인증 이력 파기</span>
|
||
<span class="text-[10px] opacity-60">(identity:prune-logs)</span>
|
||
</button>
|
||
<button onclick="runCommand('geoip:update')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>GeoIP DB 갱신</span>
|
||
<span class="text-[10px] opacity-60">(geoip:update)</span>
|
||
</button>
|
||
<button onclick="runCommand('dashboard:broadcast-resources')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-cyan-600 hover:bg-cyan-700 text-white text-xs font-medium rounded transition-colors">
|
||
<span>대시보드 리소스 브로드캐스트</span>
|
||
<span class="text-[10px] opacity-60">(dashboard:broadcast-resources)</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-10">
|
||
<!-- 왼쪽: 페이지 이동 -->
|
||
<div class="space-y-4">
|
||
<h2 class="text-sm font-medium text-slate-500 uppercase tracking-wider text-left">페이지 이동</h2>
|
||
|
||
<a href="{{ url('/admin') }}" class="card-hover block bg-gradient-to-br from-slate-800/50 to-slate-800/30 backdrop-blur-lg rounded-2xl p-5 border border-slate-700/40 shadow-xl relative overflow-hidden group">
|
||
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/0 to-cyan-500/0 group-hover:from-blue-500/10 group-hover:to-cyan-500/5 transition-all duration-300"></div>
|
||
<div class="relative z-10 flex items-center gap-4">
|
||
<div class="card-icon text-4xl shrink-0">⚙️</div>
|
||
<div class="text-left">
|
||
<div class="text-lg font-semibold text-slate-100 group-hover:text-blue-200 transition-colors">관리자 페이지</div>
|
||
<p class="text-sm text-slate-400 mt-0.5">시스템 관리 및 설정</p>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
|
||
<a href="{{ url('/install') }}" class="card-hover block bg-gradient-to-br from-slate-800/50 to-slate-800/30 backdrop-blur-lg rounded-2xl p-5 border border-slate-700/40 shadow-xl relative overflow-hidden group">
|
||
<div class="absolute inset-0 bg-gradient-to-br from-purple-500/0 to-pink-500/0 group-hover:from-purple-500/10 group-hover:to-pink-500/5 transition-all duration-300"></div>
|
||
<div class="relative z-10 flex items-center gap-4">
|
||
<div class="card-icon text-4xl shrink-0">📥</div>
|
||
<div class="text-left">
|
||
<div class="text-lg font-semibold text-slate-100 group-hover:text-purple-200 transition-colors">설치 페이지</div>
|
||
<p class="text-sm text-slate-400 mt-0.5">프로젝트 설치 및 구성</p>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
|
||
<!-- 오른쪽: 개발 도구 -->
|
||
<div class="space-y-4">
|
||
<h2 class="text-sm font-medium text-slate-500 uppercase tracking-wider text-left">개발 도구</h2>
|
||
|
||
<!-- 인스톨러 초기화 -->
|
||
<div class="card-hover bg-gradient-to-br from-slate-800/50 to-slate-800/30 backdrop-blur-lg rounded-2xl p-4 border border-slate-700/40 shadow-xl relative overflow-hidden group">
|
||
<div class="absolute inset-0 bg-gradient-to-br from-red-500/0 to-orange-500/0 group-hover:from-red-500/5 group-hover:to-orange-500/5 transition-all duration-300"></div>
|
||
<div class="relative z-10">
|
||
<div class="flex items-center gap-3 mb-3">
|
||
<div class="text-2xl shrink-0">🔄</div>
|
||
<div class="text-left flex-1">
|
||
<div class="text-base font-semibold text-slate-100">인스톨러 초기화</div>
|
||
<p class="text-xs text-slate-400">.env, storage 파일 삭제하여 설치 상태 초기화</p>
|
||
</div>
|
||
<button type="button" onclick="startReset()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-xs font-medium rounded-lg transition-colors shrink-0">
|
||
초기화
|
||
</button>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div class="flex items-center gap-2 text-xs text-slate-400 bg-slate-900/30 px-3 py-2 rounded-lg">
|
||
<span>vendor 폴더 삭제</span>
|
||
<label class="relative cursor-pointer ml-auto">
|
||
<input type="checkbox" id="deleteVendor" class="sr-only peer">
|
||
<div class="w-9 h-5 bg-slate-700 rounded-full peer peer-checked:bg-red-600 transition-colors"></div>
|
||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||
</label>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-xs text-slate-400 bg-slate-900/30 px-3 py-2 rounded-lg">
|
||
<span>확장 설치 디렉토리 삭제</span>
|
||
<span class="text-[10px] text-slate-500">(_bundled, _pending 보존)</span>
|
||
<label class="relative cursor-pointer ml-auto">
|
||
<input type="checkbox" id="deleteExtensions" class="sr-only peer">
|
||
<div class="w-9 h-5 bg-slate-700 rounded-full peer peer-checked:bg-red-600 transition-colors"></div>
|
||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||
</label>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-xs text-slate-400 bg-slate-900/30 px-3 py-2 rounded-lg">
|
||
<span>DB 테이블 전체 삭제</span>
|
||
<span class="text-[10px] text-slate-500">(migrations 포함)</span>
|
||
<label class="relative cursor-pointer ml-auto">
|
||
<input type="checkbox" id="dropTables" class="sr-only peer">
|
||
<div class="w-9 h-5 bg-slate-700 rounded-full peer peer-checked:bg-red-600 transition-colors"></div>
|
||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||
</label>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-xs text-slate-400 bg-slate-900/30 px-3 py-2 rounded-lg">
|
||
<span>환경설정 파일 삭제</span>
|
||
<span class="text-[10px] text-slate-500">(코어, 모듈, 플러그인)</span>
|
||
<label class="relative cursor-pointer ml-auto">
|
||
<input type="checkbox" id="deleteSettings" class="sr-only peer">
|
||
<div class="w-9 h-5 bg-slate-700 rounded-full peer peer-checked:bg-red-600 transition-colors"></div>
|
||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||
</label>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-xs text-slate-400 bg-slate-900/30 px-3 py-2 rounded-lg">
|
||
<span>첨부파일 스토리지 삭제</span>
|
||
<span class="text-[10px] text-slate-500">(업로드 파일 전체)</span>
|
||
<label class="relative cursor-pointer ml-auto">
|
||
<input type="checkbox" id="deleteAttachments" class="sr-only peer">
|
||
<div class="w-9 h-5 bg-slate-700 rounded-full peer peer-checked:bg-red-600 transition-colors"></div>
|
||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 컴포넌트 미리보기 (아코디언) - 전체 너비 -->
|
||
<div class="bg-gradient-to-br from-slate-800/50 to-slate-800/30 backdrop-blur-lg rounded-2xl border border-slate-700/40 shadow-xl relative overflow-hidden">
|
||
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/0 to-cyan-500/0 transition-all duration-300"></div>
|
||
<div class="relative z-10">
|
||
<!-- 아코디언 헤더 -->
|
||
<button type="button" onclick="toggleComponentPreview()" class="w-full flex items-center gap-3 p-4 text-left hover:bg-slate-700/20 transition-colors rounded-2xl">
|
||
<div class="text-2xl shrink-0">🧪</div>
|
||
<div class="flex-1">
|
||
<div class="text-base font-semibold text-slate-100">컴포넌트 미리보기</div>
|
||
<p class="text-xs text-slate-400">개발 중인 UI 컴포넌트 테스트</p>
|
||
</div>
|
||
<svg id="componentPreviewArrow" class="w-5 h-5 text-slate-400 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 아코디언 콘텐츠 (기본 닫힘) - 2단 그리드 -->
|
||
<div id="componentPreviewContent" class="hidden border-t border-slate-700/30">
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
|
||
|
||
<!-- Toggle 컴포넌트 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🔘</span>
|
||
<span class="text-sm font-medium text-slate-200">Toggle</span>
|
||
<span class="text-[10px] text-slate-500 bg-slate-800 px-2 py-0.5 rounded">Flowbite 스타일</span>
|
||
</div>
|
||
|
||
<!-- Props 설명 -->
|
||
<div class="bg-slate-800/30 rounded-lg p-2 mb-3 text-[10px] text-slate-400">
|
||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||
<div><span class="text-blue-400">checked/value</span>: 체크 상태</div>
|
||
<div><span class="text-blue-400">onChange</span>: 변경 콜백</div>
|
||
<div><span class="text-blue-400">disabled</span>: 비활성화</div>
|
||
<div><span class="text-blue-400">size</span>: sm | md | lg</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-3" id="toggleDemo">
|
||
<!-- 기본 토글 (크기별) -->
|
||
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/30">
|
||
<label class="block text-xs font-medium text-blue-400 mb-2">
|
||
📏 크기별 토글
|
||
</label>
|
||
<div class="space-y-3">
|
||
<!-- Small -->
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-slate-300">Small (sm)</span>
|
||
<label class="relative inline-flex items-center cursor-pointer">
|
||
<input type="checkbox" class="toggle-small sr-only peer">
|
||
<div class="relative w-9 h-5 bg-gray-200 dark:bg-gray-700 rounded-full peer peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-600 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white transition-colors"></div>
|
||
</label>
|
||
</div>
|
||
<!-- Medium -->
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-slate-300">Medium (md) - 기본</span>
|
||
<label class="relative inline-flex items-center cursor-pointer">
|
||
<input type="checkbox" class="toggle-medium sr-only peer" checked>
|
||
<div class="relative w-11 h-6 bg-gray-200 dark:bg-gray-700 rounded-full peer peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-600 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white transition-colors"></div>
|
||
</label>
|
||
</div>
|
||
<!-- Large -->
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-slate-300">Large (lg)</span>
|
||
<label class="relative inline-flex items-center cursor-pointer">
|
||
<input type="checkbox" class="toggle-large sr-only peer">
|
||
<div class="relative w-14 h-7 bg-gray-200 dark:bg-gray-700 rounded-full peer peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-600 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white transition-colors"></div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 라벨과 설명이 있는 토글 -->
|
||
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/30">
|
||
<label class="block text-xs font-medium text-emerald-400 mb-2">
|
||
📝 라벨 & 설명
|
||
</label>
|
||
<div class="flex items-center">
|
||
<div class="flex-1 mr-4">
|
||
<label for="toggle-notifications" class="block text-sm font-medium text-gray-900 dark:text-white cursor-pointer">
|
||
알림 활성화
|
||
</label>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
이 옵션을 활성화하면 시스템 알림을 받습니다
|
||
</p>
|
||
</div>
|
||
<label class="relative inline-flex items-center cursor-pointer">
|
||
<input type="checkbox" id="toggle-notifications" class="toggle-notifications sr-only peer" checked>
|
||
<div class="relative w-11 h-6 bg-gray-200 dark:bg-gray-700 rounded-full peer peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-600 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white transition-colors"></div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Disabled 상태 -->
|
||
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/30">
|
||
<label class="block text-xs font-medium text-slate-500 mb-2">
|
||
🚫 비활성화 상태
|
||
</label>
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-xs text-slate-500">Disabled Toggle</span>
|
||
<label class="relative inline-flex items-center cursor-not-allowed opacity-50">
|
||
<input type="checkbox" class="sr-only peer" disabled>
|
||
<div class="relative w-11 h-6 bg-gray-200 dark:bg-gray-700 rounded-full peer peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 dark:after:border-gray-600 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white transition-colors"></div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="text-[10px] text-slate-500 mt-3">💡 라이트/다크 모드 모두 지원 | onChange 이벤트 객체 전달</p>
|
||
</div>
|
||
<!-- /Toggle 컴포넌트 -->
|
||
|
||
<!-- TagInput 컴포넌트 -->
|
||
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">🏷️</span>
|
||
<span class="text-sm font-medium text-slate-200">TagInput</span>
|
||
<span class="text-[10px] text-slate-500 bg-slate-800 px-2 py-0.5 rounded">react-select 기반</span>
|
||
</div>
|
||
|
||
<!-- Props 설명 -->
|
||
<div class="bg-slate-800/30 rounded-lg p-2 mb-3 text-[10px] text-slate-400">
|
||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||
<div><span class="text-emerald-400">creatable</span>: 새 항목 추가 가능</div>
|
||
<div><span class="text-emerald-400">maxItems</span>: 최대 선택 개수</div>
|
||
<div><span class="text-emerald-400">onBeforeRemove</span>: 삭제 전 확인</div>
|
||
<div><span class="text-emerald-400">onCreateOption</span>: 생성 콜백</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<!-- 분류 (Creatable) -->
|
||
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/30">
|
||
<label class="block text-xs font-medium text-emerald-400 mb-2">
|
||
📁 분류 <span class="text-slate-500 font-normal">creatable={true}</span>
|
||
</label>
|
||
<div class="tag-input-container" data-creatable="true" data-name="categories">
|
||
<div class="flex flex-wrap gap-1.5 p-2 bg-slate-800 rounded-lg border border-slate-600 focus-within:border-emerald-500 transition-colors min-h-[42px]">
|
||
<span class="tag-chip bg-emerald-600/30 text-emerald-300 px-2 py-1 rounded text-xs flex items-center gap-1">
|
||
A/S <span class="text-emerald-400/60 text-[10px]">(5)</span>
|
||
<button type="button" class="tag-remove hover:text-red-400 ml-0.5" data-value="A/S" data-count="5">×</button>
|
||
</span>
|
||
<span class="tag-chip bg-emerald-600/30 text-emerald-300 px-2 py-1 rounded text-xs flex items-center gap-1">
|
||
반품
|
||
<button type="button" class="tag-remove hover:text-red-400 ml-0.5" data-value="반품" data-count="0">×</button>
|
||
</span>
|
||
<input type="text" class="tag-input flex-1 min-w-[120px] bg-transparent text-slate-200 text-xs outline-none placeholder-slate-500" placeholder="검색...">
|
||
</div>
|
||
<div class="tag-dropdown hidden mt-1 bg-slate-800 border border-slate-600 rounded-lg shadow-xl max-h-48 overflow-y-auto">
|
||
<!-- 동적으로 채워짐 -->
|
||
</div>
|
||
</div>
|
||
<p class="text-[10px] text-slate-500 mt-1.5">💡 count > 0이면 삭제 차단 (onBeforeRemove)</p>
|
||
</div>
|
||
|
||
<!-- 역할 (Select Only) -->
|
||
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/30">
|
||
<label class="block text-xs font-medium text-purple-400 mb-2">
|
||
👥 역할 <span class="text-slate-500 font-normal">creatable={false}</span>
|
||
</label>
|
||
<div class="tag-input-container" data-creatable="false" data-name="roles">
|
||
<div class="flex flex-wrap gap-1.5 p-2 bg-slate-800 rounded-lg border border-slate-600 focus-within:border-purple-500 transition-colors min-h-[42px]">
|
||
<span class="tag-chip bg-purple-600/30 text-purple-300 px-2 py-1 rounded text-xs flex items-center gap-1">
|
||
회원
|
||
<button type="button" class="tag-remove hover:text-red-400 ml-0.5" data-value="1">×</button>
|
||
</span>
|
||
<span class="tag-chip bg-purple-600/30 text-purple-300 px-2 py-1 rounded text-xs flex items-center gap-1">
|
||
콘텐츠 관리자
|
||
<button type="button" class="tag-remove hover:text-red-400 ml-0.5" data-value="2">×</button>
|
||
</span>
|
||
<input type="text" class="tag-input flex-1 min-w-[120px] bg-transparent text-slate-200 text-xs outline-none placeholder-slate-500" placeholder="검색...">
|
||
</div>
|
||
<div class="tag-dropdown hidden mt-1 bg-slate-800 border border-slate-600 rounded-lg shadow-xl max-h-48 overflow-y-auto">
|
||
<!-- 동적으로 채워짐 -->
|
||
</div>
|
||
</div>
|
||
<p class="text-[10px] text-slate-500 mt-1.5">💡 체크박스 다중 선택 | 새 항목 추가 불가</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- /TagInput 컴포넌트 -->
|
||
|
||
<!-- 추가 컴포넌트는 여기에 추가 -->
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</main>
|
||
|
||
<!-- 하단 푸터 -->
|
||
<footer class="bg-slate-800/30 border-t border-slate-700/30 px-6 py-4">
|
||
<div class="max-w-6xl mx-auto">
|
||
<div class="flex flex-wrap items-center justify-center gap-2 text-xs mb-3">
|
||
<span class="inline-flex items-center gap-1.5 bg-slate-900/50 px-3 py-1 rounded-full">
|
||
<span class="text-blue-400">🐘</span>
|
||
<span class="text-slate-300">PHP {{ phpversion() }}</span>
|
||
</span>
|
||
<span class="inline-flex items-center gap-1.5 bg-slate-900/50 px-3 py-1 rounded-full">
|
||
<span class="text-red-400">🔺</span>
|
||
<span class="text-slate-300">Laravel {{ app()->version() }}</span>
|
||
</span>
|
||
<span class="inline-flex items-center gap-1.5 bg-slate-900/50 px-3 py-1 rounded-full">
|
||
<span class="text-orange-400">🌐</span>
|
||
<span class="text-slate-300">{{ php_sapi_name() === 'cli' ? 'CLI' : (isset($_SERVER['SERVER_SOFTWARE']) ? explode('/', $_SERVER['SERVER_SOFTWARE'])[0] : 'Unknown') }}</span>
|
||
</span>
|
||
<span class="inline-flex items-center gap-1.5 bg-slate-900/50 px-3 py-1 rounded-full">
|
||
<span class="text-cyan-400">🗄️</span>
|
||
<span class="text-slate-300">{{ env('DB_WRITE_DATABASE', env('DB_DATABASE', 'N/A')) }}</span>
|
||
</span>
|
||
<span class="inline-flex items-center gap-1.5 bg-slate-900/50 px-3 py-1 rounded-full {{ env('APP_ENV') === 'production' ? 'text-red-400' : 'text-yellow-400' }}">
|
||
<span>🏷️</span>
|
||
<span>{{ env('APP_ENV', 'local') }}</span>
|
||
</span>
|
||
<span class="inline-flex items-center gap-1.5 bg-slate-900/50 px-3 py-1 rounded-full {{ env('APP_DEBUG') ? 'text-green-400' : 'text-slate-500' }}">
|
||
<span>🐛</span>
|
||
<span>Debug {{ env('APP_DEBUG') ? 'ON' : 'OFF' }}</span>
|
||
</span>
|
||
</div>
|
||
<div class="text-center text-slate-500 text-xs">
|
||
© {{ date('Y') }} {{ config('app.name', '그누보드7') }}
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
<script>
|
||
async function startReset() {
|
||
const deleteVendor = document.getElementById('deleteVendor').checked;
|
||
const deleteExtensions = document.getElementById('deleteExtensions').checked;
|
||
const dropTables = document.getElementById('dropTables').checked;
|
||
const deleteSettings = document.getElementById('deleteSettings').checked;
|
||
const deleteAttachments = document.getElementById('deleteAttachments').checked;
|
||
let message = '⚠️ 인스톨러 초기화\n\n';
|
||
message += '다음 항목이 삭제됩니다:\n';
|
||
message += ' • .env\n';
|
||
message += ' • storage/app/g7_installed\n';
|
||
message += ' • storage/installer-state.json\n';
|
||
if (deleteVendor) {
|
||
message += ' • vendor/ (.gitkeep 보존)\n';
|
||
}
|
||
if (deleteExtensions) {
|
||
message += ' • modules/ 설치 디렉토리 (_bundled, _pending 보존)\n';
|
||
message += ' • plugins/ 설치 디렉토리 (_bundled, _pending 보존)\n';
|
||
message += ' • templates/ 설치 디렉토리 (_bundled, _pending 보존)\n';
|
||
}
|
||
if (dropTables) {
|
||
message += ' • DB 테이블 전체 삭제 (migrations 포함)\n';
|
||
}
|
||
if (deleteSettings) {
|
||
message += ' • storage/app/settings/ (코어 환경설정)\n';
|
||
message += ' • storage/app/modules/*/settings/ (모듈 환경설정)\n';
|
||
message += ' • storage/app/plugins/*/settings/ (플러그인 환경설정)\n';
|
||
message += ' ※ 템플릿은 파일 환경설정이 없음 (DB/패키지 보관)\n';
|
||
}
|
||
if (deleteAttachments) {
|
||
message += ' • storage/app/attachments/ (첨부파일)\n';
|
||
message += ' • storage/app/public/ (공개 업로드 파일)\n';
|
||
message += ' • storage/app/modules|plugins/*/ 업로드 파일 (settings 보존)\n';
|
||
}
|
||
message += '\n계속하시겠습니까?';
|
||
|
||
if (!confirm(message)) return;
|
||
|
||
const panel = document.getElementById('progressPanel');
|
||
const content = document.getElementById('progressContent');
|
||
document.getElementById('progressTitle').textContent = '🔄 인스톨러 초기화';
|
||
content.innerHTML = '<div class="text-slate-400 text-[11px]">⏳ 초기화 진행 중...</div>';
|
||
panel.classList.remove('hidden');
|
||
|
||
try {
|
||
const url = `${window.location.pathname}?ajax_action=reset&delete_vendor=${deleteVendor ? '1' : '0'}&delete_extensions=${deleteExtensions ? '1' : '0'}&drop_tables=${dropTables ? '1' : '0'}&delete_settings=${deleteSettings ? '1' : '0'}&delete_attachments=${deleteAttachments ? '1' : '0'}`;
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
|
||
// 결과 표시
|
||
let html = '<div class="space-y-1">';
|
||
html += '<div class="font-medium text-slate-200 text-[11px] mb-2">📁 파일 초기화 결과:</div>';
|
||
|
||
const statusIcons = { deleted: '✅', failed: '❌', notfound: '⏭️' };
|
||
const statusTexts = { deleted: '삭제됨', failed: '삭제 실패', notfound: '없음' };
|
||
const statusColors = { deleted: 'text-emerald-400', failed: 'text-red-400', notfound: 'text-slate-500' };
|
||
|
||
data.results.forEach(item => {
|
||
html += `<div class="flex items-center gap-2 ${statusColors[item.status]} text-[11px]">
|
||
<span>${statusIcons[item.status]}</span>
|
||
<span>${statusTexts[item.status]}:</span>
|
||
<code class="bg-slate-900/50 px-1.5 py-0.5 rounded text-cyan-300 font-mono">${item.name}</code>
|
||
</div>`;
|
||
});
|
||
|
||
html += '</div>';
|
||
|
||
// 완료 메시지 및 버튼
|
||
html += `<div class="mt-3 pt-3 border-t border-slate-700/50">
|
||
<div class="text-emerald-400 text-[11px] font-medium mb-2">🎉 초기화 완료!</div>
|
||
<a href="/install" class="inline-flex items-center gap-1.5 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium px-3 py-1.5 rounded-lg transition-colors">
|
||
<span>📥</span> 설치 페이지로 이동
|
||
</a>
|
||
</div>`;
|
||
|
||
content.innerHTML = html;
|
||
} catch (error) {
|
||
content.innerHTML = `<div class="text-red-400 text-[11px]">❌ 오류 발생: ${error.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function closeProgress() {
|
||
document.getElementById('progressPanel').classList.add('hidden');
|
||
}
|
||
|
||
// 컴포넌트 미리보기 아코디언 토글
|
||
function toggleComponentPreview() {
|
||
const content = document.getElementById('componentPreviewContent');
|
||
const arrow = document.getElementById('componentPreviewArrow');
|
||
|
||
if (content.classList.contains('hidden')) {
|
||
content.classList.remove('hidden');
|
||
arrow.classList.add('rotate-180');
|
||
} else {
|
||
content.classList.add('hidden');
|
||
arrow.classList.remove('rotate-180');
|
||
}
|
||
}
|
||
|
||
/** 화면이 커맨드 응답을 기다리는 기본 시간 (ms) */
|
||
const DEFAULT_COMMAND_TIMEOUT = 60000;
|
||
|
||
/**
|
||
* 기본 대기 시간 안에 끝나지 않는 커맨드의 개별 대기 시간 (ms).
|
||
*
|
||
* 잠금파일 전수를 레지스트리에 물어보는 점검처럼, 실행 자체가 기본 대기 시간에
|
||
* 근접하거나 넘는 커맨드가 있다. 화면이 먼저 포기하면 운영자에게는 타임아웃만
|
||
* 남고 결과가 도달하지 않는다 — 서버에서는 점검이 정상 수행 중인데도 화면만
|
||
* 보면 "점검이 실패했다" 로 읽힌다.
|
||
*/
|
||
const COMMAND_TIMEOUTS = {
|
||
'security:audit-dependencies': 300000,
|
||
};
|
||
|
||
/**
|
||
* 커맨드 문자열에서 대기 시간을 정한다. 옵션은 무시하고 커맨드 이름으로만 찾는다.
|
||
*
|
||
* @param {string} command 실행할 Artisan 커맨드 (옵션 포함 가능)
|
||
* @returns {number} 대기 시간 (ms)
|
||
*/
|
||
function resolveCommandTimeout(command) {
|
||
const name = String(command).trim().split(/\s+/)[0];
|
||
|
||
return COMMAND_TIMEOUTS[name] ?? DEFAULT_COMMAND_TIMEOUT;
|
||
}
|
||
|
||
async function runArtisanCommand(command, stepName, timeoutMs = null) {
|
||
const limit = timeoutMs ?? resolveCommandTimeout(command);
|
||
|
||
try {
|
||
const url = `${window.location.pathname}?ajax_action=artisan&command=${encodeURIComponent(command)}`;
|
||
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), limit);
|
||
|
||
const response = await fetch(url, { signal: controller.signal });
|
||
clearTimeout(timeoutId);
|
||
|
||
// HTTP 상태 코드 검증
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
return { ...result, stepName };
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') {
|
||
return { success: false, output: `⏱️ 타임아웃 (${Math.round(limit / 1000)}초 초과)`, stepName };
|
||
}
|
||
return { success: false, output: '❌ ' + error.message, stepName };
|
||
}
|
||
}
|
||
|
||
// Manager 직접 호출 (uninstall용)
|
||
async function runUninstall(type, identifier, stepName) {
|
||
try {
|
||
const actionMap = { template: 'template_uninstall', module: 'module_uninstall', plugin: 'plugin_uninstall' };
|
||
const action = actionMap[type] || 'module_uninstall';
|
||
const url = `${window.location.pathname}?ajax_action=${action}&identifier=${encodeURIComponent(identifier)}`;
|
||
|
||
// 타임아웃 설정 (30초)
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||
|
||
const response = await fetch(url, { signal: controller.signal });
|
||
clearTimeout(timeoutId);
|
||
|
||
// HTTP 상태 코드 검증
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
return { ...result, stepName };
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') {
|
||
return { success: false, output: '⏱️ 타임아웃 (30초 초과)', stepName };
|
||
}
|
||
return { success: false, output: '❌ ' + error.message, stepName };
|
||
}
|
||
}
|
||
|
||
function addProgressStep(stepName, status, output) {
|
||
const content = document.getElementById('progressContent');
|
||
const statusIcon = status === 'running' ? '⏳' : (status === 'success' ? '✅' : (status === 'warning' ? '⚠️' : '❌'));
|
||
const statusColor = status === 'running' ? 'text-amber-400' : (status === 'success' ? 'text-emerald-400' : (status === 'warning' ? 'text-yellow-400' : 'text-red-400'));
|
||
|
||
const stepId = 'step-' + stepName.replace(/\s+/g, '-');
|
||
let stepEl = document.getElementById(stepId);
|
||
|
||
if (!stepEl) {
|
||
stepEl = document.createElement('div');
|
||
stepEl.id = stepId;
|
||
stepEl.className = 'bg-slate-900/50 rounded p-2';
|
||
content.appendChild(stepEl);
|
||
}
|
||
|
||
stepEl.innerHTML = `
|
||
<div class="flex items-center gap-1.5 ${statusColor} font-medium text-[11px]">
|
||
<span>${statusIcon}</span> ${stepName}
|
||
</div>
|
||
${output ? `<pre class="text-[10px] text-slate-400 mt-1 whitespace-pre-wrap leading-relaxed">${output}</pre>` : ''}
|
||
`;
|
||
|
||
content.scrollTop = content.scrollHeight;
|
||
}
|
||
|
||
async function startReactivate() {
|
||
const selected = document.querySelector('input[name="extension_identifier"]:checked');
|
||
if (!selected) {
|
||
alert('재활성화할 항목을 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
const identifier = selected.value;
|
||
const type = selected.dataset.type;
|
||
const typeName = { module: '모듈', template: '템플릿', plugin: '플러그인' }[type] || type;
|
||
|
||
if (!confirm(`"${identifier}" ${typeName}을(를) 재활성화하시겠습니까?\n\n(deactivate → activate → 캐시 초기화)`)) return;
|
||
|
||
const panel = document.getElementById('progressPanel');
|
||
const content = document.getElementById('progressContent');
|
||
document.getElementById('progressTitle').textContent = `⚡ ${identifier} ${typeName} 재활성화`;
|
||
content.innerHTML = '';
|
||
panel.classList.remove('hidden');
|
||
|
||
let allSuccess = true;
|
||
let failedStep = null;
|
||
|
||
const steps = [
|
||
{ name: '1. 비활성화 (deactivate )', cmd: `${type}:deactivate ${identifier}` },
|
||
{ name: '2. 활성화 (activate)', cmd: `${type}:activate ${identifier}` },
|
||
{ name: '3. 캐시 초기화', cmd: 'cache:clear' },
|
||
];
|
||
|
||
for (const step of steps) {
|
||
addProgressStep(step.name, 'running', '처리 중...');
|
||
|
||
const result = await runArtisanCommand(step.cmd, step.name);
|
||
|
||
// 비활성화 단계는 실패해도 계속 진행 (이미 비활성화 상태일 수 있음)
|
||
if (step.name === '1. 비활성화 (deactivate )') {
|
||
addProgressStep(step.name, 'success',
|
||
result.success ? result.output : '⏭️ 이미 비활성화 상태 (스킵)');
|
||
} else {
|
||
addProgressStep(step.name, result.success ? 'success' : 'error', result.output);
|
||
|
||
if (!result.success) {
|
||
allSuccess = false;
|
||
failedStep = step.name;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 캐시 초기화 추가 명령어
|
||
if (step.name === '3. 캐시 초기화') {
|
||
await runArtisanCommand('config:clear', '');
|
||
await runArtisanCommand('route:clear', '');
|
||
await runArtisanCommand('view:clear', '');
|
||
}
|
||
}
|
||
|
||
// 최종 결과
|
||
const finalEl = document.createElement('div');
|
||
finalEl.className = allSuccess
|
||
? 'mt-2 p-2 bg-emerald-900/30 border border-emerald-700/50 rounded text-emerald-300 text-[11px]'
|
||
: 'mt-2 p-2 bg-red-900/30 border border-red-700/50 rounded text-red-300 text-[11px]';
|
||
finalEl.textContent = allSuccess
|
||
? `🎉 ${identifier} ${typeName} 재활성화 완료!`
|
||
: `❌ "${failedStep}" 단계에서 오류 발생으로 중단됨`;
|
||
content.appendChild(finalEl);
|
||
}
|
||
|
||
async function startReinstall() {
|
||
const selected = document.querySelector('input[name="extension_identifier"]:checked');
|
||
if (!selected) {
|
||
alert('재설치할 항목을 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
const identifier = selected.value;
|
||
const type = selected.dataset.type;
|
||
const typeName = { module: '모듈', template: '템플릿', plugin: '플러그인' }[type] || type;
|
||
|
||
if (!confirm(`"${identifier}" ${typeName}을(를) 재설치하시겠습니까?`)) return;
|
||
|
||
const panel = document.getElementById('progressPanel');
|
||
const content = document.getElementById('progressContent');
|
||
document.getElementById('progressTitle').textContent = `📦 ${identifier} ${typeName} 재설치`;
|
||
content.innerHTML = '';
|
||
panel.classList.remove('hidden');
|
||
|
||
let allSuccess = true;
|
||
let failedStep = null;
|
||
|
||
// 1. 기존 제거 (Manager 직접 호출)
|
||
const uninstallStep = '1. 기존 제거 (uninstall)';
|
||
addProgressStep(uninstallStep, 'running', '처리 중...');
|
||
const uninstallResult = await runUninstall(type, identifier, uninstallStep);
|
||
|
||
// uninstall 결과 상세 표시
|
||
if (uninstallResult.success) {
|
||
addProgressStep(uninstallStep, 'success', '제거 완료');
|
||
} else {
|
||
// 출력 메시지에서 "not installed" 등의 문구가 있으면 스킵으로 처리
|
||
const isNotInstalled = uninstallResult.output &&
|
||
(uninstallResult.output.includes('not installed') ||
|
||
uninstallResult.output.includes('설치되지 않음'));
|
||
|
||
if (isNotInstalled) {
|
||
addProgressStep(uninstallStep, 'success', '⏭️ 이미 제거 상태 (스킵)');
|
||
} else {
|
||
// 실제 에러는 경고로 표시하되 계속 진행
|
||
addProgressStep(uninstallStep, 'warning', `⚠️ 제거 중 오류 (계속 진행): ${uninstallResult.output || '알 수 없는 오류'}`);
|
||
}
|
||
}
|
||
// uninstall은 실패해도 계속 진행 (미설치 상태일 수 있음)
|
||
|
||
// 2~4. 나머지 단계
|
||
const steps = [
|
||
{ name: '2. 새로 설치 (install)', cmd: `${type}:install ${identifier}` },
|
||
{ name: '3. 활성화 (activate)', cmd: `${type}:activate ${identifier}` },
|
||
{ name: '4. 캐시 초기화', cmd: 'cache:clear' },
|
||
];
|
||
|
||
for (const step of steps) {
|
||
addProgressStep(step.name, 'running', '처리 중...');
|
||
|
||
const result = await runArtisanCommand(step.cmd, step.name);
|
||
addProgressStep(step.name, result.success ? 'success' : 'error', result.output);
|
||
|
||
if (!result.success) {
|
||
allSuccess = false;
|
||
failedStep = step.name;
|
||
break;
|
||
}
|
||
|
||
// 캐시 초기화 추가 명령어
|
||
if (step.name === '4. 캐시 초기화') {
|
||
await runArtisanCommand('config:clear', '');
|
||
await runArtisanCommand('route:clear', '');
|
||
await runArtisanCommand('view:clear', '');
|
||
}
|
||
}
|
||
|
||
// 최종 결과 (모든 단계 완료 후 표시)
|
||
setTimeout(() => {
|
||
const finalEl = document.createElement('div');
|
||
finalEl.className = allSuccess
|
||
? 'mt-2 p-2 bg-emerald-900/30 border border-emerald-700/50 rounded text-emerald-300 text-[11px]'
|
||
: 'mt-2 p-2 bg-red-900/30 border border-red-700/50 rounded text-red-300 text-[11px]';
|
||
finalEl.textContent = allSuccess
|
||
? `🎉 ${identifier} ${typeName} 재설치 완료!`
|
||
: `❌ "${failedStep}" 단계에서 오류 발생으로 중단됨`;
|
||
content.appendChild(finalEl);
|
||
}, 100);
|
||
}
|
||
|
||
|
||
// ==========================================
|
||
// TagInput 컴포넌트 미리보기 (데모용)
|
||
// ==========================================
|
||
|
||
// 샘플 데이터
|
||
const tagInputData = {
|
||
categories: {
|
||
options: [
|
||
{ value: 'A/S', label: 'A/S', count: 5 },
|
||
{ value: '반품', label: '반품', count: 0 },
|
||
{ value: '교환', label: '교환', count: 3 },
|
||
{ value: '배송', label: '배송', count: 12 },
|
||
{ value: '결제', label: '결제', count: 0 },
|
||
{ value: '테스트', label: '테스트', count: 0 },
|
||
],
|
||
selected: ['A/S', '반품'],
|
||
color: 'emerald'
|
||
},
|
||
roles: {
|
||
options: [
|
||
{ value: '1', label: '회원' },
|
||
{ value: '2', label: '콘텐츠 관리자' },
|
||
{ value: '3', label: '마케팅 담당자' },
|
||
{ value: '4', label: '운영자' },
|
||
{ value: '5', label: '편집자' },
|
||
{ value: '6', label: '최고관리자' },
|
||
],
|
||
selected: ['1', '2'],
|
||
color: 'purple'
|
||
}
|
||
};
|
||
|
||
// 드롭다운 렌더링
|
||
function renderDropdown(container, searchText = '') {
|
||
const name = container.dataset.name;
|
||
const creatable = container.dataset.creatable === 'true';
|
||
const data = tagInputData[name];
|
||
const dropdown = container.querySelector('.tag-dropdown');
|
||
const color = data.color;
|
||
|
||
let filtered = data.options.filter(opt =>
|
||
opt.label.toLowerCase().includes(searchText.toLowerCase())
|
||
);
|
||
|
||
let html = '';
|
||
|
||
if (creatable) {
|
||
// 분류용: 클릭으로 선택 + 새로 추가 옵션
|
||
filtered.forEach(opt => {
|
||
const isSelected = data.selected.includes(opt.value);
|
||
const countText = opt.count !== undefined ? ` <span class="text-slate-500">(${opt.count})</span>` : '';
|
||
html += `
|
||
<div class="tag-option flex items-center gap-2 px-3 py-2 hover:bg-slate-700/50 cursor-pointer ${isSelected ? 'bg-' + color + '-900/30' : ''}"
|
||
data-value="${opt.value}" data-label="${opt.label}" data-count="${opt.count || 0}">
|
||
<span class="w-4 h-4 rounded border ${isSelected ? 'bg-' + color + '-500 border-' + color + '-500' : 'border-slate-500'} flex items-center justify-center text-white text-[10px]">
|
||
${isSelected ? '✓' : ''}
|
||
</span>
|
||
<span class="text-slate-200 text-xs flex-1">${opt.label}${countText}</span>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
// 새로 추가 옵션 (검색어가 있고, 기존 옵션에 없을 때)
|
||
if (searchText && !data.options.some(opt => opt.label.toLowerCase() === searchText.toLowerCase())) {
|
||
html += `
|
||
<div class="tag-option-create flex items-center gap-2 px-3 py-2 hover:bg-emerald-900/30 cursor-pointer border-t border-slate-700"
|
||
data-value="${searchText}" data-label="${searchText}">
|
||
<span class="text-emerald-400 text-xs">+</span>
|
||
<span class="text-emerald-300 text-xs">"${searchText}" 추가</span>
|
||
</div>
|
||
`;
|
||
}
|
||
} else {
|
||
// 역할용: 체크박스 다중 선택
|
||
filtered.forEach(opt => {
|
||
const isSelected = data.selected.includes(opt.value);
|
||
html += `
|
||
<div class="tag-option flex items-center gap-2 px-3 py-2 hover:bg-slate-700/50 cursor-pointer"
|
||
data-value="${opt.value}" data-label="${opt.label}">
|
||
<input type="checkbox" ${isSelected ? 'checked' : ''} class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-purple-500 focus:ring-purple-500">
|
||
<span class="text-slate-200 text-xs">${opt.label}</span>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
if (!html) {
|
||
html = '<div class="px-3 py-2 text-slate-500 text-xs">검색 결과가 없습니다</div>';
|
||
}
|
||
|
||
dropdown.innerHTML = html;
|
||
dropdown.classList.remove('hidden');
|
||
|
||
// 이벤트 바인딩
|
||
dropdown.querySelectorAll('.tag-option, .tag-option-create').forEach(el => {
|
||
el.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const value = el.dataset.value;
|
||
const label = el.dataset.label;
|
||
const count = el.dataset.count || 0;
|
||
const isCreate = el.classList.contains('tag-option-create');
|
||
|
||
if (isCreate) {
|
||
// 새 옵션 추가
|
||
data.options.push({ value, label, count: 0 });
|
||
}
|
||
|
||
// 토글
|
||
const idx = data.selected.indexOf(value);
|
||
if (idx > -1) {
|
||
data.selected.splice(idx, 1);
|
||
} else {
|
||
data.selected.push(value);
|
||
}
|
||
|
||
// 새 항목 추가 시 입력창 초기화
|
||
if (isCreate) {
|
||
container.querySelector('.tag-input').value = '';
|
||
}
|
||
|
||
renderTags(container);
|
||
renderDropdown(container, container.querySelector('.tag-input').value);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 태그 칩 렌더링
|
||
function renderTags(container) {
|
||
const name = container.dataset.name;
|
||
const data = tagInputData[name];
|
||
const color = data.color;
|
||
const wrapper = container.querySelector('.flex.flex-wrap');
|
||
const input = wrapper.querySelector('.tag-input');
|
||
|
||
// 기존 태그 제거
|
||
wrapper.querySelectorAll('.tag-chip').forEach(el => el.remove());
|
||
|
||
// 선택된 태그 추가
|
||
data.selected.forEach(value => {
|
||
const opt = data.options.find(o => o.value === value);
|
||
if (!opt) return;
|
||
|
||
const chip = document.createElement('span');
|
||
chip.className = `tag-chip bg-${color}-600/30 text-${color}-300 px-2 py-1 rounded text-xs flex items-center gap-1`;
|
||
|
||
let countHtml = '';
|
||
if (opt.count !== undefined && opt.count > 0) {
|
||
countHtml = `<span class="text-${color}-400/60 text-[10px]">(${opt.count})</span>`;
|
||
}
|
||
|
||
chip.innerHTML = `
|
||
${opt.label} ${countHtml}
|
||
<button type="button" class="tag-remove hover:text-red-400 ml-0.5" data-value="${value}" data-count="${opt.count || 0}">×</button>
|
||
`;
|
||
|
||
wrapper.insertBefore(chip, input);
|
||
});
|
||
|
||
// 태그 제거 버튼 이벤트
|
||
wrapper.querySelectorAll('.tag-remove').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const value = btn.dataset.value;
|
||
const count = parseInt(btn.dataset.count || 0);
|
||
|
||
// 분류이고 게시글이 사용 중이면 삭제 불가
|
||
if (name === 'categories' && count > 0) {
|
||
alert(`"${value}" 분류는 현재 ${count}개의 게시글에서 사용 중입니다.\n\n게시글에서 사용 중인 분류는 제거할 수 없습니다.`);
|
||
return;
|
||
}
|
||
|
||
const idx = data.selected.indexOf(value);
|
||
if (idx > -1) {
|
||
data.selected.splice(idx, 1);
|
||
renderTags(container);
|
||
renderDropdown(container, container.querySelector('.tag-input').value);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 초기화
|
||
document.querySelectorAll('.tag-input-container').forEach(container => {
|
||
const input = container.querySelector('.tag-input');
|
||
const dropdown = container.querySelector('.tag-dropdown');
|
||
|
||
// 포커스 시 드롭다운 표시
|
||
input.addEventListener('focus', () => {
|
||
renderDropdown(container, input.value);
|
||
});
|
||
|
||
// 입력 시 필터링
|
||
input.addEventListener('input', () => {
|
||
renderDropdown(container, input.value);
|
||
});
|
||
|
||
// 외부 클릭 시 드롭다운 숨김
|
||
document.addEventListener('click', (e) => {
|
||
if (!container.contains(e.target)) {
|
||
dropdown.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// 초기 태그 렌더링
|
||
renderTags(container);
|
||
});
|
||
|
||
// ==========================================
|
||
// 템플릿 선택 감지 및 빌드 버튼 표시/숨김
|
||
// ==========================================
|
||
document.querySelectorAll('input[name="extension_identifier"]').forEach(radio => {
|
||
radio.addEventListener('change', (e) => {
|
||
const buildButton = document.getElementById('buildButton');
|
||
const selectedType = e.target.dataset.type;
|
||
|
||
if (selectedType === 'template') {
|
||
buildButton.classList.remove('hidden');
|
||
buildButton.classList.add('flex');
|
||
} else {
|
||
buildButton.classList.add('hidden');
|
||
buildButton.classList.remove('flex');
|
||
}
|
||
});
|
||
});
|
||
|
||
// 페이지 로드 시 초기 상태 설정 (첫 번째 템플릿이 선택되어 있으면 빌드 버튼 표시)
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
const selected = document.querySelector('input[name="extension_identifier"]:checked');
|
||
if (selected && selected.dataset.type === 'template') {
|
||
const buildButton = document.getElementById('buildButton');
|
||
buildButton.classList.remove('hidden');
|
||
buildButton.classList.add('flex');
|
||
}
|
||
});
|
||
|
||
// ==========================================
|
||
// 템플릿 빌드 함수
|
||
// ==========================================
|
||
async function startBuild() {
|
||
const selected = document.querySelector('input[name="extension_identifier"]:checked');
|
||
if (!selected || selected.dataset.type !== 'template') {
|
||
alert('템플릿을 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
const identifier = selected.value;
|
||
|
||
if (!confirm(`"${identifier}" 템플릿을 빌드하시겠습니까?\n\n(npm run build 실행)`)) return;
|
||
|
||
const panel = document.getElementById('progressPanel');
|
||
const content = document.getElementById('progressContent');
|
||
document.getElementById('progressTitle').textContent = `🔨 ${identifier} 템플릿 빌드`;
|
||
content.innerHTML = '';
|
||
panel.classList.remove('hidden');
|
||
|
||
addProgressStep('빌드 시작', 'running', `cd templates/${identifier} && npm run build`);
|
||
|
||
try {
|
||
const url = `${window.location.pathname}?ajax_action=template_build&identifier=${encodeURIComponent(identifier)}`;
|
||
|
||
// 타임아웃 설정 (120초 - 빌드는 시간이 오래 걸릴 수 있음)
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 120000);
|
||
|
||
const response = await fetch(url, { signal: controller.signal });
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
addProgressStep('빌드 완료', result.success ? 'success' : 'error', result.output);
|
||
|
||
// 최종 결과
|
||
const finalEl = document.createElement('div');
|
||
finalEl.className = result.success
|
||
? 'mt-2 p-2 bg-emerald-900/30 border border-emerald-700/50 rounded text-emerald-300 text-[11px]'
|
||
: 'mt-2 p-2 bg-red-900/30 border border-red-700/50 rounded text-red-300 text-[11px]';
|
||
finalEl.textContent = result.success
|
||
? `🎉 ${identifier} 템플릿 빌드 완료!`
|
||
: `❌ 빌드 실패`;
|
||
content.appendChild(finalEl);
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') {
|
||
addProgressStep('빌드 실패', 'error', '⏱️ 타임아웃 (120초 초과)');
|
||
} else {
|
||
addProgressStep('빌드 실패', 'error', '❌ ' + error.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 캐시 초기화 함수
|
||
// ==========================================
|
||
async function startCacheClear() {
|
||
if (!confirm('모든 캐시를 초기화하시겠습니까?\n\n(cache:clear, config:clear, route:clear, view:clear)')) return;
|
||
|
||
const panel = document.getElementById('progressPanel');
|
||
const content = document.getElementById('progressContent');
|
||
document.getElementById('progressTitle').textContent = '🧹 캐시 초기화';
|
||
content.innerHTML = '';
|
||
panel.classList.remove('hidden');
|
||
|
||
const cacheCommands = [
|
||
{ name: 'cache:clear', label: 'Application Cache' },
|
||
{ name: 'config:clear', label: 'Configuration Cache' },
|
||
{ name: 'route:clear', label: 'Route Cache' },
|
||
{ name: 'view:clear', label: 'View Cache' },
|
||
];
|
||
|
||
let allSuccess = true;
|
||
|
||
for (const { name, label } of cacheCommands) {
|
||
addProgressStep(label, 'running', '처리 중...');
|
||
|
||
const result = await runArtisanCommand(name, label);
|
||
addProgressStep(label, result.success ? 'success' : 'error', result.output);
|
||
|
||
if (!result.success) {
|
||
allSuccess = false;
|
||
}
|
||
}
|
||
|
||
// 최종 결과
|
||
const finalEl = document.createElement('div');
|
||
finalEl.className = allSuccess
|
||
? 'mt-2 p-2 bg-emerald-900/30 border border-emerald-700/50 rounded text-emerald-300 text-[11px]'
|
||
: 'mt-2 p-2 bg-red-900/30 border border-red-700/50 rounded text-red-300 text-[11px]';
|
||
finalEl.textContent = allSuccess
|
||
? '🎉 모든 캐시가 초기화되었습니다!'
|
||
: '⚠️ 일부 캐시 초기화에 실패했습니다.';
|
||
content.appendChild(finalEl);
|
||
}
|
||
|
||
// ==========================================
|
||
// 개별 캐시 클리어 함수
|
||
// ==========================================
|
||
async function clearSingleCache(command, label) {
|
||
if (!confirm(`${label}를 초기화하시겠습니까?\n\n(${command})`)) return;
|
||
|
||
const panel = document.getElementById('progressPanel');
|
||
const content = document.getElementById('progressContent');
|
||
document.getElementById('progressTitle').textContent = `🧹 ${label} 초기화`;
|
||
content.innerHTML = '';
|
||
panel.classList.remove('hidden');
|
||
|
||
addProgressStep(label, 'running', '처리 중...');
|
||
|
||
const result = await runArtisanCommand(command, label);
|
||
addProgressStep(label, result.success ? 'success' : 'error', result.output);
|
||
|
||
// 최종 결과
|
||
const finalEl = document.createElement('div');
|
||
finalEl.className = result.success
|
||
? 'mt-2 p-2 bg-emerald-900/30 border border-emerald-700/50 rounded text-emerald-300 text-[11px]'
|
||
: 'mt-2 p-2 bg-red-900/30 border border-red-700/50 rounded text-red-300 text-[11px]';
|
||
finalEl.textContent = result.success
|
||
? `🎉 ${label} 초기화 완료!`
|
||
: `⚠️ ${label} 초기화에 실패했습니다.`;
|
||
content.appendChild(finalEl);
|
||
}
|
||
|
||
// ==========================================
|
||
// Artisan 커맨드 패널 토글
|
||
// ==========================================
|
||
function toggleCommandPanel() {
|
||
const content = document.getElementById('commandPanelContent');
|
||
const arrow = document.getElementById('commandPanelArrow');
|
||
|
||
if (content.classList.contains('hidden')) {
|
||
content.classList.remove('hidden');
|
||
arrow.classList.add('rotate-180');
|
||
} else {
|
||
content.classList.add('hidden');
|
||
arrow.classList.remove('rotate-180');
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 공통 커맨드 실행 함수
|
||
// ==========================================
|
||
async function runCommand(command) {
|
||
if (!confirm(`다음 명령을 실행하시겠습니까?\n\n${command}`)) return;
|
||
|
||
const panel = document.getElementById('progressPanel');
|
||
const content = document.getElementById('progressContent');
|
||
document.getElementById('progressTitle').textContent = `⚡ ${command}`;
|
||
content.innerHTML = '';
|
||
panel.classList.remove('hidden');
|
||
|
||
addProgressStep(command, 'running', '실행 중...');
|
||
|
||
const result = await runArtisanCommand(command, command);
|
||
addProgressStep(command, result.success ? 'success' : 'error', result.output);
|
||
|
||
// 최종 결과
|
||
const finalEl = document.createElement('div');
|
||
finalEl.className = result.success
|
||
? 'mt-2 p-2 bg-emerald-900/30 border border-emerald-700/50 rounded text-emerald-300 text-[11px]'
|
||
: 'mt-2 p-2 bg-red-900/30 border border-red-700/50 rounded text-red-300 text-[11px]';
|
||
finalEl.textContent = result.success
|
||
? `🎉 ${command} 실행 완료!`
|
||
: `⚠️ ${command} — 종료 코드 ${result.code ?? 1} (출력 확인 필요)`;
|
||
content.appendChild(finalEl);
|
||
}
|
||
|
||
// ==========================================
|
||
// 모듈 커맨드 실행
|
||
// ==========================================
|
||
async function runModuleCommand(action) {
|
||
const select = document.getElementById('moduleSelect');
|
||
const identifier = select.value;
|
||
|
||
if (!identifier) {
|
||
alert('모듈을 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
const command = `module:${action} ${identifier}`;
|
||
await runCommand(command);
|
||
}
|
||
|
||
// ==========================================
|
||
// 템플릿 커맨드 실행
|
||
// ==========================================
|
||
async function runTemplateCommand(action) {
|
||
const select = document.getElementById('templateSelect');
|
||
const identifier = select.value;
|
||
|
||
if (!identifier) {
|
||
alert('템플릿을 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
const command = `template:${action} ${identifier}`;
|
||
await runCommand(command);
|
||
}
|
||
|
||
// ==========================================
|
||
// 플러그인 커맨드 실행
|
||
// ==========================================
|
||
async function runPluginCommand(action) {
|
||
const select = document.getElementById('pluginSelect');
|
||
const identifier = select.value;
|
||
|
||
if (!identifier) {
|
||
alert('플러그인을 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
const command = `plugin:${action} ${identifier}`;
|
||
await runCommand(command);
|
||
}
|
||
|
||
// ==========================================
|
||
// 확장 커맨드 실행 (identifier + 플래그)
|
||
// ==========================================
|
||
async function runExtensionCommand(type, action, flags = '') {
|
||
const select = document.getElementById(type + 'Select');
|
||
const identifier = select.value;
|
||
|
||
if (!identifier) {
|
||
const labels = {module: '모듈', template: '템플릿', plugin: '플러그인'};
|
||
alert((labels[type] || type) + '을(를) 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
const command = `${type}:${action} ${identifier}${flags ? ' ' + flags : ''}`;
|
||
await runCommand(command);
|
||
}
|
||
|
||
// ==========================================
|
||
// 언어팩 커맨드 실행 (드롭다운 선택)
|
||
// ==========================================
|
||
async function runLanguagePackCommand(action, flags = '') {
|
||
const select = document.getElementById('languagePackSelect');
|
||
const identifier = select.value;
|
||
|
||
if (!identifier) {
|
||
alert('언어팩을 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
const command = `language-pack:${action} ${identifier}${flags ? ' ' + flags : ''}`;
|
||
await runCommand(command);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|