From 18b2a20806c4da1eda25f9739745e400db184896 Mon Sep 17 00:00:00 2001 From: backuppc Date: Wed, 9 Jul 2025 08:44:34 +0900 Subject: [PATCH] add icon(128*128) --- .gitignore | 1 + Models/VNCServer.cs | 2 + Services/DatabaseService.cs | 28 ++- Web/Controllers/VNCServerController.cs | 183 +++++++++++++++ Web/Startup.cs | 3 + Web/wwwroot/index.html | 32 +++ Web/wwwroot/js/app.js | 310 +++++++++++++++++-------- 7 files changed, 456 insertions(+), 103 deletions(-) diff --git a/.gitignore b/.gitignore index f7f2c57..743cf1a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ desktop.ini .vs packages *.zip +deploy diff --git a/Models/VNCServer.cs b/Models/VNCServer.cs index d3866a8..5bcbc6b 100644 --- a/Models/VNCServer.cs +++ b/Models/VNCServer.cs @@ -11,5 +11,7 @@ namespace VNCServerList.Models public string Description { get; set; } public string Password { get; set; } public string Argument { get; set; } + public byte[] Icon { get; set; } + public string IconBase64 { get; set; } // Base64 인코딩된 아이콘 데이터 } } \ No newline at end of file diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index f38982a..068ac47 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -36,6 +36,7 @@ namespace VNCServerList.Services [Description] [varchar](100) NULL, [Password] [varchar](20) NULL, [Argument] [varchar](50) NULL, + [Icon] [image] NULL, CONSTRAINT [PK_VNC_ServerList] PRIMARY KEY CLUSTERED ( [User] ASC, @@ -47,6 +48,16 @@ namespace VNCServerList.Services { command.ExecuteNonQuery(); } + + // Icon 컬럼이 존재하는지 확인하고 없으면 추가 + string checkIconColumnSql = @" + IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[VNC_ServerList]') AND name = 'Icon') + ALTER TABLE [dbo].[VNC_ServerList] ADD [Icon] [image] NULL"; + + using (var command = new SqlCommand(checkIconColumnSql, connection)) + { + command.ExecuteNonQuery(); + } } } @@ -72,7 +83,8 @@ namespace VNCServerList.Services Title = reader["Title"] == DBNull.Value ? null : reader["Title"].ToString(), Description = reader["Description"] == DBNull.Value ? null : reader["Description"].ToString(), Password = reader["Password"] == DBNull.Value ? null : reader["Password"].ToString(), - Argument = reader["Argument"] == DBNull.Value ? null : reader["Argument"].ToString() + Argument = reader["Argument"] == DBNull.Value ? null : reader["Argument"].ToString(), + Icon = reader["Icon"] == DBNull.Value ? null : (byte[])reader["Icon"] }); } } @@ -105,7 +117,8 @@ namespace VNCServerList.Services Title = reader["Title"] == DBNull.Value ? null : reader["Title"].ToString(), Description = reader["Description"] == DBNull.Value ? null : reader["Description"].ToString(), Password = reader["Password"] == DBNull.Value ? null : reader["Password"].ToString(), - Argument = reader["Argument"] == DBNull.Value ? null : reader["Argument"].ToString() + Argument = reader["Argument"] == DBNull.Value ? null : reader["Argument"].ToString(), + Icon = reader["Icon"] == DBNull.Value ? null : (byte[])reader["Icon"] }; } } @@ -121,8 +134,8 @@ namespace VNCServerList.Services { connection.Open(); string sql = @" - INSERT INTO VNC_ServerList ([User], [IP], [Category], [Title],[Description], [Password], [Argument]) - VALUES (@User, @IP, @Category, @Title,@Description, @Password, @Argument)"; + INSERT INTO VNC_ServerList ([User], [IP], [Category], [Title],[Description], [Password], [Argument], [Icon]) + VALUES (@User, @IP, @Category, @Title,@Description, @Password, @Argument, @Icon)"; using (var command = new SqlCommand(sql, connection)) { @@ -133,6 +146,7 @@ namespace VNCServerList.Services command.Parameters.AddWithValue("@Description", (object)server.Description ?? DBNull.Value); command.Parameters.AddWithValue("@Password", (object)server.Password ?? DBNull.Value); command.Parameters.AddWithValue("@Argument", (object)server.Argument ?? DBNull.Value); + command.Parameters.AddWithValue("@Icon", (object)server.Icon ?? DBNull.Value); return command.ExecuteNonQuery() > 0; } @@ -147,7 +161,7 @@ namespace VNCServerList.Services string sql = @" UPDATE VNC_ServerList SET [Category] = @Category, [Title] = @Title, [Description] = @Description, - [Password] = @Password, [Argument] = @Argument + [Password] = @Password, [Argument] = @Argument, [Icon] = @Icon WHERE [User] = @User AND [IP] = @IP"; using (var command = new SqlCommand(sql, connection)) @@ -159,6 +173,7 @@ namespace VNCServerList.Services command.Parameters.AddWithValue("@Description", (object)server.Description ?? DBNull.Value); command.Parameters.AddWithValue("@Password", (object)server.Password ?? DBNull.Value); command.Parameters.AddWithValue("@Argument", (object)server.Argument ?? DBNull.Value); + command.Parameters.AddWithValue("@Icon", (object)server.Icon ?? DBNull.Value); return command.ExecuteNonQuery() > 0; } @@ -173,7 +188,7 @@ namespace VNCServerList.Services string sql = @" UPDATE VNC_ServerList SET [IP] = @NewIP, [Category] = @Category, [Title] = @Title, [Description] = @Description, - [Password] = @Password, [Argument] = @Argument + [Password] = @Password, [Argument] = @Argument, [Icon] = @Icon WHERE [User] = @User AND [IP] = @OriginalIP"; using (var command = new SqlCommand(sql, connection)) @@ -186,6 +201,7 @@ namespace VNCServerList.Services command.Parameters.AddWithValue("@Description", (object)newServerData.Description ?? DBNull.Value); command.Parameters.AddWithValue("@Password", (object)newServerData.Password ?? DBNull.Value); command.Parameters.AddWithValue("@Argument", (object)newServerData.Argument ?? DBNull.Value); + command.Parameters.AddWithValue("@Icon", (object)newServerData.Icon ?? DBNull.Value); return command.ExecuteNonQuery() > 0; } diff --git a/Web/Controllers/VNCServerController.cs b/Web/Controllers/VNCServerController.cs index ca1f7e0..e66a719 100644 --- a/Web/Controllers/VNCServerController.cs +++ b/Web/Controllers/VNCServerController.cs @@ -2,6 +2,12 @@ using System; using System.Collections.Generic; using System.Web.Http; using System.Linq; +using System.Net.Http; +using System.Net; +using System.IO; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; using VNCServerList.Models; using VNCServerList.Services; @@ -75,6 +81,20 @@ namespace VNCServerList.Web.Controllers return BadRequest("서버 정보가 없습니다."); } + // 아이콘이 Base64로 전송된 경우 처리 + if (!string.IsNullOrEmpty(server.IconBase64)) + { + try + { + var iconData = Convert.FromBase64String(server.IconBase64); + server.Icon = ResizeImage(iconData, 128, 128); + } + catch + { + return BadRequest("아이콘 데이터 형식이 올바르지 않습니다."); + } + } + bool success = _databaseService.AddServer(server); if (success) { @@ -102,6 +122,20 @@ namespace VNCServerList.Web.Controllers return BadRequest("서버 정보가 없습니다."); } + // 아이콘이 Base64로 전송된 경우 처리 + if (!string.IsNullOrEmpty(server.IconBase64)) + { + try + { + var iconData = Convert.FromBase64String(server.IconBase64); + server.Icon = ResizeImage(iconData, 128, 128); + } + catch + { + return BadRequest("아이콘 데이터 형식이 올바르지 않습니다."); + } + } + // 원본 사용자명과 IP로 서버를 찾아서 업데이트 bool success = _databaseService.UpdateServerByUserAndIP(originalUser, originalIp, server); if (success) @@ -264,5 +298,154 @@ namespace VNCServerList.Web.Controllers return InternalServerError(ex); } } + + [HttpGet] + [Route("icon/{user}/{ip}")] + public HttpResponseMessage GetServerIcon(string user, string ip) + { + try + { + var server = _databaseService.GetServerByUserAndIP(user, ip); + if (server?.Icon == null || server.Icon.Length == 0) + { + return Request.CreateResponse(HttpStatusCode.NotFound); + } + + var response = Request.CreateResponse(HttpStatusCode.OK); + response.Content = new ByteArrayContent(server.Icon); + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + return response; + } + catch (Exception ex) + { + return Request.CreateResponse(HttpStatusCode.InternalServerError, ex.Message); + } + } + + private byte[] ResizeImage(byte[] imageData, int maxWidth = 128, int maxHeight = 128) + { + using (var originalStream = new MemoryStream(imageData)) + using (var originalImage = Image.FromStream(originalStream)) + { + // 원본 이미지 크기 + int originalWidth = originalImage.Width; + int originalHeight = originalImage.Height; + + // 리사이즈가 필요한지 확인 + if (originalWidth <= maxWidth && originalHeight <= maxHeight) + { + return imageData; // 리사이즈 불필요 + } + + // 새로운 크기 계산 (비율 유지) + double ratioX = (double)maxWidth / originalWidth; + double ratioY = (double)maxHeight / originalHeight; + double ratio = Math.Min(ratioX, ratioY); + + int newWidth = (int)(originalWidth * ratio); + int newHeight = (int)(originalHeight * ratio); + + // 새 이미지 생성 + using (var resizedImage = new Bitmap(newWidth, newHeight)) + using (var graphics = Graphics.FromImage(resizedImage)) + { + // 고품질 리사이즈 설정 + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.SmoothingMode = SmoothingMode.HighQuality; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + + // 이미지 그리기 + graphics.DrawImage(originalImage, 0, 0, newWidth, newHeight); + + // PNG로 변환하여 반환 + using (var outputStream = new MemoryStream()) + { + resizedImage.Save(outputStream, ImageFormat.Png); + return outputStream.ToArray(); + } + } + } + } + + [HttpPost] + [Route("icon/{user}/{ip}")] + public IHttpActionResult UploadServerIcon(string user, string ip) + { + try + { + var httpRequest = System.Web.HttpContext.Current.Request; + if (httpRequest.Files.Count == 0) + { + return BadRequest("업로드된 파일이 없습니다."); + } + + var file = httpRequest.Files[0]; + if (file.ContentLength > 1024 * 1024) // 1MB 제한 + { + return BadRequest("파일 크기는 1MB를 초과할 수 없습니다."); + } + + using (var memoryStream = new MemoryStream()) + { + file.InputStream.CopyTo(memoryStream); + var originalIconData = memoryStream.ToArray(); + + // 이미지 리사이즈 (128x128로 제한) + var resizedIconData = ResizeImage(originalIconData, 128, 128); + + var server = _databaseService.GetServerByUserAndIP(user, ip); + if (server == null) + { + return NotFound(); + } + + server.Icon = resizedIconData; + bool success = _databaseService.UpdateServer(server); + + if (success) + { + return Ok(new { Message = "아이콘이 성공적으로 업로드되었습니다." }); + } + else + { + return BadRequest("아이콘 업로드에 실패했습니다."); + } + } + } + catch (Exception ex) + { + return InternalServerError(ex); + } + } + + [HttpDelete] + [Route("icon/{user}/{ip}")] + public IHttpActionResult DeleteServerIcon(string user, string ip) + { + try + { + var server = _databaseService.GetServerByUserAndIP(user, ip); + if (server == null) + { + return NotFound(); + } + + server.Icon = null; + bool success = _databaseService.UpdateServer(server); + + if (success) + { + return Ok(new { Message = "아이콘이 성공적으로 삭제되었습니다." }); + } + else + { + return BadRequest("아이콘 삭제에 실패했습니다."); + } + } + catch (Exception ex) + { + return InternalServerError(ex); + } + } } } \ No newline at end of file diff --git a/Web/Startup.cs b/Web/Startup.cs index bc64ab8..d6e1a53 100644 --- a/Web/Startup.cs +++ b/Web/Startup.cs @@ -25,6 +25,9 @@ namespace VNCServerList.Web // JSON 포맷터 설정 config.Formatters.JsonFormatter.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore; + // 파일 업로드 설정 + config.Formatters.Remove(config.Formatters.XmlFormatter); + app.UseWebApi(config); // 정적 파일 서빙 설정 diff --git a/Web/wwwroot/index.html b/Web/wwwroot/index.html index 57627ad..8ea6655 100644 --- a/Web/wwwroot/index.html +++ b/Web/wwwroot/index.html @@ -63,6 +63,29 @@

서버 추가

+ +
+ +
+
+ + + + +
+
+ + + +
+
+

PNG, JPG, GIF 형식 지원 (최대 1MB, 128x128 자동 리사이즈)

+
+
@@ -167,6 +190,15 @@
+ +
+ + +

{{serverName}}

클릭하여 연결 diff --git a/Web/wwwroot/js/app.js b/Web/wwwroot/js/app.js index 4ba9a25..3e4fac7 100644 --- a/Web/wwwroot/js/app.js +++ b/Web/wwwroot/js/app.js @@ -72,7 +72,18 @@ class VNCServerApp { this.executeConfirmAction(); }); + // 아이콘 관련 이벤트 + document.getElementById('selectIconBtn').addEventListener('click', () => { + document.getElementById('iconFile').click(); + }); + document.getElementById('iconFile').addEventListener('change', (e) => { + this.handleIconFileSelect(e); + }); + + document.getElementById('removeIconBtn').addEventListener('click', () => { + this.removeIconPreview(); + }); } async loadServerList() { @@ -201,35 +212,101 @@ class VNCServerApp { } } + handleIconFileSelect(event) { + const file = event.target.files[0]; + if (!file) return; + + // 파일 크기 체크 (1MB) + if (file.size > 1024 * 1024) { + this.showError('파일 크기는 1MB를 초과할 수 없습니다.'); + return; + } + + // 이미지 크기 체크 (클라이언트 측에서 미리 확인) + const img = new Image(); + img.onload = () => { + if (img.width > 128 || img.height > 128) { + this.showSuccess('이미지가 128x128로 자동 리사이즈됩니다.'); + } + URL.revokeObjectURL(img.src); // 메모리 정리 + }; + img.src = URL.createObjectURL(file); + + // 파일 타입 체크 + if (!file.type.startsWith('image/')) { + this.showError('이미지 파일만 선택할 수 있습니다.'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const iconPreview = document.getElementById('iconPreview'); + const iconPlaceholder = document.getElementById('iconPlaceholder'); + const removeIconBtn = document.getElementById('removeIconBtn'); + + iconPreview.src = e.target.result; + iconPreview.classList.remove('hidden'); + iconPlaceholder.classList.add('hidden'); + removeIconBtn.classList.remove('hidden'); + }; + reader.readAsDataURL(file); + } + + removeIconPreview() { + const iconPreview = document.getElementById('iconPreview'); + const iconPlaceholder = document.getElementById('iconPlaceholder'); + const removeIconBtn = document.getElementById('removeIconBtn'); + const iconFile = document.getElementById('iconFile'); + + iconPreview.src = ''; + iconPreview.classList.add('hidden'); + iconPlaceholder.classList.remove('hidden'); + removeIconBtn.classList.add('hidden'); + iconFile.value = ''; + } + showServerModal(server = null) { const modal = document.getElementById('serverModal'); - const title = document.getElementById('modalTitle'); + const modalTitle = document.getElementById('modalTitle'); const form = document.getElementById('serverForm'); - console.log('서버 정보2:', server); // 디버깅용 - + // 폼 초기화 + form.reset(); + this.removeIconPreview(); if (server) { - title.textContent = '서버 편집'; + // 편집 모드 + modalTitle.textContent = '서버 편집'; document.getElementById('serverIp').value = server.IP; - document.getElementById('serverCategory').value = server.Category || ''; - document.getElementById('serverTitle').value = server.Title || ''; - document.getElementById('serverDescription').value = server.Description || ''; document.getElementById('serverPassword').value = server.Password || ''; + document.getElementById('serverTitle').value = server.Title || ''; + document.getElementById('serverCategory').value = server.Category || ''; + document.getElementById('serverDescription').value = server.Description || ''; document.getElementById('serverArgument').value = server.Argument || ''; - // 편집 모드 플래그 설정 - modal.dataset.editMode = 'true'; - modal.dataset.editUser = server.User; - modal.dataset.editIp = server.IP; + + // 아이콘 미리보기 설정 + if (server.Icon) { + const iconPreview = document.getElementById('iconPreview'); + const iconPlaceholder = document.getElementById('iconPlaceholder'); + const removeIconBtn = document.getElementById('removeIconBtn'); + + iconPreview.src = `${this.apiBase}/icon/${encodeURIComponent(server.User)}/${encodeURIComponent(server.IP)}`; + iconPreview.classList.remove('hidden'); + iconPlaceholder.classList.add('hidden'); + removeIconBtn.classList.remove('hidden'); + } + + form.dataset.editMode = 'true'; + form.dataset.originalUser = server.User; + form.dataset.originalIp = server.IP; } else { - title.textContent = '서버 추가'; - form.reset(); - // 추가 모드 플래그 설정 - modal.dataset.editMode = 'false'; - delete modal.dataset.editUser; - delete modal.dataset.editIp; + // 추가 모드 + modalTitle.textContent = '서버 추가'; + delete form.dataset.editMode; + delete form.dataset.originalUser; + delete form.dataset.originalIp; } - + modal.classList.remove('hidden'); } @@ -356,53 +433,77 @@ class VNCServerApp { } async saveServer() { - const serverIp = document.getElementById('serverIp').value; - const userName = document.getElementById('userName')?.value || ''; - const modal = document.getElementById('serverModal'); - const isEditMode = modal.dataset.editMode === 'true'; - - if (!userName) { - this.showError('사용자 이름이 설정되지 않았습니다. 먼저 설정에서 사용자 이름을 입력해주세요.'); - return; - } - - const serverData = { - user: userName, - ip: serverIp, - category: document.getElementById('serverCategory').value, - title: document.getElementById('serverTitle').value, - description: document.getElementById('serverDescription').value, - password: document.getElementById('serverPassword').value, - argument: document.getElementById('serverArgument').value - }; - try { - let url, method; + const form = document.getElementById('serverForm'); + const userName = document.getElementById('userName')?.value || ''; - if (isEditMode) { - // 편집 모드: 원본 사용자명과 IP를 쿼리 파라미터로 전달 - const originalUser = modal.dataset.editUser; - const originalIp = modal.dataset.editIp; - url = `${this.apiBase}/update/${encodeURIComponent(originalUser)}/${encodeURIComponent(originalIp)}`; - method = 'PUT'; - } else { - // 추가 모드 - url = `${this.apiBase}/add`; - method = 'POST'; + if (!userName) { + this.showError('사용자 이름을 먼저 설정해주세요.'); + return; } - const response = await fetch(url, { - method: method, - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(serverData) - }); + const serverData = { + User: userName, + IP: document.getElementById('serverIp').value, + Title: document.getElementById('serverTitle').value, + Category: document.getElementById('serverCategory').value, + Description: document.getElementById('serverDescription').value, + Password: document.getElementById('serverPassword').value, + Argument: document.getElementById('serverArgument').value + }; - if (!response.ok) throw new Error('서버 저장에 실패했습니다.'); - - const result = await response.json(); - this.showSuccess(result.message); + // 아이콘 파일이 선택된 경우 Base64로 변환 + const iconFile = document.getElementById('iconFile'); + if (iconFile.files.length > 0) { + const file = iconFile.files[0]; + const reader = new FileReader(); + + reader.onload = async (e) => { + // Base64 데이터에서 헤더 제거 (data:image/png;base64, 부분) + const base64Data = e.target.result.split(',')[1]; + serverData.IconBase64 = base64Data; + + await this.submitServerData(serverData, form); + }; + + reader.readAsDataURL(file); + } else { + await this.submitServerData(serverData, form); + } + } catch (error) { + this.showError('서버 저장 중 오류가 발생했습니다: ' + error.message); + } + } + + async submitServerData(serverData, form) { + try { + let response; + const isEditMode = form.dataset.editMode === 'true'; + + if (isEditMode) { + // 편집 모드 + const originalUser = form.dataset.originalUser; + const originalIp = form.dataset.originalIp; + response = await fetch(`${this.apiBase}/update/${encodeURIComponent(originalUser)}/${encodeURIComponent(originalIp)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serverData) + }); + } else { + // 추가 모드 + response = await fetch(`${this.apiBase}/add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serverData) + }); + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.Message || '서버 저장에 실패했습니다.'); + } + + this.showSuccess(isEditMode ? '서버가 성공적으로 수정되었습니다.' : '서버가 성공적으로 추가되었습니다.'); this.hideServerModal(); this.loadServerList(); } catch (error) { @@ -543,54 +644,69 @@ class VNCServerApp { } renderCategoryNode(categoryName, categoryData, depth) { - const indent = depth * 20; // 들여쓰기 - const hasSubcategories = Object.keys(categoryData.subcategories).length > 0; - const hasServers = categoryData.servers.length > 0; - const totalItems = categoryData.servers.length + Object.keys(categoryData.subcategories).length; + const indent = ' '.repeat(depth); + let html = ''; - const headerData = { - categoryName: categoryName, - depth: depth, - bgClass: depth === 0 ? 'bg-gray-50' : 'bg-gray-25', - hasContent: hasSubcategories || hasServers, - totalItems: totalItems - }; + // 카테고리 헤더 + if (depth === 0) { + html += `
+
+

+ + + + ${this.escapeHtml(categoryName)} +

+ ${categoryData.servers.length}개 서버 +
+
`; + } else { + html += `
+
+

+ + + + ${this.escapeHtml(categoryName)} +

+ ${categoryData.servers.length}개 서버 +
+
`; + } - let html = ` -
- ${this.renderTemplate('categoryHeaderTemplate', headerData)} -
- `; - - // 서버 목록 렌더링 - if (hasServers) { + // 서버 목록 + if (categoryData.servers.length > 0) { categoryData.servers.forEach(server => { - console.log('서버 데이터:', server); // 디버깅용 + const serverName = server.Title || server.title || `${server.User || server.user} - ${server.IP || server.ip}`; + const serverIP = server.IP || server.ip; + const userName = server.User || server.user; + const serverCategory = server.Category || server.category; + const serverDescription = server.Description || server.description; + const serverArgument = server.Argument || server.argument; + + // 아이콘 URL 생성 + const iconUrl = `${this.apiBase}/icon/${encodeURIComponent(userName)}/${encodeURIComponent(serverIP)}`; + const serverData = { - serverName: `${server.User || server.user}@${server.Title || server.title}`, - userName: server.User || server.user, - serverIP: server.IP || server.ip, - serverCategory: server.Category || server.category, - serverTitle: server.Title || server.title, - serverDescription: server.Description || server.description, - serverArgument: server.Argument || server.argument + userName: userName, + serverIP: serverIP, + serverName: this.escapeHtml(serverName), + serverCategory: this.escapeHtml(serverCategory), + serverDescription: this.escapeHtml(serverDescription), + serverArgument: this.escapeHtml(serverArgument), + iconUrl: iconUrl }; - console.log('렌더링할 서버 데이터:', serverData); // 디버깅용 + html += this.renderTemplate('serverItemTemplate', serverData); }); } - // 하위 카테고리 렌더링 - const sortedSubcategories = Object.keys(categoryData.subcategories).sort(); - sortedSubcategories.forEach(subcategoryName => { - html += this.renderCategoryNode(subcategoryName, categoryData.subcategories[subcategoryName], depth + 1); + // 하위 카테고리 + const subcategories = Object.keys(categoryData.subcategories).sort(); + subcategories.forEach(subcategory => { + html += this.renderCategoryNode(subcategory, categoryData.subcategories[subcategory], depth + 1); }); - html += ` -
-
- `; - return html; }