This commit is contained in:
CSKIM
2026-05-07 09:44:53 +09:00
parent ed16fdafe6
commit 348288caeb
607 changed files with 7618 additions and 248 deletions
+171 -21
View File
@@ -312,6 +312,104 @@ async function uploadImage(file, options, form) {
return response.json();
}
function isImageFile(file) {
if (!file) {
return false;
}
if (file.type?.startsWith("image/")) {
return true;
}
return /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name || "");
}
function hasFileTransfer(dataTransfer) {
if (!dataTransfer) {
return false;
}
return (
Array.from(dataTransfer.types || []).includes("Files") ||
Array.from(dataTransfer.items || []).some((item) => item.kind === "file") ||
(dataTransfer.files?.length || 0) > 0
);
}
function getImageFiles(files) {
return Array.from(files || []).filter(isImageFile);
}
function getDropInsertPosition(editor, event) {
const position = editor.view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (typeof position?.pos !== "number") {
return null;
}
return Math.max(0, Math.min(position.pos, editor.state.doc.content.size));
}
function createPortfolioImageNode(payload, file, isRepresentative) {
return {
type: PORTFOLIO_IMAGE_TYPE,
attrs: {
src: payload.url,
alt: payload.orgnlFileNm || file.name,
atchFileIdntfNo: payload.atchFileIdntfNo,
fileSn: payload.fileSn,
isRepresentative,
},
};
}
function insertImageNodes(editor, imageNodes, insertAt) {
if (!imageNodes.length) {
return;
}
const chain = editor.chain().focus();
if (typeof insertAt === "number") {
chain.insertContentAt(insertAt, imageNodes).run();
return;
}
chain.insertContent(imageNodes).run();
}
async function uploadAndInsertImages(files, editor, options, form, insertAt, editorElement) {
const imageFiles = getImageFiles(files);
if (!imageFiles.length) {
window.alert("이미지 파일만 업로드할 수 있습니다.");
return;
}
editorElement?.classList.add("is-uploading-image");
try {
const imageNodes = [];
let shouldBeRepresentative = !getRepresentativeImage(editor);
for (const file of imageFiles) {
const payload = await uploadImage(file, options, form);
imageNodes.push(
createPortfolioImageNode(payload, file, shouldBeRepresentative),
);
shouldBeRepresentative = false;
}
insertImageNodes(editor, imageNodes, insertAt);
} catch (error) {
window.alert(error.message || "이미지 업로드에 실패했습니다.");
} finally {
editorElement?.classList.remove("is-uploading-image");
}
}
function ensureLegacyRepresentative(editor, form) {
const currentUrl = form.dataset.currentThumbnailUrl || "";
const currentAtchFileIdntfNo =
@@ -442,6 +540,70 @@ function attachToolbarActions(editor, toolbar, fileInput) {
});
}
function attachImageDropUpload(editor, editorElement, options, form) {
let dragDepth = 0;
const resetDragState = () => {
dragDepth = 0;
editorElement.classList.remove("is-dragging-image");
};
const handleDragEnter = (event) => {
if (!hasFileTransfer(event.dataTransfer)) {
return;
}
dragDepth += 1;
editorElement.classList.add("is-dragging-image");
event.preventDefault();
};
const handleDragOver = (event) => {
if (!hasFileTransfer(event.dataTransfer)) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
};
const handleDragLeave = (event) => {
if (!hasFileTransfer(event.dataTransfer)) {
return;
}
dragDepth -= 1;
if (dragDepth <= 0 || !editorElement.contains(event.relatedTarget)) {
resetDragState();
}
};
const handleDrop = (event) => {
if (!hasFileTransfer(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
resetDragState();
const insertAt = getDropInsertPosition(editor, event);
void uploadAndInsertImages(
event.dataTransfer.files,
editor,
options,
form,
insertAt,
editorElement,
);
};
editorElement.addEventListener("dragenter", handleDragEnter, true);
editorElement.addEventListener("dragover", handleDragOver, true);
editorElement.addEventListener("dragleave", handleDragLeave, true);
editorElement.addEventListener("drop", handleDrop, true);
}
function initPortfolioEditor(options) {
const form = document.querySelector(options.formSelector);
const editorElement = document.querySelector(options.editorSelector);
@@ -501,34 +663,22 @@ function initPortfolioEditor(options) {
});
attachToolbarActions(editor, toolbar, fileInput);
attachImageDropUpload(editor, editorElement, options, form);
fileInput.addEventListener("change", async () => {
if (!fileInput.files?.length) {
return;
}
const [file] = fileInput.files;
try {
const payload = await uploadImage(file, options, form);
const shouldBeRepresentative = !getRepresentativeImage(editor);
editor
.chain()
.focus()
.insertContent({
type: PORTFOLIO_IMAGE_TYPE,
attrs: {
src: payload.url,
alt: payload.orgnlFileNm || file.name,
atchFileIdntfNo: payload.atchFileIdntfNo,
fileSn: payload.fileSn,
isRepresentative: shouldBeRepresentative,
},
})
.run();
} catch (error) {
window.alert(error.message || "이미지 업로드에 실패했습니다.");
await uploadAndInsertImages(
fileInput.files,
editor,
options,
form,
null,
editorElement,
);
} finally {
fileInput.value = "";
}
+325
View File
@@ -0,0 +1,325 @@
import {
AllCommunityModule,
ModuleRegistry,
createGrid,
} from "ag-grid-community";
import "ag-grid-community/styles/ag-grid.css";
import "ag-grid-community/styles/ag-theme-quartz.css";
ModuleRegistry.registerModules([AllCommunityModule]);
const DEFAULT_SELECTOR = "[data-portfolio-list-panel]";
const DEFAULT_PAGE_SIZE = 5;
function toDate(value) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDate(value) {
const date = toDate(value);
if (!date) {
return "";
}
return new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
function formatRelativeDate(value) {
const date = toDate(value);
if (!date) {
return "";
}
const diffMs = Date.now() - date.getTime();
if (diffMs < 0) {
return formatDate(value);
}
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) {
return "방금 전";
}
if (diffMinutes < 60) {
return `${diffMinutes}분 전`;
}
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours}시간 전`;
}
return formatDate(value);
}
function getPostUrl(portfolioIdntfNo) {
return `/portfolio/${encodeURIComponent(portfolioIdntfNo)}`;
}
function createTitleCell(params) {
const link = document.createElement("a");
link.className = "portfolio-post-title-link";
link.href = getPostUrl(params.data.portfolioIdntfNo);
link.textContent = params.value || "";
link.title = params.value || "";
return link;
}
function createPageButton(label, options = {}) {
const button = document.createElement("button");
button.type = "button";
button.className = "portfolio-post-page-button";
button.textContent = label;
if (options.active) {
button.classList.add("is-active");
button.setAttribute("aria-current", "page");
}
if (options.disabled) {
button.disabled = true;
}
if (typeof options.onClick === "function") {
button.addEventListener("click", options.onClick);
}
return button;
}
function renderPagination(gridApi, paginationElement) {
if (!gridApi || !paginationElement) {
return;
}
const totalPages = gridApi.paginationGetTotalPages();
const currentPage = gridApi.paginationGetCurrentPage();
paginationElement.replaceChildren();
if (totalPages <= 0) {
return;
}
const maxVisiblePages = 10;
const currentPageGroup = Math.floor(currentPage / maxVisiblePages);
const startPage = currentPageGroup * maxVisiblePages;
const endPage = Math.min(startPage + maxVisiblePages, totalPages);
if (startPage > 0) {
paginationElement.appendChild(
createPageButton("이전", {
onClick: () => gridApi.paginationGoToPage(startPage - 1),
}),
);
}
for (let page = startPage; page < endPage; page += 1) {
paginationElement.appendChild(
createPageButton(String(page + 1), {
active: page === currentPage,
onClick: () => gridApi.paginationGoToPage(page),
}),
);
}
if (endPage < totalPages) {
paginationElement.appendChild(
createPageButton("다음 >", {
onClick: () => gridApi.paginationGoToPage(endPage),
}),
);
}
}
function createGridOptions(rows, currentId, paginationElement) {
return {
rowData: rows,
columnDefs: [
{
field: "title",
headerName: "글 제목",
flex: 1,
minWidth: 260,
cellRenderer: createTitleCell,
},
{
field: "viewCount",
headerName: "조회수",
width: 96,
type: "numericColumn",
headerClass: "portfolio-post-number-header",
cellClass: "portfolio-post-number-cell",
},
{
field: "registeredAt",
headerName: "작성일",
width: 132,
valueFormatter: (params) => formatRelativeDate(params.value),
headerClass: "portfolio-post-date-header",
cellClass: "portfolio-post-date-cell",
},
],
defaultColDef: {
sortable: true,
resizable: false,
suppressMovable: true,
},
domLayout: "autoHeight",
headerHeight: 34,
rowHeight: 40,
pagination: true,
paginationPageSize: DEFAULT_PAGE_SIZE,
suppressPaginationPanel: true,
suppressCellFocus: true,
suppressDragLeaveHidesColumns: true,
suppressHorizontalScroll: true,
getRowId: (params) => params.data.portfolioIdntfNo,
getRowClass: (params) =>
params.data.portfolioIdntfNo === currentId ? "ag-row-current-post" : "",
onGridReady: (params) => renderPagination(params.api, paginationElement),
onPaginationChanged: (event) => renderPagination(event.api, paginationElement),
localeText: {
page: "페이지",
more: "더 보기",
to: "-",
of: "/",
next: "다음",
last: "마지막",
first: "처음",
previous: "이전",
loadingOoo: "불러오는 중...",
noRowsToShow: "등록된 글이 없습니다.",
pageSizeSelectorLabel: "줄 보기",
},
};
}
async function fetchPortfolioItems(listUrl) {
const response = await fetch(listUrl, {
headers: {
Accept: "application/json",
},
credentials: "same-origin",
});
if (!response.ok) {
throw new Error("포트폴리오 목록을 불러오지 못했습니다.");
}
const rows = await response.json();
return Array.isArray(rows) ? rows : [];
}
function initPortfolioListPanel(selector = DEFAULT_SELECTOR) {
const panel = document.querySelector(selector);
if (!panel) {
return null;
}
const toggleButton = panel.querySelector("[data-portfolio-list-toggle]");
const body = panel.querySelector("[data-portfolio-list-body]");
const gridElement = panel.querySelector("[data-portfolio-list-grid]");
const paginationElement = panel.querySelector("[data-portfolio-pagination]");
const pageSizeSelect = panel.querySelector("[data-portfolio-page-size]");
const countElement = panel.querySelector(".portfolio-post-list-summary span");
const listUrl = panel.dataset.listUrl;
const currentId = panel.dataset.currentId || "";
const initialOpen = panel.dataset.initialOpen === "true";
if (!toggleButton || !body || !gridElement || !paginationElement || !listUrl) {
return null;
}
let isOpen = false;
let rowsPromise = null;
let gridApi = null;
const loadRows = () => {
rowsPromise ||= fetchPortfolioItems(listUrl);
return rowsPromise;
};
const renderRows = async () => {
const rows = await loadRows();
if (countElement) {
countElement.textContent = `${rows.length}개의 글`;
}
if (!gridApi) {
gridApi = createGrid(
gridElement,
createGridOptions(rows, currentId, paginationElement),
);
renderPagination(gridApi, paginationElement);
return;
}
gridApi.setGridOption("rowData", rows);
renderPagination(gridApi, paginationElement);
};
const setOpen = async (nextOpen) => {
isOpen = nextOpen;
toggleButton.textContent = isOpen ? "목록닫기" : "목록열기";
toggleButton.setAttribute("aria-expanded", String(isOpen));
body.hidden = !isOpen;
if (!isOpen) {
return;
}
toggleButton.disabled = true;
try {
await renderRows();
} catch (error) {
window.alert(error.message || "포트폴리오 목록을 불러오지 못했습니다.");
setOpen(false);
} finally {
toggleButton.disabled = false;
}
};
toggleButton.addEventListener("click", () => {
void setOpen(!isOpen);
});
pageSizeSelect?.addEventListener("change", () => {
if (!gridApi) {
return;
}
gridApi.setGridOption(
"paginationPageSize",
Number.parseInt(pageSizeSelect.value, 10) || DEFAULT_PAGE_SIZE,
);
gridApi.paginationGoToPage(0);
renderPagination(gridApi, paginationElement);
});
void setOpen(initialOpen);
return {
open: () => setOpen(true),
close: () => setOpen(false),
reload: async () => {
rowsPromise = null;
await renderRows();
},
};
}
document.addEventListener("DOMContentLoaded", () => {
initPortfolioListPanel();
});
export { initPortfolioListPanel };
@@ -0,0 +1,138 @@
package kr.iotdoor.boot.mapper;
import kr.iotdoor.boot.model.statistics.AccessDailyStat;
import kr.iotdoor.boot.model.statistics.AccessLog;
import kr.iotdoor.boot.model.statistics.AccessStatItem;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AccessStatisticsMapper {
@Insert("""
INSERT INTO TN_ACCESS_LOG (
ACCESS_LOG_IDNTF_NO,
SESSION_ID,
REFERER_URL,
ACCESS_IP,
HTTP_METHOD,
REQUEST_URI,
QUERY_STRING,
USER_AGENT,
STATUS_CD,
REG_DT
) VALUES (
#{accessLogIdntfNo},
#{sessionId},
#{refererUrl},
#{accessIp},
#{httpMethod},
#{requestUri},
#{queryString},
#{userAgent},
#{statusCode},
CURRENT_TIMESTAMP
)
""")
int insertAccessLog(AccessLog accessLog);
@Select("""
SELECT COUNT(*)
FROM TN_ACCESS_LOG
""")
long countAll();
@Select("""
SELECT COUNT(*)
FROM TN_ACCESS_LOG
WHERE REG_DT >= #{startAt}
AND REG_DT < #{endAt}
""")
long countBetween(
@Param("startAt") LocalDateTime startAt,
@Param("endAt") LocalDateTime endAt
);
@Select("""
SELECT COUNT(DISTINCT ACCESS_IP)
FROM TN_ACCESS_LOG
WHERE ACCESS_IP IS NOT NULL
AND ACCESS_IP <> ''
""")
long countUniqueIp();
@Select("""
SELECT COUNT(DISTINCT SESSION_ID)
FROM TN_ACCESS_LOG
WHERE SESSION_ID IS NOT NULL
AND SESSION_ID <> ''
""")
long countUniqueSession();
@Select("""
SELECT
ACCESS_LOG_IDNTF_NO,
SESSION_ID,
REFERER_URL,
ACCESS_IP,
HTTP_METHOD,
REQUEST_URI,
QUERY_STRING,
USER_AGENT,
STATUS_CD AS STATUS_CODE,
REG_DT AS REGISTERED_AT
FROM TN_ACCESS_LOG
ORDER BY REG_DT DESC
LIMIT #{limit}
""")
List<AccessLog> selectRecentLogs(@Param("limit") int limit);
@Select("""
SELECT
CAST(REG_DT AS DATE) AS ACCESS_DATE,
COUNT(*) AS ACCESS_COUNT
FROM TN_ACCESS_LOG
WHERE REG_DT >= #{startAt}
GROUP BY CAST(REG_DT AS DATE)
ORDER BY ACCESS_DATE DESC
LIMIT #{limit}
""")
List<AccessDailyStat> selectDailyStats(
@Param("startAt") LocalDateTime startAt,
@Param("limit") int limit
);
@Select("""
SELECT
REQUEST_URI AS LABEL,
COUNT(*) AS ACCESS_COUNT
FROM TN_ACCESS_LOG
GROUP BY REQUEST_URI
ORDER BY ACCESS_COUNT DESC, LABEL ASC
LIMIT #{limit}
""")
List<AccessStatItem> selectTopPages(@Param("limit") int limit);
@Select("""
SELECT
CASE
WHEN REFERER_URL IS NULL OR REFERER_URL = '' THEN '직접 접속'
ELSE REFERER_URL
END AS LABEL,
COUNT(*) AS ACCESS_COUNT
FROM TN_ACCESS_LOG
GROUP BY
CASE
WHEN REFERER_URL IS NULL OR REFERER_URL = '' THEN '직접 접속'
ELSE REFERER_URL
END
ORDER BY ACCESS_COUNT DESC, LABEL ASC
LIMIT #{limit}
""")
List<AccessStatItem> selectTopReferers(@Param("limit") int limit);
}
@@ -0,0 +1,187 @@
package kr.iotdoor.boot.mapper;
import kr.iotdoor.boot.model.banner.MainBanner;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface MainBannerMapper {
@Select("""
SELECT
BANNER_IDNTF_NO,
TTL AS TITLE,
CN AS CONTENT,
IMAGE_ATCH_FILE_IDNTF_NO,
IMAGE_FILE_SN,
IMAGE_URL,
SORT_ORDR AS SORT_ORDER,
USE_YN,
DEL_YN,
REG_DT AS REGISTERED_AT,
UPD_DT AS UPDATED_AT
FROM TN_MAIN_BANNER
WHERE DEL_YN = 'N'
ORDER BY SORT_ORDR ASC, REG_DT ASC
""")
List<MainBanner> selectAdminBanners();
@Select("""
SELECT
BANNER_IDNTF_NO,
TTL AS TITLE,
CN AS CONTENT,
IMAGE_ATCH_FILE_IDNTF_NO,
IMAGE_FILE_SN,
IMAGE_URL,
SORT_ORDR AS SORT_ORDER,
USE_YN,
DEL_YN,
REG_DT AS REGISTERED_AT,
UPD_DT AS UPDATED_AT
FROM TN_MAIN_BANNER
WHERE DEL_YN = 'N'
AND USE_YN = 'Y'
ORDER BY SORT_ORDR ASC, REG_DT ASC
""")
List<MainBanner> selectPublicBanners();
@Select("""
SELECT
BANNER_IDNTF_NO,
TTL AS TITLE,
CN AS CONTENT,
IMAGE_ATCH_FILE_IDNTF_NO,
IMAGE_FILE_SN,
IMAGE_URL,
SORT_ORDR AS SORT_ORDER,
USE_YN,
DEL_YN,
REG_DT AS REGISTERED_AT,
UPD_DT AS UPDATED_AT
FROM TN_MAIN_BANNER
WHERE BANNER_IDNTF_NO = #{bannerIdntfNo}
AND DEL_YN = 'N'
""")
MainBanner selectBanner(@Param("bannerIdntfNo") String bannerIdntfNo);
@Select("""
SELECT
BANNER_IDNTF_NO,
TTL AS TITLE,
CN AS CONTENT,
IMAGE_ATCH_FILE_IDNTF_NO,
IMAGE_FILE_SN,
IMAGE_URL,
SORT_ORDR AS SORT_ORDER,
USE_YN,
DEL_YN,
REG_DT AS REGISTERED_AT,
UPD_DT AS UPDATED_AT
FROM TN_MAIN_BANNER
WHERE DEL_YN = 'N'
AND SORT_ORDR < #{sortOrder}
ORDER BY SORT_ORDR DESC, REG_DT DESC
LIMIT 1
""")
MainBanner selectPreviousBanner(@Param("sortOrder") int sortOrder);
@Select("""
SELECT
BANNER_IDNTF_NO,
TTL AS TITLE,
CN AS CONTENT,
IMAGE_ATCH_FILE_IDNTF_NO,
IMAGE_FILE_SN,
IMAGE_URL,
SORT_ORDR AS SORT_ORDER,
USE_YN,
DEL_YN,
REG_DT AS REGISTERED_AT,
UPD_DT AS UPDATED_AT
FROM TN_MAIN_BANNER
WHERE DEL_YN = 'N'
AND SORT_ORDR > #{sortOrder}
ORDER BY SORT_ORDR ASC, REG_DT ASC
LIMIT 1
""")
MainBanner selectNextBanner(@Param("sortOrder") int sortOrder);
@Select("""
SELECT COALESCE(MAX(SORT_ORDR), 0)
FROM TN_MAIN_BANNER
WHERE DEL_YN = 'N'
""")
int selectMaxSortOrder();
@Insert("""
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO,
TTL,
CN,
IMAGE_ATCH_FILE_IDNTF_NO,
IMAGE_FILE_SN,
IMAGE_URL,
SORT_ORDR,
USE_YN,
DEL_YN,
REG_DT,
UPD_DT
) VALUES (
#{bannerIdntfNo},
#{title},
#{content},
#{imageAtchFileIdntfNo},
#{imageFileSn},
#{imageUrl},
#{sortOrder},
#{useYn},
'N',
CURRENT_TIMESTAMP,
NULL
)
""")
int insertBanner(MainBanner mainBanner);
@Update("""
UPDATE TN_MAIN_BANNER
SET
TTL = #{title},
CN = #{content},
IMAGE_ATCH_FILE_IDNTF_NO = #{imageAtchFileIdntfNo},
IMAGE_FILE_SN = #{imageFileSn},
IMAGE_URL = #{imageUrl},
USE_YN = #{useYn},
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = #{bannerIdntfNo}
AND DEL_YN = 'N'
""")
int updateBanner(MainBanner mainBanner);
@Update("""
UPDATE TN_MAIN_BANNER
SET
SORT_ORDR = #{sortOrder},
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = #{bannerIdntfNo}
AND DEL_YN = 'N'
""")
int updateSortOrder(
@Param("bannerIdntfNo") String bannerIdntfNo,
@Param("sortOrder") int sortOrder
);
@Update("""
UPDATE TN_MAIN_BANNER
SET
DEL_YN = 'Y',
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = #{bannerIdntfNo}
""")
int deleteBanner(@Param("bannerIdntfNo") String bannerIdntfNo);
}
@@ -0,0 +1,106 @@
package kr.iotdoor.boot.model.banner;
import java.time.LocalDateTime;
public class MainBanner {
private String bannerIdntfNo;
private String title;
private String content;
private String imageAtchFileIdntfNo;
private Integer imageFileSn;
private String imageUrl;
private int sortOrder;
private String useYn;
private String delYn;
private LocalDateTime registeredAt;
private LocalDateTime updatedAt;
public String getBannerIdntfNo() {
return bannerIdntfNo;
}
public void setBannerIdntfNo(String bannerIdntfNo) {
this.bannerIdntfNo = bannerIdntfNo;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getImageAtchFileIdntfNo() {
return imageAtchFileIdntfNo;
}
public void setImageAtchFileIdntfNo(String imageAtchFileIdntfNo) {
this.imageAtchFileIdntfNo = imageAtchFileIdntfNo;
}
public Integer getImageFileSn() {
return imageFileSn;
}
public void setImageFileSn(Integer imageFileSn) {
this.imageFileSn = imageFileSn;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public int getSortOrder() {
return sortOrder;
}
public void setSortOrder(int sortOrder) {
this.sortOrder = sortOrder;
}
public String getUseYn() {
return useYn;
}
public void setUseYn(String useYn) {
this.useYn = useYn;
}
public String getDelYn() {
return delYn;
}
public void setDelYn(String delYn) {
this.delYn = delYn;
}
public LocalDateTime getRegisteredAt() {
return registeredAt;
}
public void setRegisteredAt(LocalDateTime registeredAt) {
this.registeredAt = registeredAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,78 @@
package kr.iotdoor.boot.model.banner;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class MainBannerForm {
private String bannerIdntfNo;
@NotBlank(message = "제목을 입력해주세요.")
@Size(max = 200, message = "제목은 200자 이하로 입력해주세요.")
private String title;
@NotBlank(message = "내용을 입력해주세요.")
@Size(max = 1000, message = "내용은 1000자 이하로 입력해주세요.")
private String content;
private String imageAtchFileIdntfNo;
private Integer imageFileSn;
private String imageUrl;
private String useYn = "Y";
public String getBannerIdntfNo() {
return bannerIdntfNo;
}
public void setBannerIdntfNo(String bannerIdntfNo) {
this.bannerIdntfNo = bannerIdntfNo;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getImageAtchFileIdntfNo() {
return imageAtchFileIdntfNo;
}
public void setImageAtchFileIdntfNo(String imageAtchFileIdntfNo) {
this.imageAtchFileIdntfNo = imageAtchFileIdntfNo;
}
public Integer getImageFileSn() {
return imageFileSn;
}
public void setImageFileSn(Integer imageFileSn) {
this.imageFileSn = imageFileSn;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public String getUseYn() {
return useYn;
}
public void setUseYn(String useYn) {
this.useYn = useYn;
}
}
@@ -1,6 +1,9 @@
package kr.iotdoor.boot.model.portfolio;
import jakarta.validation.constraints.NotBlank;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
public class PortfolioForm {
@@ -15,6 +18,9 @@ public class PortfolioForm {
private String thumbnailAtchFileIdntfNo;
private int thumbnailFileSn;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime registeredAt;
public String getPortfolioIdntfNo() {
return portfolioIdntfNo;
}
@@ -54,4 +60,12 @@ public class PortfolioForm {
public void setThumbnailFileSn(int thumbnailFileSn) {
this.thumbnailFileSn = thumbnailFileSn;
}
public LocalDateTime getRegisteredAt() {
return registeredAt;
}
public void setRegisteredAt(LocalDateTime registeredAt) {
this.registeredAt = registeredAt;
}
}
@@ -0,0 +1,25 @@
package kr.iotdoor.boot.model.statistics;
import java.time.LocalDate;
public class AccessDailyStat {
private LocalDate accessDate;
private long accessCount;
public LocalDate getAccessDate() {
return accessDate;
}
public void setAccessDate(LocalDate accessDate) {
this.accessDate = accessDate;
}
public long getAccessCount() {
return accessCount;
}
public void setAccessCount(long accessCount) {
this.accessCount = accessCount;
}
}
@@ -0,0 +1,104 @@
package kr.iotdoor.boot.model.statistics;
import java.time.LocalDateTime;
public class AccessLog {
private String accessLogIdntfNo;
private String sessionId;
private String refererUrl;
private String accessIp;
private String httpMethod;
private String requestUri;
private String queryString;
private String userAgent;
private Integer statusCode;
private LocalDateTime registeredAt;
public String getAccessLogIdntfNo() {
return accessLogIdntfNo;
}
public void setAccessLogIdntfNo(String accessLogIdntfNo) {
this.accessLogIdntfNo = accessLogIdntfNo;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public String getRefererUrl() {
return refererUrl;
}
public void setRefererUrl(String refererUrl) {
this.refererUrl = refererUrl;
}
public String getAccessIp() {
return accessIp;
}
public void setAccessIp(String accessIp) {
this.accessIp = accessIp;
}
public String getHttpMethod() {
return httpMethod;
}
public void setHttpMethod(String httpMethod) {
this.httpMethod = httpMethod;
}
public String getRequestUri() {
return requestUri;
}
public void setRequestUri(String requestUri) {
this.requestUri = requestUri;
}
public String getQueryString() {
return queryString;
}
public void setQueryString(String queryString) {
this.queryString = queryString;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public Integer getStatusCode() {
return statusCode;
}
public void setStatusCode(Integer statusCode) {
this.statusCode = statusCode;
}
public LocalDateTime getRegisteredAt() {
return registeredAt;
}
public void setRegisteredAt(LocalDateTime registeredAt) {
this.registeredAt = registeredAt;
}
public String getFullRequestPath() {
if (queryString == null || queryString.isBlank()) {
return requestUri;
}
return requestUri + "?" + queryString;
}
}
@@ -0,0 +1,23 @@
package kr.iotdoor.boot.model.statistics;
public class AccessStatItem {
private String label;
private long accessCount;
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public long getAccessCount() {
return accessCount;
}
public void setAccessCount(long accessCount) {
this.accessCount = accessCount;
}
}
@@ -0,0 +1,41 @@
package kr.iotdoor.boot.model.statistics;
public class AccessStatisticsSummary {
private long totalAccessCount;
private long todayAccessCount;
private long uniqueIpCount;
private long uniqueSessionCount;
public long getTotalAccessCount() {
return totalAccessCount;
}
public void setTotalAccessCount(long totalAccessCount) {
this.totalAccessCount = totalAccessCount;
}
public long getTodayAccessCount() {
return todayAccessCount;
}
public void setTodayAccessCount(long todayAccessCount) {
this.todayAccessCount = todayAccessCount;
}
public long getUniqueIpCount() {
return uniqueIpCount;
}
public void setUniqueIpCount(long uniqueIpCount) {
this.uniqueIpCount = uniqueIpCount;
}
public long getUniqueSessionCount() {
return uniqueSessionCount;
}
public void setUniqueSessionCount(long uniqueSessionCount) {
this.uniqueSessionCount = uniqueSessionCount;
}
}
@@ -0,0 +1,69 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.mapper.AccessStatisticsMapper;
import kr.iotdoor.boot.model.statistics.AccessDailyStat;
import kr.iotdoor.boot.model.statistics.AccessLog;
import kr.iotdoor.boot.model.statistics.AccessStatItem;
import kr.iotdoor.boot.model.statistics.AccessStatisticsSummary;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
@Service
public class AccessStatisticsService {
private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Seoul");
private final AccessStatisticsMapper accessStatisticsMapper;
public AccessStatisticsService(AccessStatisticsMapper accessStatisticsMapper) {
this.accessStatisticsMapper = accessStatisticsMapper;
}
@Transactional
public void record(AccessLog accessLog) {
accessLog.setAccessLogIdntfNo(newIdentifier());
accessStatisticsMapper.insertAccessLog(accessLog);
}
@Transactional(readOnly = true)
public AccessStatisticsSummary getSummary() {
LocalDate today = LocalDate.now(DEFAULT_ZONE);
AccessStatisticsSummary summary = new AccessStatisticsSummary();
summary.setTotalAccessCount(accessStatisticsMapper.countAll());
summary.setTodayAccessCount(accessStatisticsMapper.countBetween(today.atStartOfDay(), today.plusDays(1).atStartOfDay()));
summary.setUniqueIpCount(accessStatisticsMapper.countUniqueIp());
summary.setUniqueSessionCount(accessStatisticsMapper.countUniqueSession());
return summary;
}
@Transactional(readOnly = true)
public List<AccessLog> getRecentLogs(int limit) {
return accessStatisticsMapper.selectRecentLogs(limit);
}
@Transactional(readOnly = true)
public List<AccessDailyStat> getDailyStats(int days) {
LocalDate startDate = LocalDate.now(DEFAULT_ZONE).minusDays(Math.max(days - 1, 0));
return accessStatisticsMapper.selectDailyStats(startDate.atStartOfDay(), days);
}
@Transactional(readOnly = true)
public List<AccessStatItem> getTopPages(int limit) {
return accessStatisticsMapper.selectTopPages(limit);
}
@Transactional(readOnly = true)
public List<AccessStatItem> getTopReferers(int limit) {
return accessStatisticsMapper.selectTopReferers(limit);
}
private String newIdentifier() {
return UUID.randomUUID().toString().replace("-", "");
}
}
@@ -0,0 +1,165 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.mapper.MainBannerMapper;
import kr.iotdoor.boot.model.banner.MainBanner;
import kr.iotdoor.boot.model.banner.MainBannerForm;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.UUID;
@Service
public class MainBannerService {
private static final String FALLBACK_IMAGE = "/resources/kr/iotdoor/comn/site/layout/image/common/no_img.svg";
private final MainBannerMapper mainBannerMapper;
private final FileStorageService fileStorageService;
public MainBannerService(MainBannerMapper mainBannerMapper, FileStorageService fileStorageService) {
this.mainBannerMapper = mainBannerMapper;
this.fileStorageService = fileStorageService;
}
@Transactional(readOnly = true)
public List<MainBanner> getPublicBanners() {
return mainBannerMapper.selectPublicBanners().stream()
.map(this::enrich)
.toList();
}
@Transactional(readOnly = true)
public List<MainBanner> getAdminBanners() {
return mainBannerMapper.selectAdminBanners().stream()
.map(this::enrich)
.toList();
}
@Transactional(readOnly = true)
public MainBannerForm getForm(String bannerIdntfNo) {
MainBannerForm form = new MainBannerForm();
form.setUseYn("Y");
if (!StringUtils.hasText(bannerIdntfNo)) {
return form;
}
MainBanner banner = mainBannerMapper.selectBanner(bannerIdntfNo);
if (banner == null) {
return form;
}
form.setBannerIdntfNo(banner.getBannerIdntfNo());
form.setTitle(banner.getTitle());
form.setContent(banner.getContent());
form.setImageAtchFileIdntfNo(banner.getImageAtchFileIdntfNo());
form.setImageFileSn(banner.getImageFileSn());
form.setImageUrl(banner.getImageUrl());
form.setUseYn("Y".equals(banner.getUseYn()) ? "Y" : "N");
return form;
}
@Transactional
public String save(MainBannerForm form) {
boolean hasUploadedImage = hasUploadedImage(form);
if (!hasUploadedImage && !StringUtils.hasText(form.getImageUrl())) {
throw new IllegalArgumentException("배너 이미지는 필수입니다.");
}
if (StringUtils.hasText(form.getBannerIdntfNo())) {
MainBanner existing = mainBannerMapper.selectBanner(form.getBannerIdntfNo());
if (existing == null) {
throw new IllegalArgumentException("수정할 배너를 찾을 수 없습니다.");
}
MainBanner banner = toBanner(form);
banner.setBannerIdntfNo(existing.getBannerIdntfNo());
banner.setSortOrder(existing.getSortOrder());
mainBannerMapper.updateBanner(banner);
return existing.getBannerIdntfNo();
}
MainBanner banner = toBanner(form);
banner.setBannerIdntfNo(newIdentifier());
banner.setSortOrder(mainBannerMapper.selectMaxSortOrder() + 10);
mainBannerMapper.insertBanner(banner);
return banner.getBannerIdntfNo();
}
@Transactional
public void delete(String bannerIdntfNo) {
mainBannerMapper.deleteBanner(bannerIdntfNo);
}
@Transactional
public void move(String bannerIdntfNo, String direction) {
MainBanner current = mainBannerMapper.selectBanner(bannerIdntfNo);
if (current == null) {
return;
}
MainBanner target = "up".equals(direction)
? mainBannerMapper.selectPreviousBanner(current.getSortOrder())
: mainBannerMapper.selectNextBanner(current.getSortOrder());
if (target == null) {
return;
}
mainBannerMapper.updateSortOrder(current.getBannerIdntfNo(), target.getSortOrder());
mainBannerMapper.updateSortOrder(target.getBannerIdntfNo(), current.getSortOrder());
}
public String resolveImageUrl(String imageAtchFileIdntfNo, Integer imageFileSn, String imageUrl) {
if (StringUtils.hasText(imageAtchFileIdntfNo) && imageFileSn != null && imageFileSn > 0) {
return fileStorageService.toPublicUrl(imageAtchFileIdntfNo, imageFileSn);
}
if (StringUtils.hasText(imageUrl)) {
return imageUrl;
}
return FALLBACK_IMAGE;
}
private MainBanner toBanner(MainBannerForm form) {
boolean hasUploadedImage = hasUploadedImage(form);
MainBanner banner = new MainBanner();
banner.setTitle(form.getTitle().trim());
banner.setContent(form.getContent().trim());
banner.setImageAtchFileIdntfNo(hasUploadedImage ? form.getImageAtchFileIdntfNo() : null);
banner.setImageFileSn(hasUploadedImage ? form.getImageFileSn() : null);
banner.setImageUrl(hasUploadedImage ? null : trimToNull(form.getImageUrl()));
banner.setUseYn("Y".equals(form.getUseYn()) ? "Y" : "N");
return banner;
}
private MainBanner enrich(MainBanner banner) {
banner.setImageUrl(resolveImageUrl(
banner.getImageAtchFileIdntfNo(),
banner.getImageFileSn(),
banner.getImageUrl()
));
return banner;
}
private boolean hasUploadedImage(MainBannerForm form) {
return StringUtils.hasText(form.getImageAtchFileIdntfNo())
&& form.getImageFileSn() != null
&& form.getImageFileSn() > 0;
}
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
private String newIdentifier() {
return UUID.randomUUID().toString().replace("-", "");
}
}
@@ -90,7 +90,7 @@ public class PortfolioService {
item.setContent(form.getContent().trim());
item.setThumbnailAtchFileIdntfNo(thumbnailAtchFileIdntfNo);
item.setThumbnailFileSn(thumbnailFileSn);
item.setRegisteredAt(LocalDateTime.now());
item.setRegisteredAt(form.getRegisteredAt() == null ? LocalDateTime.now() : form.getRegisteredAt());
item.setRegisteredByUserIdntfNo(principal.getUserIdntfNo());
portfolioMapper.insertPortfolio(item);
}
@@ -0,0 +1,163 @@
package kr.iotdoor.boot.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import kr.iotdoor.boot.model.statistics.AccessLog;
import kr.iotdoor.boot.service.AccessStatisticsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
@Component
public class AccessLogFilter extends OncePerRequestFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(AccessLogFilter.class);
private static final List<String> SKIP_PREFIXES = List.of(
"/resources/",
"/comn/file/view/",
"/comn/user/login/",
"/mfuc/",
"/mngr/",
"/actuator/",
"/error",
"/favicon"
);
private static final List<String> STATIC_EXTENSIONS = List.of(
".css",
".js",
".map",
".ico",
".png",
".jpg",
".jpeg",
".gif",
".svg",
".webp",
".woff",
".woff2",
".ttf",
".eot",
".otf"
);
private final AccessStatisticsService accessStatisticsService;
public AccessLogFilter(AccessStatisticsService accessStatisticsService) {
this.accessStatisticsService = accessStatisticsService;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = getApplicationPath(request);
if (!StringUtils.hasText(path)) {
return true;
}
String method = request.getMethod();
if ("OPTIONS".equalsIgnoreCase(method)) {
return true;
}
for (String skipPrefix : SKIP_PREFIXES) {
if (path.startsWith(skipPrefix)) {
return true;
}
}
String lowerPath = path.toLowerCase(Locale.ROOT);
for (String staticExtension : STATIC_EXTENSIONS) {
if (lowerPath.endsWith(staticExtension)) {
return true;
}
}
return false;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
HttpSession session = request.getSession();
String sessionId = session.getId();
try {
filterChain.doFilter(request, response);
} finally {
if (!request.isAsyncStarted()) {
recordAccess(request, response, sessionId);
}
}
}
@Override
protected boolean shouldNotFilterErrorDispatch() {
return true;
}
private void recordAccess(HttpServletRequest request, HttpServletResponse response, String sessionId) {
try {
AccessLog accessLog = new AccessLog();
accessLog.setSessionId(limit(sessionId, 128));
accessLog.setRefererUrl(limit(request.getHeader("Referer"), 1000));
accessLog.setAccessIp(limit(resolveClientIp(request), 45));
accessLog.setHttpMethod(limit(request.getMethod(), 10));
accessLog.setRequestUri(limit(getApplicationPath(request), 500));
accessLog.setQueryString(limit(request.getQueryString(), 1000));
accessLog.setUserAgent(limit(request.getHeader("User-Agent"), 1000));
accessLog.setStatusCode(response.getStatus());
accessStatisticsService.record(accessLog);
} catch (RuntimeException exception) {
LOGGER.warn("Failed to record access statistics.", exception);
}
}
private String resolveClientIp(HttpServletRequest request) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (StringUtils.hasText(forwardedFor)) {
return forwardedFor.split(",")[0].trim();
}
String realIp = request.getHeader("X-Real-IP");
if (StringUtils.hasText(realIp)) {
return realIp.trim();
}
return request.getRemoteAddr();
}
private String getApplicationPath(HttpServletRequest request) {
String requestUri = request.getRequestURI();
String contextPath = request.getContextPath();
if (StringUtils.hasText(contextPath) && requestUri.startsWith(contextPath)) {
return requestUri.substring(contextPath.length());
}
return requestUri;
}
private String limit(String value, int maxLength) {
if (!StringUtils.hasText(value)) {
return null;
}
String trimmed = value.trim();
if (trimmed.length() <= maxLength) {
return trimmed;
}
return trimmed.substring(0, maxLength);
}
}
@@ -0,0 +1,112 @@
package kr.iotdoor.boot.web;
import jakarta.validation.Valid;
import kr.iotdoor.boot.model.banner.MainBannerForm;
import kr.iotdoor.boot.service.MainBannerService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class AdminMainBannerController {
private final MainBannerService mainBannerService;
public AdminMainBannerController(MainBannerService mainBannerService) {
this.mainBannerService = mainBannerService;
}
@GetMapping("/mfuc/banner/list.do")
public String bannerList(Model model) {
model.addAttribute("banners", mainBannerService.getAdminBanners());
model.addAttribute("adminMenu", "banner");
model.addAttribute("pageTitle", "배너 관리");
model.addAttribute("pageDescription", "메인 서비스 배너의 이미지, 제목, 내용, 노출 순서를 관리합니다.");
return "kr/iotdoor/boot/admin/banner/list";
}
@GetMapping("/mfuc/banner/form.do")
public String bannerForm(
@RequestParam(name = "bannerIdntfNo", required = false) String bannerIdntfNo,
Model model
) {
MainBannerForm form = mainBannerService.getForm(bannerIdntfNo);
model.addAttribute("form", form);
model.addAttribute("currentImageUrl", currentImageUrl(form));
applyFormPage(model, form);
return "kr/iotdoor/boot/admin/banner/form";
}
@PostMapping("/mfuc/banner/save.do")
public String saveBanner(
@Valid @ModelAttribute("form") MainBannerForm form,
BindingResult bindingResult,
@RequestParam(name = "useYn", required = false) String useYn,
Model model
) {
form.setUseYn("Y".equals(useYn) ? "Y" : "N");
if (!hasImage(form)) {
bindingResult.rejectValue("imageAtchFileIdntfNo", "required", "배너 이미지는 필수입니다.");
}
if (bindingResult.hasErrors()) {
model.addAttribute("currentImageUrl", currentImageUrl(form));
applyFormPage(model, form);
return "kr/iotdoor/boot/admin/banner/form";
}
try {
mainBannerService.save(form);
return "redirect:/mfuc/banner/list.do?saved=1";
} catch (IllegalArgumentException exception) {
bindingResult.reject("saveError", exception.getMessage());
model.addAttribute("currentImageUrl", currentImageUrl(form));
applyFormPage(model, form);
return "kr/iotdoor/boot/admin/banner/form";
}
}
@PostMapping("/mfuc/banner/delete.do")
public String deleteBanner(@RequestParam("bannerIdntfNo") String bannerIdntfNo) {
mainBannerService.delete(bannerIdntfNo);
return "redirect:/mfuc/banner/list.do?deleted=1";
}
@PostMapping("/mfuc/banner/move.do")
public String moveBanner(
@RequestParam("bannerIdntfNo") String bannerIdntfNo,
@RequestParam("direction") String direction
) {
mainBannerService.move(bannerIdntfNo, direction);
return "redirect:/mfuc/banner/list.do?ordered=1";
}
private void applyFormPage(Model model, MainBannerForm form) {
model.addAttribute("adminMenu", "banner");
model.addAttribute("pageTitle", StringUtils.hasText(form.getBannerIdntfNo()) ? "배너 수정" : "배너 등록");
model.addAttribute("pageDescription", "메인 서비스 영역에 노출할 배너 이미지와 문구를 입력합니다.");
}
private boolean hasImage(MainBannerForm form) {
return StringUtils.hasText(form.getImageUrl())
|| (
StringUtils.hasText(form.getImageAtchFileIdntfNo())
&& form.getImageFileSn() != null
&& form.getImageFileSn() > 0
);
}
private String currentImageUrl(MainBannerForm form) {
return mainBannerService.resolveImageUrl(
form.getImageAtchFileIdntfNo(),
form.getImageFileSn(),
form.getImageUrl()
);
}
}
@@ -46,6 +46,7 @@ public class AdminPortfolioController {
form.setContent(portfolio.getContent());
form.setThumbnailAtchFileIdntfNo(portfolio.getThumbnailAtchFileIdntfNo());
form.setThumbnailFileSn(portfolio.getThumbnailFileSn());
form.setRegisteredAt(portfolio.getRegisteredAt());
model.addAttribute("currentThumbnailUrl", portfolio.getImageUrl());
}
}
@@ -0,0 +1,30 @@
package kr.iotdoor.boot.web;
import kr.iotdoor.boot.service.AccessStatisticsService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class AdminStatisticsController {
private final AccessStatisticsService accessStatisticsService;
public AdminStatisticsController(AccessStatisticsService accessStatisticsService) {
this.accessStatisticsService = accessStatisticsService;
}
@GetMapping("/mfuc/statistics/access.do")
public String accessStatistics(Model model) {
model.addAttribute("summary", accessStatisticsService.getSummary());
model.addAttribute("dailyStats", accessStatisticsService.getDailyStats(14));
model.addAttribute("topPages", accessStatisticsService.getTopPages(10));
model.addAttribute("topReferers", accessStatisticsService.getTopReferers(10));
model.addAttribute("recentLogs", accessStatisticsService.getRecentLogs(100));
model.addAttribute("adminMenu", "statistics");
model.addAttribute("adminSubMenu", "access");
model.addAttribute("pageTitle", "접속 통계");
model.addAttribute("pageDescription", "공개 페이지 접속 기록과 주요 유입 정보를 확인합니다.");
return "kr/iotdoor/boot/admin/statistics/access";
}
}
@@ -1,22 +1,29 @@
package kr.iotdoor.boot.web;
import kr.iotdoor.boot.service.PortfolioService;
import kr.iotdoor.boot.service.MainBannerService;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class PublicPageController {
private final MainBannerService mainBannerService;
private final PortfolioService portfolioService;
public PublicPageController(PortfolioService portfolioService) {
public PublicPageController(MainBannerService mainBannerService, PortfolioService portfolioService) {
this.mainBannerService = mainBannerService;
this.portfolioService = portfolioService;
}
@GetMapping({"/", "/index.do"})
public String home(Model model) {
model.addAttribute("mainYn", "Y");
model.addAttribute("mainBanners", mainBannerService.getPublicBanners());
return "kr/iotdoor/comn/site/mainPge";
}
@@ -42,6 +49,37 @@ public class PublicPageController {
}
model.addAttribute("portfolio", portfolio);
model.addAttribute("portfolioCount", portfolioService.count());
return "kr/iotdoor/cmty/portfolio/detail";
}
@ResponseBody
@GetMapping("/portfolio/items.json")
public List<PortfolioListItemResponse> portfolioItems() {
return portfolioService.getPortfolioItems().stream()
.map(PortfolioListItemResponse::from)
.toList();
}
public record PortfolioListItemResponse(
String portfolioIdntfNo,
String title,
String summary,
String imageUrl,
LocalDateTime registeredAt,
long viewCount,
String registeredByName
) {
public static PortfolioListItemResponse from(kr.iotdoor.boot.model.portfolio.PortfolioItem item) {
return new PortfolioListItemResponse(
item.getPortfolioIdntfNo(),
item.getTitle(),
item.getSummary(),
item.getImageUrl(),
item.getRegisteredAt(),
item.getViewCount(),
item.getRegisteredByName()
);
}
}
}
+1
View File
@@ -42,6 +42,7 @@ spring:
sql:
init:
mode: always
encoding: UTF-8
jackson:
time-zone: Asia/Seoul
mvc:
+187
View File
@@ -66,6 +66,193 @@ CREATE TABLE IF NOT EXISTS TN_CONTACT_INQRY (
PRIMARY KEY (CONTACT_IDNTF_NO)
);
CREATE TABLE IF NOT EXISTS TN_ACCESS_LOG (
ACCESS_LOG_IDNTF_NO VARCHAR(32) NOT NULL,
SESSION_ID VARCHAR(128) NULL,
REFERER_URL VARCHAR(1000) NULL,
ACCESS_IP VARCHAR(45) NULL,
HTTP_METHOD VARCHAR(10) NOT NULL,
REQUEST_URI VARCHAR(500) NOT NULL,
QUERY_STRING VARCHAR(1000) NULL,
USER_AGENT VARCHAR(1000) NULL,
STATUS_CD INT NULL,
REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (ACCESS_LOG_IDNTF_NO)
);
CREATE TABLE IF NOT EXISTS TN_MAIN_BANNER (
BANNER_IDNTF_NO VARCHAR(32) NOT NULL,
TTL VARCHAR(200) NOT NULL,
CN TEXT NULL,
IMAGE_ATCH_FILE_IDNTF_NO VARCHAR(32) NULL,
IMAGE_FILE_SN INT NULL,
IMAGE_URL VARCHAR(500) NULL,
SORT_ORDR INT NOT NULL DEFAULT 0,
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
DEL_YN CHAR(1) NOT NULL DEFAULT 'N',
REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UPD_DT DATETIME NULL,
PRIMARY KEY (BANNER_IDNTF_NO),
CONSTRAINT FK_TN_MAIN_BANNER__IMAGE FOREIGN KEY (IMAGE_ATCH_FILE_IDNTF_NO)
REFERENCES TN_ATCH_FILE (ATCH_FILE_IDNTF_NO)
);
CREATE INDEX IF NOT EXISTS IDX_TN_ATCH_FILE_DTL__PATH ON TN_ATCH_FILE_DTL (FILE_STRG_PATH_NM);
CREATE INDEX IF NOT EXISTS IDX_TN_PORTFOLIO__REG_DT ON TN_PORTFOLIO (REG_DT);
CREATE INDEX IF NOT EXISTS IDX_TN_CONTACT_INQRY__SUBMIT_DT ON TN_CONTACT_INQRY (SUBMIT_DT);
CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__REG_DT ON TN_ACCESS_LOG (REG_DT);
CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__ACCESS_IP ON TN_ACCESS_LOG (ACCESS_IP);
CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__SESSION_ID ON TN_ACCESS_LOG (SESSION_ID);
CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__REQUEST_URI ON TN_ACCESS_LOG (REQUEST_URI);
CREATE INDEX IF NOT EXISTS IDX_TN_MAIN_BANNER__SORT ON TN_MAIN_BANNER (SORT_ORDR);
CREATE INDEX IF NOT EXISTS IDX_TN_MAIN_BANNER__USE_DEL ON TN_MAIN_BANNER (USE_YN, DEL_YN);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000001',
'슬라이딩자동문',
'슬라이드 오토도어를 현장 조건에 맞춰 시공합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-1.jpg',
10,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000001'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000002',
'산업용자동문',
'공장, 물류 현장에 필요한 산업용 자동문을 제공합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-2.jpg',
20,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000002'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000003',
'고급산업용자동문',
'정밀 제어와 안정성이 필요한 고급 산업 현장에 대응합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-3.jpg',
30,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000003'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000004',
'내풍압고속셔터',
'바람과 사용 빈도가 높은 공간에 적합한 고속 셔터를 시공합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-4.jpg',
40,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000004'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000005',
'오버헤드도어',
'창고와 산업 시설에 맞는 오버헤드도어를 설치합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-5.jpg?1',
50,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000005'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000006',
'방화용슬라이딩자동문',
'방화 성능이 필요한 출입 공간에 맞춘 자동문 솔루션입니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-6.jpg?1?1',
60,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000006'
);
UPDATE TN_MAIN_BANNER
SET
TTL = '슬라이딩자동문',
CN = '슬라이드 오토도어를 현장 조건에 맞춰 시공합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-1.jpg'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000001'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '산업용자동문',
CN = '공장, 물류 현장에 필요한 산업용 자동문을 제공합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-2.jpg'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000002'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '고급산업용자동문',
CN = '정밀 제어와 안정성이 필요한 고급 산업 현장에 대응합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-3.jpg'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000003'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '내풍압고속셔터',
CN = '바람과 사용 빈도가 높은 공간에 적합한 고속 셔터를 시공합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-4.jpg'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000004'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '오버헤드도어',
CN = '창고와 산업 시설에 맞는 오버헤드도어를 설치합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-5.jpg?1'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000005'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '방화용슬라이딩자동문',
CN = '방화 성능이 필요한 출입 공간에 맞춘 자동문 솔루션입니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-6.jpg?1?1'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000006'
AND (TTL LIKE '%%' OR CN LIKE '%%');
@@ -3,10 +3,15 @@ body {
overflow-x: hidden;
}
:root {
--iotdoor-admin-header-height: 104px;
}
.iotdoor-admin-shell {
min-height: 100vh;
width: 100%;
overflow-x: hidden;
padding-top: var(--iotdoor-admin-header-height);
}
.iotdoor-admin-sidebar {
@@ -14,6 +19,18 @@ body {
width: 100%;
}
.iotdoor-admin-header {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 9999;
min-height: var(--iotdoor-admin-header-height);
background: #ffffff;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
isolation: isolate;
}
.iotdoor-admin-main {
width: 100%;
max-width: 100%;
@@ -21,6 +38,10 @@ body {
}
@media (min-width: 1280px) {
.iotdoor-admin-shell {
padding-top: 0;
}
.iotdoor-admin-sidebar {
position: fixed;
top: 0;
@@ -33,6 +54,11 @@ body {
.iotdoor-admin-main {
margin-left: 290px;
width: calc(100% - 290px);
padding-top: var(--iotdoor-admin-header-height);
}
.iotdoor-admin-header {
left: 290px;
}
}
@@ -63,10 +89,23 @@ body {
}
.iotdoor-editor-surface {
position: relative;
border: 1px solid #d0d5dd;
border-radius: 1rem;
background: #ffffff;
overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.iotdoor-editor-surface.is-dragging-image {
border-color: #465fff;
background: #f8fbff;
box-shadow: 0 0 0 4px rgba(70, 95, 255, 0.14);
}
.iotdoor-editor-surface.is-uploading-image,
.iotdoor-editor-surface.is-uploading-image .ProseMirror {
cursor: progress;
}
.iotdoor-editor-surface .ProseMirror {
@@ -121,33 +160,159 @@ body {
}
.iotdoor-editor-image-node {
display: table;
max-width: 100%;
position: relative;
margin: 1.25rem 0;
}
.iotdoor-editor-image-node.is-representative::before {
content: "";
content: "✓ 대표";
position: absolute;
inset: 0;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(70, 95, 255, 0.18), rgba(22, 25, 80, 0.3));
top: 0.75rem;
left: 0.75rem;
z-index: 1;
padding: 0.45rem 0.7rem;
border-radius: 0.25rem;
background: #03c75a;
color: #ffffff;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0;
pointer-events: none;
}
.iotdoor-editor-image-node.is-representative::after {
content: "대표 이미지";
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.5rem 0.875rem;
border-radius: 999px;
background: rgba(16, 24, 40, 0.85);
color: #ffffff;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
content: none;
}
.iotdoor-editor-image-node.ProseMirror-selectednode img {
box-shadow: 0 0 0 4px rgba(70, 95, 255, 0.18);
}
.iotdoor-access-log-table {
table-layout: fixed;
width: 100%;
min-width: 1180px;
}
.iotdoor-access-log-table th,
.iotdoor-access-log-table td {
vertical-align: top;
}
.iotdoor-access-log-table .access-log-col-time {
width: 160px;
}
.iotdoor-access-log-table .access-log-col-ip {
width: 120px;
}
.iotdoor-access-log-table .access-log-col-request {
width: 150px;
}
.iotdoor-access-log-table .access-log-col-referer {
width: 260px;
}
.iotdoor-access-log-table .access-log-col-session {
width: 190px;
}
.iotdoor-access-log-table .access-log-col-status {
width: 70px;
}
.iotdoor-access-log-table .access-log-col-agent {
width: 230px;
}
.iotdoor-access-log-text {
display: block;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
line-height: 1.55;
}
.iotdoor-access-log-mono {
font-family: Consolas, "Courier New", monospace;
font-size: 0.8125rem;
}
.iotdoor-banner-table {
width: 100%;
min-width: 980px;
}
.iotdoor-banner-list-content {
max-width: 520px;
}
.iotdoor-banner-form-grid {
display: grid;
gap: 1.5rem;
}
.iotdoor-portfolio-table {
table-layout: fixed;
width: 100%;
min-width: 1040px;
}
.iotdoor-portfolio-table th,
.iotdoor-portfolio-table td {
vertical-align: top;
}
.iotdoor-portfolio-table .portfolio-col-image {
width: 170px;
}
.iotdoor-portfolio-table .portfolio-col-title {
width: auto;
}
.iotdoor-portfolio-table .portfolio-col-date {
width: 160px;
}
.iotdoor-portfolio-table .portfolio-col-author {
width: 100px;
}
.iotdoor-portfolio-table .portfolio-col-view {
width: 80px;
}
.iotdoor-portfolio-table .portfolio-col-action {
width: 92px;
}
.iotdoor-portfolio-table th {
white-space: nowrap;
}
.iotdoor-portfolio-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 64px;
}
.iotdoor-portfolio-actions a,
.iotdoor-portfolio-actions button {
min-width: 64px;
white-space: nowrap;
}
@media (min-width: 1280px) {
.iotdoor-banner-form-grid {
grid-template-columns: 360px 1fr;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,344 @@
.portfolio-post-list-panel {
background: #ffffff;
color: #202124;
}
.portfolio-post-list-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0 0 14px;
}
.portfolio-post-list-summary {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
font-size: 0.95rem;
}
.portfolio-post-list-summary a {
color: #111111;
font-weight: 500;
text-decoration: none;
}
.portfolio-post-list-summary span {
color: #333333;
}
.portfolio-post-list-toggle {
border: 0;
background: transparent;
color: #111111;
font-size: 0.95rem;
font-weight: 500;
line-height: 1;
padding: 0;
}
.portfolio-post-list-toggle:hover,
.portfolio-post-list-toggle:focus-visible {
color: #1f5aa0;
}
.portfolio-post-list-body {
border-top: 1px solid #d5d9df;
}
.portfolio-post-list-grid {
width: 100%;
height: auto;
min-height: 0;
}
.portfolio-post-list-grid.ag-theme-quartz {
--ag-font-family: "Noto Sans KR", "Helvetica Neue", Helvetica, Arial, sans-serif;
--ag-font-size: 14px;
--ag-foreground-color: #111111;
--ag-secondary-foreground-color: #94979d;
--ag-header-background-color: #ffffff;
--ag-header-foreground-color: #8c9097;
--ag-row-hover-color: #fafafa;
--ag-selected-row-background-color: #ffffff;
--ag-border-color: transparent;
--ag-row-border-color: #e6e6e6;
--ag-wrapper-border-radius: 0;
--ag-cell-horizontal-padding: 10px;
}
.portfolio-post-list-grid .ag-root-wrapper,
.portfolio-post-list-grid .ag-root,
.portfolio-post-list-grid .ag-body-viewport,
.portfolio-post-list-grid .ag-center-cols-viewport {
border: 0;
border-radius: 0;
}
.portfolio-post-list-grid .ag-header {
border-bottom: 1px solid #9da3ad;
}
.portfolio-post-list-grid .ag-header-cell {
padding-inline: 10px;
}
.portfolio-post-list-grid .ag-header-cell-label {
font-weight: 400;
}
.portfolio-post-number-header .ag-header-cell-label {
justify-content: flex-end;
}
.portfolio-post-date-header .ag-header-cell-label {
justify-content: center;
}
.portfolio-post-list-grid .ag-header-cell-resize,
.portfolio-post-list-grid .ag-sort-indicator-container {
display: none;
}
.portfolio-post-list-grid .ag-row {
border-bottom: 1px solid #e6e6e6;
}
.portfolio-post-list-grid .ag-cell {
display: flex;
align-items: center;
border: 0;
padding-inline: 10px;
}
.portfolio-post-list-grid .ag-row-current-post {
font-weight: 700;
}
.portfolio-post-number-cell {
justify-content: flex-end;
}
.portfolio-post-date-cell {
justify-content: center;
color: #111111;
}
.portfolio-post-title-link {
display: inline-block;
max-width: 100%;
overflow: hidden;
color: #111111;
text-decoration: none;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
.portfolio-post-title-link:hover {
color: #111111;
text-decoration: underline;
}
.portfolio-post-list-footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: start;
gap: 1rem;
padding-top: 12px;
}
.portfolio-post-list-footer-side {
min-height: 32px;
}
.portfolio-post-page-size-wrap {
display: flex;
justify-content: flex-end;
}
.portfolio-post-manage-link {
display: inline-flex;
align-items: center;
min-height: 32px;
border: 1px solid #d8d8d8;
background: #ffffff;
color: #333333;
padding: 0.35rem 0.85rem;
font-size: 0.875rem;
text-decoration: none;
}
.portfolio-post-manage-link:hover {
border-color: #aaaaaa;
color: #111111;
}
.portfolio-post-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 32px;
}
.portfolio-post-page-button {
min-width: 28px;
height: 28px;
border: 0;
background: transparent;
color: #111111;
font-size: 0.875rem;
line-height: 1;
padding: 0 0.35rem;
}
.portfolio-post-page-button:hover,
.portfolio-post-page-button:focus-visible {
color: #1f5aa0;
}
.portfolio-post-page-button.is-active {
border: 1px solid #dddddd;
background: #ffffff;
font-weight: 700;
}
.portfolio-post-page-size {
min-width: 106px;
height: 32px;
border: 1px solid #d8d8d8;
background: #ffffff;
color: #111111;
font-size: 0.875rem;
padding: 0 2rem 0 0.75rem;
}
@media (max-width: 575.98px) {
.portfolio-post-list-bar {
align-items: flex-start;
flex-direction: column;
}
.portfolio-post-list-toggle {
align-self: flex-end;
}
.portfolio-post-list-footer {
grid-template-columns: 1fr;
}
.portfolio-post-page-size-wrap {
justify-content: flex-start;
}
}
.portfolio-frame-card {
display: flex;
flex-direction: column;
width: 100%;
height: 520px;
overflow: hidden;
border-radius: 0.25rem;
background: #ffffff;
box-shadow: 0 0 45px rgba(0, 0, 0, 0.08);
color: inherit;
cursor: pointer;
text-decoration: none;
}
.portfolio-frame-card:hover {
color: inherit;
text-decoration: none;
}
.portfolio-frame-card:focus-visible {
outline: 3px solid rgba(31, 90, 160, 0.35);
outline-offset: 3px;
}
.portfolio-frame-image-link {
display: block;
flex: 0 0 285px;
width: 100%;
overflow: hidden;
background: #f8f9fa;
text-decoration: none;
}
.portfolio-frame-image {
display: block;
width: 100%;
height: 285px;
object-fit: cover;
transition: transform 0.35s ease;
}
.portfolio-frame-card:hover .portfolio-frame-image,
.portfolio-frame-card:focus-visible .portfolio-frame-image {
transform: scale(1.1);
}
.portfolio-frame-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: flex-end;
min-height: 0;
padding: 1.5rem;
}
.portfolio-frame-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
color: #667085;
font-size: 0.875rem;
}
.portfolio-frame-title {
display: -webkit-box;
min-height: 3.1rem;
margin: 0 0 0.9rem;
overflow: hidden;
color: #101828;
font-size: 1.25rem;
font-weight: 600;
line-height: 1.25;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.portfolio-frame-card:hover .portfolio-frame-title,
.portfolio-frame-card:focus-visible .portfolio-frame-title {
color: #1f5aa0;
}
.portfolio-frame-summary {
display: -webkit-box;
min-height: 5.4rem;
margin: 0;
overflow: hidden;
color: #667085;
font-size: 1rem;
line-height: 1.8;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
@media (max-width: 767.98px) {
.portfolio-frame-card {
height: 500px;
}
.portfolio-frame-image-link {
flex-basis: 265px;
}
.portfolio-frame-image {
height: 265px;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13,4 +13,90 @@
margin: auto 0;
border-radius: 20px;
box-shadow: 0 5px 6px rgba(0, 0, 0, .1019607843);
}
}
.portfolio-content {
color: #1d2939;
font-size: 1rem;
line-height: 1.8;
}
.portfolio-content p {
margin: 0 0 1rem;
}
.portfolio-content h1,
.portfolio-content h2,
.portfolio-content h3 {
color: #101828;
font-weight: 700;
margin: 1.5rem 0 1rem;
}
.portfolio-content h1 {
font-size: 2rem;
}
.portfolio-content h2 {
font-size: 1.5rem;
}
.portfolio-content h3 {
font-size: 1.25rem;
}
.portfolio-content blockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid #9cb9ff;
background: #f9fafb;
color: #475467;
}
.portfolio-content ul,
.portfolio-content ol {
padding-left: 1.5rem;
}
.portfolio-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1.25rem 0;
border-radius: 1rem;
}
.main-service-card {
height: 100%;
display: flex;
flex-direction: column;
}
.main-service-card-image {
width: 100%;
aspect-ratio: 3 / 2;
height: auto;
object-fit: cover;
}
.main-service-card-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 210px;
}
.main-service-card-body h4 {
word-break: keep-all;
}
.main-service-card-content {
flex: 1;
margin-bottom: 1rem;
line-height: 1.7;
word-break: keep-all;
}
.main-service-card-link {
margin-top: auto;
}
@@ -10,6 +10,25 @@
}, 1);
};
spinner();
// Mobile navbar fallback
$(document).on('click', '.navbar-toggler[data-bs-target]', function () {
if (window.bootstrap && window.bootstrap.Collapse && window.bootstrap.Collapse.DATA_KEY === 'bs.collapse') {
return;
}
var targetSelector = $(this).attr('data-bs-target');
var $target = targetSelector ? $(targetSelector) : $();
if (!$target.length) {
return;
}
var isOpen = $target.hasClass('show');
$target.toggleClass('show', !isOpen);
$(this).attr('aria-expanded', String(!isOpen));
});
// Initiate the wowjs
@@ -95,4 +114,3 @@
});
})(jQuery);
@@ -0,0 +1,181 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/boot/admin/layout/adminLayout}">
<body>
<div layout:fragment="content" class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-gray-800"
th:text="${form.bannerIdntfNo != null and !#strings.isEmpty(form.bannerIdntfNo) ? '배너 수정' : '배너 등록'}">배너 등록</h2>
<p class="mt-1 text-sm text-gray-500">메인 서비스 영역에 노출할 이미지, 제목, 내용을 입력합니다.</p>
</div>
<a class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
th:href="@{/mfuc/banner/list.do}">
목록으로
</a>
</div>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="border-b border-gray-100 px-6 py-5">
<h3 class="text-base font-medium text-gray-800">배너 정보</h3>
</div>
<div class="p-4 sm:p-6">
<form id="mainBannerForm"
method="post"
th:action="@{/mfuc/banner/save.do}"
th:object="${form}"
th:attr="data-csrf-header=${_csrf.headerName},
data-csrf-token=${_csrf.token},
data-current-image-url=${currentImageUrl}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" th:field="*{bannerIdntfNo}">
<input type="hidden" th:field="*{imageAtchFileIdntfNo}" id="imageAtchFileIdntfNo">
<input type="hidden" th:field="*{imageFileSn}" id="imageFileSn">
<input type="hidden" th:field="*{imageUrl}" id="imageUrl">
<div class="iotdoor-banner-form-grid">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700">배너 이미지</label>
<div class="rounded-2xl border border-gray-200 bg-gray-50 p-4">
<img id="bannerImagePreview"
class="aspect-4/3 w-full rounded-xl object-cover"
th:src="${currentImageUrl}"
alt="배너 이미지 미리보기">
<input id="bannerImageFile" type="file" accept="image/*" hidden>
<button id="bannerImageUploadButton"
class="mt-4 inline-flex w-full items-center justify-center rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600"
type="button">
이미지 업로드
</button>
</div>
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('imageAtchFileIdntfNo')}" th:errors="*{imageAtchFileIdntfNo}">이미지 오류</div>
</div>
<div class="space-y-6">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="title">제목</label>
<input class="h-11 w-full rounded-lg border border-gray-300 bg-white px-4 text-sm text-gray-800 outline-none transition focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10"
id="title"
th:field="*{title}"
placeholder="배너 제목을 입력해주세요.">
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">제목 오류</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="content">내용</label>
<textarea class="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm leading-6 text-gray-800 outline-none transition focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10"
style="min-height: 160px;"
id="content"
th:field="*{content}"
placeholder="배너 내용을 입력해주세요."></textarea>
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('content')}" th:errors="*{content}">내용 오류</div>
</div>
<div>
<label class="inline-flex items-center gap-3 text-sm font-medium text-gray-700">
<input class="size-5 rounded border-gray-300 text-brand-500"
type="checkbox"
th:field="*{useYn}"
value="Y">
메인에 노출
</label>
</div>
<div class="text-sm text-error-600" th:if="${#fields.hasGlobalErrors()}">
<span th:each="error : ${#fields.globalErrors()}" th:text="${error}">오류 메시지</span>
</div>
<div class="flex flex-wrap gap-3">
<button class="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600"
type="submit">
저장
</button>
<a class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
th:href="@{/mfuc/banner/list.do}">
취소
</a>
</div>
</div>
</div>
</form>
</div>
</section>
</div>
<th:block layout:fragment="pageScripts">
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('mainBannerForm');
const uploadButton = document.getElementById('bannerImageUploadButton');
const fileInput = document.getElementById('bannerImageFile');
const preview = document.getElementById('bannerImagePreview');
const atchFileIdInput = document.getElementById('imageAtchFileIdntfNo');
const fileSnInput = document.getElementById('imageFileSn');
const imageUrlInput = document.getElementById('imageUrl');
if (!form || !uploadButton || !fileInput || !preview || !atchFileIdInput || !fileSnInput || !imageUrlInput) {
return;
}
uploadButton.addEventListener('click', function () {
fileInput.click();
});
fileInput.addEventListener('change', async function () {
if (!fileInput.files || fileInput.files.length === 0) {
return;
}
const file = fileInput.files[0];
if (!file.type.startsWith('image/')) {
window.alert('이미지 파일만 업로드할 수 있습니다.');
fileInput.value = '';
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('directory', 'banner');
formData.append('description', 'main-banner-image');
const headers = {};
const csrfHeader = form.dataset.csrfHeader;
const csrfToken = form.dataset.csrfToken;
if (csrfHeader && csrfToken) {
headers[csrfHeader] = csrfToken;
}
uploadButton.disabled = true;
uploadButton.textContent = '업로드 중';
try {
const response = await fetch('/mfuc/file/upload.do', {
method: 'POST',
headers,
body: formData
});
if (!response.ok) {
throw new Error('이미지 업로드에 실패했습니다.');
}
const result = await response.json();
atchFileIdInput.value = result.atchFileIdntfNo || '';
fileSnInput.value = result.fileSn || '';
imageUrlInput.value = result.url || '';
preview.src = result.url;
} catch (error) {
window.alert(error.message || '이미지 업로드에 실패했습니다.');
} finally {
uploadButton.disabled = false;
uploadButton.textContent = '이미지 업로드';
fileInput.value = '';
}
});
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/boot/admin/layout/adminLayout}">
<body>
<div layout:fragment="content" class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-gray-800">배너관리</h2>
<p class="mt-1 text-sm text-gray-500">메인 서비스 배너의 노출 순서와 내용을 관리합니다.</p>
</div>
<a class="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600"
th:href="@{/mfuc/banner/form.do}">
새 배너 등록
</a>
</div>
<div class="rounded-xl border border-success-100 bg-success-50 px-4 py-3 text-sm text-success-700" th:if="${param.saved}">
배너가 저장되었습니다.
</div>
<div class="rounded-xl border border-success-100 bg-success-50 px-4 py-3 text-sm text-success-700" th:if="${param.deleted}">
배너가 삭제되었습니다.
</div>
<div class="rounded-xl border border-success-100 bg-success-50 px-4 py-3 text-sm text-success-700" th:if="${param.ordered}">
배너 순서가 변경되었습니다.
</div>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="border-b border-gray-100 px-6 py-5">
<h3 class="text-base font-medium text-gray-800">등록 목록</h3>
<p class="mt-1 text-sm text-gray-500">위/아래 버튼으로 메인 노출 순서를 변경할 수 있습니다.</p>
</div>
<div class="p-4 sm:p-6">
<div th:if="${#lists.isEmpty(banners)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
아직 등록된 배너가 없습니다.
</div>
<div th:unless="${#lists.isEmpty(banners)}" class="overflow-x-auto">
<table class="iotdoor-banner-table text-sm">
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">순서</th>
<th class="px-3 py-3">이미지</th>
<th class="px-3 py-3">제목/내용</th>
<th class="px-3 py-3">노출</th>
<th class="px-3 py-3">등록일시</th>
<th class="px-3 py-3">관리</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 align-top last:border-0"
th:each="banner, stat : ${banners}">
<td class="px-3 py-4" style="width: 92px;">
<div class="flex flex-col gap-2">
<form method="post" th:action="@{/mfuc/banner/move.do}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="bannerIdntfNo" th:value="${banner.bannerIdntfNo}">
<input type="hidden" name="direction" value="up">
<button class="inline-flex w-full items-center justify-center rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
type="submit"
th:disabled="${stat.first}">
</button>
</form>
<form method="post" th:action="@{/mfuc/banner/move.do}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="bannerIdntfNo" th:value="${banner.bannerIdntfNo}">
<input type="hidden" name="direction" value="down">
<button class="inline-flex w-full items-center justify-center rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
type="submit"
th:disabled="${stat.last}">
아래
</button>
</form>
</div>
</td>
<td class="px-3 py-4" style="width: 190px;">
<img class="h-28 w-40 rounded-xl object-cover"
th:src="${banner.imageUrl}"
th:alt="${banner.title}">
</td>
<td class="px-3 py-4">
<div class="font-semibold text-gray-900" th:text="${banner.title}">배너 제목</div>
<p class="iotdoor-banner-list-content mt-2 text-sm leading-6 text-gray-500" th:text="${banner.content}">배너 내용</p>
</td>
<td class="px-3 py-4">
<span class="inline-flex rounded-full px-3 py-1 text-xs font-medium"
th:classappend="${banner.useYn == 'Y'} ? ' bg-success-50 text-success-700' : ' bg-gray-100 text-gray-500'"
th:text="${banner.useYn == 'Y'} ? '노출' : '미노출'">노출</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-gray-500"
th:text="${#temporals.format(banner.registeredAt, 'yyyy-MM-dd HH:mm')}">2026-04-21 09:00</td>
<td class="px-3 py-4">
<div class="flex flex-col gap-2">
<a class="inline-flex items-center justify-center rounded-lg border border-brand-200 px-3 py-2 text-sm font-medium text-brand-600 hover:bg-brand-25"
th:href="@{/mfuc/banner/form.do(bannerIdntfNo=${banner.bannerIdntfNo})}">
수정
</a>
<form method="post" th:action="@{/mfuc/banner/delete.do}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="bannerIdntfNo" th:value="${banner.bannerIdntfNo}">
<button class="inline-flex w-full items-center justify-center rounded-lg border border-error-200 px-3 py-2 text-sm font-medium text-error-600 hover:bg-error-25"
type="submit"
onclick="return confirm('해당 배너를 삭제하시겠습니까?');">
삭제
</button>
</form>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</body>
</html>
@@ -67,6 +67,20 @@
<span class="menu-item-text">포트폴리오 관리</span>
</a>
</li>
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'banner'} ? ' menu-item-active' : ' menu-item-inactive'"
th:href="@{/mfuc/banner/list.do}">
<span class="menu-item-icon-size"
th:classappend="${adminMenu == 'banner'} ? ' menu-item-icon-active' : ' menu-item-icon-inactive'">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.8 5.4h14.4A1.8 1.8 0 0 1 21 7.2v9.6a1.8 1.8 0 0 1-1.8 1.8H4.8A1.8 1.8 0 0 1 3 16.8V7.2a1.8 1.8 0 0 1 1.8-1.8Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M7.2 9h9.6M7.2 12h6M7.2 15h3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</span>
<span class="menu-item-text">배너관리</span>
</a>
</li>
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'contact'} ? ' menu-item-active' : ' menu-item-inactive'"
@@ -83,11 +97,30 @@
</li>
</ul>
</nav>
<nav>
<h2 class="mb-4 text-xs uppercase leading-[20px] text-gray-400">통계</h2>
<ul class="flex flex-col gap-4">
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'statistics'} ? ' menu-item-active' : ' menu-item-inactive'"
th:href="@{/mfuc/statistics/access.do}">
<span class="menu-item-icon-size"
th:classappend="${adminMenu == 'statistics'} ? ' menu-item-icon-active' : ' menu-item-icon-inactive'">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.8 19.2V12M12 19.2V4.8M19.2 19.2v-9.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M3.6 19.2h16.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</span>
<span class="menu-item-text">접속 통계</span>
</a>
</li>
</ul>
</nav>
</div>
</aside>
<div class="iotdoor-admin-main">
<header class="sticky top-0 z-999 bg-white/95 border-b border-gray-200 backdrop-blur-sm">
<header class="iotdoor-admin-header bg-white border-b border-gray-200">
<div class="flex flex-col gap-4 px-4 py-4 md:flex-row md:items-center md:justify-between md:px-6">
<div>
<p class="text-sm text-gray-500">운영 중인 관리자</p>
@@ -34,6 +34,7 @@
data-current-thumbnail-file-sn=*{thumbnailFileSn}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" th:field="*{portfolioIdntfNo}">
<input type="hidden" th:field="*{registeredAt}">
<input type="hidden" th:field="*{thumbnailAtchFileIdntfNo}" id="thumbnailAtchFileIdntfNo">
<input type="hidden" th:field="*{thumbnailFileSn}" id="thumbnailFileSn">
<textarea hidden th:field="*{content}" id="content"></textarea>
@@ -33,7 +33,15 @@
아직 등록된 포트폴리오가 없습니다.
</div>
<div th:unless="${#lists.isEmpty(portfolioItems)}" class="overflow-x-auto">
<table class="min-w-full text-sm">
<table class="iotdoor-portfolio-table text-sm">
<colgroup>
<col class="portfolio-col-image">
<col class="portfolio-col-title">
<col class="portfolio-col-date">
<col class="portfolio-col-author">
<col class="portfolio-col-view">
<col class="portfolio-col-action">
</colgroup>
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">대표이미지</th>
@@ -47,7 +55,7 @@
<tbody>
<tr class="border-b border-gray-100 align-top last:border-0"
th:each="item : ${portfolioItems}">
<td class="px-3 py-4" style="width: 160px;">
<td class="px-3 py-4">
<img class="h-24 w-36 rounded-xl object-cover"
th:src="${item.imageUrl}"
th:alt="${item.title}">
@@ -61,12 +69,12 @@
공개 페이지 보기
</a>
</td>
<td class="px-3 py-4 text-gray-500"
<td class="whitespace-nowrap px-3 py-4 text-gray-500"
th:text="${#temporals.format(item.registeredAt, 'yyyy-MM-dd HH:mm')}">2026-04-17 09:00</td>
<td class="px-3 py-4 text-gray-500" th:text="${item.registeredByName}">관리자</td>
<td class="px-3 py-4 text-gray-500" th:text="${item.viewCount}">0</td>
<td class="whitespace-nowrap px-3 py-4 text-gray-500" th:text="${item.registeredByName}">관리자</td>
<td class="whitespace-nowrap px-3 py-4 text-gray-500" th:text="${item.viewCount}">0</td>
<td class="px-3 py-4">
<div class="flex flex-col gap-2">
<div class="iotdoor-portfolio-actions">
<a class="inline-flex items-center justify-center rounded-lg border border-brand-200 px-3 py-2 text-sm font-medium text-brand-600 hover:bg-brand-25"
th:href="@{/mfuc/portfolio/form.do(portfolioIdntfNo=${item.portfolioIdntfNo})}">
수정
@@ -0,0 +1,167 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/boot/admin/layout/adminLayout}">
<body>
<div layout:fragment="content" class="space-y-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">전체 접속</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${summary.totalAccessCount}">0</h3>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">오늘 접속</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${summary.todayAccessCount}">0</h3>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">고유 IP</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${summary.uniqueIpCount}">0</h3>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">고유 세션</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${summary.uniqueSessionCount}">0</h3>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-3">
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="px-6 py-5">
<h2 class="text-base font-medium text-gray-800">일별 접속</h2>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(dailyStats)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
접속 기록이 없습니다.
</div>
<div th:unless="${#lists.isEmpty(dailyStats)}" class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">날짜</th>
<th class="px-3 py-3 text-right">접속수</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 last:border-0"
th:each="stat : ${dailyStats}">
<td class="px-3 py-4 text-gray-600"
th:text="${#temporals.format(stat.accessDate, 'yyyy-MM-dd')}">2026-04-21</td>
<td class="px-3 py-4 text-right font-medium text-gray-900" th:text="${stat.accessCount}">0</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="px-6 py-5">
<h2 class="text-base font-medium text-gray-800">상위 페이지</h2>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(topPages)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
접속 기록이 없습니다.
</div>
<div th:unless="${#lists.isEmpty(topPages)}" class="space-y-4">
<div class="flex items-start justify-between gap-4"
th:each="page : ${topPages}">
<span class="min-w-0 flex-1 break-all text-sm text-gray-600" th:text="${page.label}">/</span>
<span class="text-sm font-medium text-gray-900" th:text="${page.accessCount}">0</span>
</div>
</div>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="px-6 py-5">
<h2 class="text-base font-medium text-gray-800">상위 유입</h2>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(topReferers)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
접속 기록이 없습니다.
</div>
<div th:unless="${#lists.isEmpty(topReferers)}" class="space-y-4">
<div class="flex items-start justify-between gap-4"
th:each="referer : ${topReferers}">
<span class="min-w-0 flex-1 break-all text-sm text-gray-600" th:text="${referer.label}">직접 접속</span>
<span class="text-sm font-medium text-gray-900" th:text="${referer.accessCount}">0</span>
</div>
</div>
</div>
</section>
</div>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="flex flex-wrap items-center justify-between gap-3 px-6 py-5">
<div>
<h2 class="text-base font-medium text-gray-800">최근 접속 기록</h2>
<p class="mt-1 text-sm text-gray-500">최근 100건 기준</p>
</div>
<a class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
th:href="@{/mfuc/index.do}">
대시보드
</a>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(recentLogs)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
접속 기록이 없습니다.
</div>
<div th:unless="${#lists.isEmpty(recentLogs)}" class="overflow-x-auto">
<table class="iotdoor-access-log-table text-sm">
<colgroup>
<col class="access-log-col-time">
<col class="access-log-col-ip">
<col class="access-log-col-request">
<col class="access-log-col-referer">
<col class="access-log-col-session">
<col class="access-log-col-status">
<col class="access-log-col-agent">
</colgroup>
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">등록시점</th>
<th class="px-3 py-3">IP</th>
<th class="px-3 py-3">요청</th>
<th class="px-3 py-3">Referer</th>
<th class="px-3 py-3">Session ID</th>
<th class="px-3 py-3">상태</th>
<th class="px-3 py-3">User-Agent</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 align-top last:border-0"
th:each="log : ${recentLogs}">
<td class="whitespace-nowrap px-3 py-4 text-gray-500"
th:text="${#temporals.format(log.registeredAt, 'yyyy-MM-dd HH:mm:ss')}">2026-04-21 10:00:00</td>
<td class="px-3 py-4 font-medium text-gray-900">
<span class="iotdoor-access-log-text iotdoor-access-log-mono" th:text="${log.accessIp}">127.0.0.1</span>
</td>
<td class="px-3 py-4 text-gray-600">
<div class="font-medium text-gray-900" th:text="${log.httpMethod}">GET</div>
<div class="iotdoor-access-log-text mt-1" th:title="${log.fullRequestPath}" th:text="${log.fullRequestPath}">/</div>
</td>
<td class="px-3 py-4 text-gray-500">
<span class="iotdoor-access-log-text"
th:title="${#strings.isEmpty(log.refererUrl) ? '-' : log.refererUrl}"
th:text="${#strings.isEmpty(log.refererUrl) ? '-' : log.refererUrl}">-</span>
</td>
<td class="px-3 py-4 text-gray-500">
<span class="iotdoor-access-log-text iotdoor-access-log-mono"
th:title="${#strings.isEmpty(log.sessionId) ? '-' : log.sessionId}"
th:text="${#strings.isEmpty(log.sessionId) ? '-' : log.sessionId}">session</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-gray-500" th:text="${log.statusCode}">200</td>
<td class="px-3 py-4 text-gray-500">
<span class="iotdoor-access-log-text"
th:title="${#strings.isEmpty(log.userAgent) ? '-' : log.userAgent}"
th:text="${#strings.isEmpty(log.userAgent) ? '-' : log.userAgent}">Mozilla/5.0</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</body>
</html>
@@ -5,13 +5,54 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}">
<th:block layout:fragment="pageStyles">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.css?v=20260421}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-post-list.css?v=20260421}">
</th:block>
<th:block layout:fragment="content">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="mb-4">
<a class="btn btn-sm btn-outline-secondary rounded-pill" th:href="@{/portfolio.do}">목록으로</a>
</div>
<section class="portfolio-post-list-panel mb-4"
data-portfolio-list-panel
th:data-current-id="${portfolio.portfolioIdntfNo}"
th:data-list-url="@{/portfolio/items.json}">
<div class="portfolio-post-list-bar">
<div class="portfolio-post-list-summary">
<a th:href="@{/portfolio.do}">전체보기</a>
<span th:text="|${portfolioCount}개의 글|">0개의 글</span>
</div>
<button class="portfolio-post-list-toggle"
type="button"
aria-expanded="false"
data-portfolio-list-toggle>목록열기</button>
</div>
<div class="portfolio-post-list-body" hidden data-portfolio-list-body>
<div class="portfolio-post-list-grid ag-theme-quartz" data-portfolio-list-grid></div>
<div class="portfolio-post-list-footer">
<div class="portfolio-post-list-footer-side">
<a class="portfolio-post-manage-link"
sec:authorize="hasRole('ADMIN')"
th:href="@{/mfuc/portfolio/list.do}">글 관리 열기</a>
</div>
<nav class="portfolio-post-pagination"
aria-label="포트폴리오 목록 페이지"
data-portfolio-pagination></nav>
<div class="portfolio-post-list-footer-side portfolio-post-page-size-wrap">
<label class="visually-hidden" for="portfolioPageSize">페이지당 글 수</label>
<select id="portfolioPageSize"
class="portfolio-post-page-size"
data-portfolio-page-size>
<option value="5">5줄 보기</option>
<option value="10">10줄 보기</option>
<option value="20">20줄 보기</option>
<option value="50">50줄 보기</option>
</select>
</div>
</div>
</div>
</section>
<article class="bg-white rounded shadow-sm overflow-hidden">
<!-- <img class="img-fluid w-100" th:src="${portfolio.imageUrl}" th:alt="${portfolio.title}"> -->
@@ -29,4 +70,8 @@
</div>
</div>
</th:block>
<th:block layout:fragment="script">
<script th:src="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.js?v=20260421}"></script>
</th:block>
</html>
@@ -5,6 +5,11 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}">
<th:block layout:fragment="pageStyles">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.css?v=20260421}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-post-list.css?v=20260421}">
</th:block>
<th:block layout:fragment="content">
<div class="container-xxl py-5">
<div class="container">
@@ -14,6 +19,45 @@
<p class="mb-0">대표 이미지, 상세 내용, 등록일시, 조회수까지 포함한 포트폴리오를 관리자 화면에서 직접 관리할 수 있습니다.</p>
</div>
<section class="portfolio-post-list-panel mb-5"
data-portfolio-list-panel
th:data-list-url="@{/portfolio/items.json}">
<div class="portfolio-post-list-bar">
<div class="portfolio-post-list-summary">
<a th:href="@{/portfolio.do}">전체보기</a>
<span th:text="|${#lists.size(portfolioItems)}개의 글|">0개의 글</span>
</div>
<button class="portfolio-post-list-toggle"
type="button"
aria-expanded="false"
data-portfolio-list-toggle>목록열기</button>
</div>
<div class="portfolio-post-list-body" hidden data-portfolio-list-body>
<div class="portfolio-post-list-grid ag-theme-quartz" data-portfolio-list-grid></div>
<div class="portfolio-post-list-footer">
<div class="portfolio-post-list-footer-side">
<a class="portfolio-post-manage-link"
sec:authorize="hasRole('ADMIN')"
th:href="@{/mfuc/portfolio/list.do}">글 관리 열기</a>
</div>
<nav class="portfolio-post-pagination"
aria-label="포트폴리오 목록 페이지"
data-portfolio-pagination></nav>
<div class="portfolio-post-list-footer-side portfolio-post-page-size-wrap">
<label class="visually-hidden" for="portfolioPageSize">페이지당 글 수</label>
<select id="portfolioPageSize"
class="portfolio-post-page-size"
data-portfolio-page-size>
<option value="5">5줄 보기</option>
<option value="10">10줄 보기</option>
<option value="20">20줄 보기</option>
<option value="50">50줄 보기</option>
</select>
</div>
</div>
</div>
</section>
<div th:if="${#lists.isEmpty(portfolioItems)}" class="text-center text-secondary py-5">
아직 등록된 포트폴리오가 없습니다.
</div>
@@ -22,25 +66,20 @@
<div class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.1s"
th:each="item : ${portfolioItems}">
<div class="service-item rounded overflow-hidden h-100 align-content-center">
<a th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}">
<img class="img-fluid" th:src="${item.imageUrl}" th:alt="${item.title}">
</a>
<div class="position-relative p-4 pt-0">
<div class="d-flex justify-content-between align-items-center mb-2 small text-secondary">
<a class="portfolio-frame-card"
th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}">
<span class="portfolio-frame-image-link">
<img class="portfolio-frame-image" th:src="${item.imageUrl}" th:alt="${item.title}">
</span>
<div class="portfolio-frame-body">
<div class="portfolio-frame-meta">
<span th:text="${#temporals.format(item.registeredAt, 'yyyy-MM-dd')}">2026-04-16</span>
<span>조회 <span th:text="${item.viewCount}">0</span></span>
</div>
<h4 class="mb-3">
<a class="text-dark" th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}" th:text="${item.title}">제목</a>
</h4>
<p class="mb-3" th:text="${item.summary}">설명</p>
<a class="btn btn-sm btn-outline-primary rounded-pill"
th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}">
자세히 보기
</a>
<h4 class="portfolio-frame-title" th:text="${item.title}">제목</h4>
<p class="portfolio-frame-summary" th:text="${item.summary}">설명</p>
</div>
</div>
</a>
</div>
</div>
@@ -50,4 +89,8 @@
</div>
</div>
</th:block>
<th:block layout:fragment="script">
<script th:src="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.js?v=20260421}"></script>
</th:block>
</html>
@@ -1,5 +1,6 @@
<!-- Spinner Start -->
<div id="spinner" class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
<div id="spinner"
class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
<span class="sr-only">Loading...</span>
</div>
@@ -30,9 +31,12 @@
</small>
</div>
<div class="h-100 d-inline-flex align-items-center mx-n2">
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i class="fab fa-facebook-f"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i class="fab fa-twitter"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i class="fab fa-linkedin-in"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i
class="fab fa-facebook-f"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i
class="fab fa-twitter"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i
class="fab fa-linkedin-in"></i></a>
<a class="btn btn-square btn-link rounded-0" href=""><i class="fab fa-instagram"></i></a>
</div>
</div>
@@ -47,13 +51,14 @@
<!-- <h2 class="m-0 text-primary">국제오토텍</h2> -->
<img src="/resources/kr/iotdoor/comn/site/layout/image/template/logo.jpg" alt="로고" style="height:75px;">
</a>
<button type="button" class="navbar-toggler me-4" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
<button type="button" class="navbar-toggler me-4" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="메뉴 열기">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<div class="navbar-nav ms-auto p-4 p-lg-0">
<a href="/" class="nav-item nav-link">Home</a>
<a href="/" class="nav-item nav-link">About</a>
<!-- <a href="/" class="nav-item nav-link">About</a> -->
<a href="/portfolio.do" class="nav-item nav-link">Portfolio</a>
<a href="/contact.do" class="nav-item nav-link">Contact</a>
<a href="/mfuc/index.do" class="nav-item nav-link">ADMIN</a>
@@ -38,6 +38,8 @@
<!-- Template Stylesheet -->
<link href="/resources/kr/iotdoor/comn/site/layout/css/templateStyle.css?220712" rel="stylesheet" />
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/custom.css?v=20260421}">
<th:block layout:fragment="pageStyles"></th:block>
<!-- <link th:if="${mainYn == 'Y' && popupList.size > 0}" rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/popup/css/popupListLayout.css}"> -->
<!-- <script th:if="${not #strings.isEmpty(checkMessage)}">alert('[[${checkMessage}]]');</script> -->
@@ -101,7 +103,7 @@
<script th:src="@{'//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js'}"></script>
<!-- Includes all JS & CSS for the JavaScript Data Grid -->
<script th:src="@{/resources/kr/iotdoor/comn/site/layout/js/common.js?v=20240716}"></script>
<script th:src="@{/resources/kr/iotdoor/comn/site/layout/js/main.js?v=20240716}"></script>
<script th:src="@{/resources/kr/iotdoor/comn/site/layout/js/main.js?v=20260507}"></script>
<script th:if="${mainYn == 'Y' and !#lists.isEmpty(popupList)}" th:src="@{/resources/kr/iotdoor/comn/popup/js/popupListLayout.js}"></script>
<th:block layout:fragment="script"></th:block>
</body>
@@ -83,139 +83,27 @@
</h1>
</div>
<div class="row g-4">
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.1s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-1.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-solar-panel fa-3x"></i>
</div>
<h4 class="mb-3">슬라이딩자동문</h4>
<!-- <p>슬라이드 오토도어를 전문적으로 다룹니다.</p> -->
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i class="fa fa-arrow-right ms-2"></i></a>
</div>
<div th:if="${#lists.isEmpty(mainBanners)}" class="col-12">
<div class="rounded bg-light p-4 text-center text-muted">
등록된 배너가 없습니다.
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.3s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-2.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="col-md-6 col-lg-4 wow fadeInUp"
th:each="banner, bannerStat : ${mainBanners}"
th:attr="data-wow-delay=${bannerStat.index % 3 == 0 ? '0.1s' : (bannerStat.index % 3 == 1 ? '0.3s' : '0.5s')}">
<div class="service-item main-service-card rounded overflow-hidden">
<img class="img-fluid main-service-card-image"
th:src="${banner.imageUrl}"
th:alt="${banner.title}"/>
<div class="main-service-card-body position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-wind fa-3x"></i>
<i th:class="${bannerStat.index % 3 == 0 ? 'fa fa-solar-panel fa-3x' : (bannerStat.index % 3 == 1 ? 'fa fa-wind fa-3x' : 'fa fa-lightbulb fa-3x')}"></i>
</div>
<h4 class="mb-3">산업용자동문</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.5s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-3.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-lightbulb fa-3x"></i>
</div>
<h4 class="mb-3">고급산업용자동문</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.1s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-4.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-solar-panel fa-3x"></i>
</div>
<h4 class="mb-3">내풍압고속셔터</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.3s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-5.jpg?1"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-wind fa-3x"></i>
</div>
<h4 class="mb-3">오버헤드도어</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.5s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-6.jpg?1?1"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-lightbulb fa-3x"></i>
</div>
<h4 class="mb-3">방화용슬라이딩자동문</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
<h4 class="mb-3" th:text="${banner.title}">배너 제목</h4>
<p class="main-service-card-content" th:if="${!#strings.isEmpty(banner.content)}" th:text="${banner.content}">배너 내용</p>
<a class="main-service-card-link small fw-medium" th:href="@{/portfolio.do}">
Read More<i class="fa fa-arrow-right ms-2"></i>
</a>
</div>
</div>
</div>
@@ -340,17 +228,17 @@
<h6 class="text-primary">Contact Us</h6>
<p class="mb-4">내용을 자세하게 입력해주시면 상담진행시에 좀 더 원활하게 상담이 가능합니다. 연중무휴 24시간 주저말고 남겨주세요. </p>
<form id="contactForm">
<input type="hidden" name="title" value="국제오토텍 문의 접수">
<input type="hidden" name="title">
<div class="row g-3">
<div class="col-md-6">
<div class="form-floating">
<input type="text" class="form-control" id="name" name="name" placeholder="Your Name" value="김찬솔">
<input type="text" class="form-control" id="name" name="name" placeholder="Your Name">
<label for="name">Name</label>
</div>
</div>
<div class="col-md-6">
<div class="form-floating">
<input type="email" class="form-control" id="email" name="email" placeholder="Your Email" value="so__l91@naver.com">
<input type="email" class="form-control" id="email" name="email" placeholder="Your Email">
<label for="email">Email</label>
</div>
<div id="emailError" style="color:red; font-size:13px; display:none;">
@@ -359,7 +247,7 @@
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control" id="phone" name="phone" placeholder="Phone" value="010-7722-0709">
<input type="text" class="form-control" id="phone" name="phone" placeholder="Phone">
<label for="subject">Phone</label>
</div>
<div id="phoneError" style="color:red; font-size:13px; display:none;">
@@ -368,13 +256,13 @@
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control" id="subject" name="subject" placeholder="Subject" value="제목324">
<input type="text" class="form-control" id="subject" name="subject" placeholder="Subject">
<label for="subject">Subject</label>
</div>
</div>
<div class="col-12">
<div class="form-floating">
<textarea class="form-control" placeholder="Leave a message here" id="message" name="message" style="height: 100px">내용324234</textarea>
<textarea class="form-control" placeholder="Leave a message here" id="message" name="message" style="height: 100px"></textarea>
<label for="message">Message</label>
</div>
</div>