Compare commits
5 Commits
e90e8bfc34
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 02c8cf004d | |||
| c9855a7196 | |||
| 6acb13c165 | |||
| cb1e53e099 | |||
| 9d38a8f54c |
@@ -5,3 +5,5 @@ target/
|
|||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
node_modules/
|
node_modules/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|||||||
Vendored
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"java.compile.nullAnalysis.mode": "disabled",
|
"java.compile.nullAnalysis.mode": "disabled",
|
||||||
"java.configuration.updateBuildConfiguration": "automatic"
|
"java.configuration.updateBuildConfiguration": "automatic",
|
||||||
|
"git.useIntegratedAskPass": false,
|
||||||
|
"git.terminalAuthentication": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 팝업관리 기능 작업내용
|
||||||
|
|
||||||
|
## 작업 개요
|
||||||
|
|
||||||
|
- 관리자에 `팝업관리` 메뉴를 추가했습니다.
|
||||||
|
- 배너관리와 동일한 목록, 등록, 수정, 삭제, 순서 변경 흐름으로 구성했습니다.
|
||||||
|
- 팝업 등록/수정 화면에는 기존 TipTap 에디터를 재사용해 텍스트와 이미지를 함께 입력할 수 있도록 적용했습니다.
|
||||||
|
- 사용자 메인 index 진입 시 노출 대상 팝업이 있으면 모달 캐러셀로 표시되도록 추가했습니다.
|
||||||
|
|
||||||
|
## DB 스키마
|
||||||
|
|
||||||
|
- `TN_MAIN_POPUP` 테이블을 추가했습니다.
|
||||||
|
- 주요 컬럼은 다음과 같습니다.
|
||||||
|
- `POPUP_IDNTF_NO`: 팝업 식별번호
|
||||||
|
- `TTL`: 제목
|
||||||
|
- `CN`: 에디터 본문 HTML
|
||||||
|
- `SORT_ORDR`: 노출 순서
|
||||||
|
- `USE_YN`: 노출 여부
|
||||||
|
- `REG_DT`: 작성일시
|
||||||
|
- `REG_USER_IDNTF_NO`: 작성자
|
||||||
|
- `DEL_YN`: 삭제 여부
|
||||||
|
- `UPD_DT`: 수정일시
|
||||||
|
- 정렬 및 노출 조회용 인덱스를 추가했습니다.
|
||||||
|
|
||||||
|
## 관리자 기능
|
||||||
|
|
||||||
|
- 신규 URL
|
||||||
|
- 목록: `/mfuc/popup/list.do`
|
||||||
|
- 등록/수정: `/mfuc/popup/form.do`
|
||||||
|
- 저장: `/mfuc/popup/save.do`
|
||||||
|
- 삭제: `/mfuc/popup/delete.do`
|
||||||
|
- 순서 변경: `/mfuc/popup/move.do`
|
||||||
|
- 목록 화면에 작성자, 작성일시, 노출여부를 표시했습니다.
|
||||||
|
- 게시기간을 선택 입력할 수 있으며, 미입력 시 기간 제한 없이 노출됩니다.
|
||||||
|
- 등록 시 로그인한 관리자 식별번호를 작성자로 저장합니다.
|
||||||
|
- 수정 시 기존 작성자 정보는 유지합니다.
|
||||||
|
|
||||||
|
## 사용자 메인 팝업
|
||||||
|
|
||||||
|
- 메인 페이지 진입 시 `USE_YN = 'Y'`, `DEL_YN = 'N'` 팝업만 노출합니다.
|
||||||
|
- 게시 시작일시와 종료일시가 설정된 경우 현재 일시가 게시기간 안에 있는 팝업만 노출합니다.
|
||||||
|
- 게시기간이 비어 있는 팝업은 기간 제한 없이 계속 노출합니다.
|
||||||
|
- 여러 팝업이 있으면 5초 간격으로 자동 이동합니다.
|
||||||
|
- 마지막 팝업 이후에는 첫 번째 팝업으로 돌아갑니다.
|
||||||
|
- 좌우 이동 버튼과 하단 불릿 이동을 지원합니다.
|
||||||
|
- 우측 상단 닫기 버튼을 추가했습니다.
|
||||||
|
- 우측 하단 `오늘 하루 표시하지않기` 체크 후 닫으면 로컬스토리지에 오늘 날짜를 저장합니다.
|
||||||
|
- 로컬스토리지 키는 `iotdoor.mainPopup.hiddenDate`입니다.
|
||||||
|
- 팝업 창은 고정 폭 기반으로 구성했고, 본문 영역만 세로 스크롤되도록 처리했습니다.
|
||||||
|
- 모바일에서는 화면 폭에 맞춰 좁혀지고 좌우 버튼은 숨기도록 적용했습니다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `npm run build:admin-js`
|
||||||
|
- `.\gradlew.bat test`
|
||||||
-2131
File diff suppressed because it is too large
Load Diff
-2117
File diff suppressed because it is too large
Load Diff
-53
@@ -1,53 +0,0 @@
|
|||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>if "Windows_NT" == "Windows_NT" setlocal
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>set DIRNAME=D:\Projects\998.IOTDOOR\iotdoor_v5\
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>if "D:\Projects\998.IOTDOOR\iotdoor_v5\" == "" set DIRNAME=.
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>set APP_BASE_NAME=gradlew
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>set APP_HOME=D:\Projects\998.IOTDOOR\iotdoor_v5\
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>for %i in ("D:\Projects\998.IOTDOOR\iotdoor_v5\") do set APP_HOME=%~fi
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>set APP_HOME=D:\Projects\998.IOTDOOR\iotdoor_v5\
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>set JAVA_HOME=C:\Program Files\Java\jdk-17
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>set JAVA_EXE=C:\Program Files\Java\jdk-17 /bin/java.exe
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>if exist "C:\Program Files\Java\jdk-17 /bin/java.exe" goto execute
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>echo. 1>&2
|
|
||||||
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>echo ERROR: JAVA_HOME is set to an invalid directory: C:\Program Files\Java\jdk-17 1>&2
|
|
||||||
ERROR: JAVA_HOME is set to an invalid directory: C:\Program Files\Java\jdk-17
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>echo. 1>&2
|
|
||||||
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>echo location of your Java installation. 1>&2
|
|
||||||
location of your Java installation.
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>goto fail
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>rem the _cmd.exe /c_ return code!
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>set EXIT_CODE=0
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>if 0 EQU 0 set EXIT_CODE=1
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>if not "" == "" exit 1
|
|
||||||
|
|
||||||
D:\Projects\998.IOTDOOR\iotdoor_v5>exit /b 1
|
|
||||||
@@ -52,6 +52,14 @@ tasks.named('bootRun') {
|
|||||||
systemProperty 'spring.devtools.restart.enabled', 'true'
|
systemProperty 'spring.devtools.restart.enabled', 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.named('bootJar') {
|
||||||
|
archiveFileName = 'iotdoor-boot.jar'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('jar') {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,9 +277,14 @@ function syncToolbar(editor, toolbar) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 에디터 HTML과 대표 이미지 hidden 값을 폼 제출 전에 동기화합니다.
|
||||||
function syncHiddenFields(editor, hiddenInput, representativeIdInput, representativeSnInput) {
|
function syncHiddenFields(editor, hiddenInput, representativeIdInput, representativeSnInput) {
|
||||||
hiddenInput.value = editor.getHTML();
|
hiddenInput.value = editor.getHTML();
|
||||||
|
|
||||||
|
if (!representativeIdInput || !representativeSnInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const representative = getRepresentativeImage(editor);
|
const representative = getRepresentativeImage(editor);
|
||||||
representativeIdInput.value = representative?.atchFileIdntfNo || "";
|
representativeIdInput.value = representative?.atchFileIdntfNo || "";
|
||||||
representativeSnInput.value = representative?.fileSn ? String(representative.fileSn) : "";
|
representativeSnInput.value = representative?.fileSn ? String(representative.fileSn) : "";
|
||||||
@@ -380,6 +385,7 @@ function insertImageNodes(editor, imageNodes, insertAt) {
|
|||||||
chain.insertContent(imageNodes).run();
|
chain.insertContent(imageNodes).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이미지 업로드 후 에디터 커서 위치에 이미지 노드를 삽입합니다.
|
||||||
async function uploadAndInsertImages(files, editor, options, form, insertAt, editorElement) {
|
async function uploadAndInsertImages(files, editor, options, form, insertAt, editorElement) {
|
||||||
const imageFiles = getImageFiles(files);
|
const imageFiles = getImageFiles(files);
|
||||||
|
|
||||||
@@ -392,7 +398,7 @@ async function uploadAndInsertImages(files, editor, options, form, insertAt, edi
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const imageNodes = [];
|
const imageNodes = [];
|
||||||
let shouldBeRepresentative = !getRepresentativeImage(editor);
|
let shouldBeRepresentative = options.requireRepresentative !== false && !getRepresentativeImage(editor);
|
||||||
|
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
const payload = await uploadImage(file, options, form);
|
const payload = await uploadImage(file, options, form);
|
||||||
@@ -452,6 +458,7 @@ function ensureLegacyRepresentative(editor, form) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 툴바 버튼 클릭을 TipTap 에디터 명령으로 연결합니다.
|
||||||
function attachToolbarActions(editor, toolbar, fileInput) {
|
function attachToolbarActions(editor, toolbar, fileInput) {
|
||||||
toolbar.addEventListener("mousedown", (event) => {
|
toolbar.addEventListener("mousedown", (event) => {
|
||||||
if (event.target.closest("button[data-editor-action]")) {
|
if (event.target.closest("button[data-editor-action]")) {
|
||||||
@@ -529,6 +536,10 @@ function attachToolbarActions(editor, toolbar, fileInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "setRepresentative") {
|
if (action === "setRepresentative") {
|
||||||
|
if (!fileInput.dataset.requireRepresentative) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedPosition = getSelectedImagePosition(editor);
|
const selectedPosition = getSelectedImagePosition(editor);
|
||||||
if (selectedPosition === null) {
|
if (selectedPosition === null) {
|
||||||
window.alert("대표 이미지로 지정할 이미지를 먼저 선택해주세요.");
|
window.alert("대표 이미지로 지정할 이미지를 먼저 선택해주세요.");
|
||||||
@@ -604,18 +615,20 @@ function attachImageDropUpload(editor, editorElement, options, form) {
|
|||||||
editorElement.addEventListener("drop", handleDrop, true);
|
editorElement.addEventListener("drop", handleDrop, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 관리자 콘텐츠 에디터를 초기화하고 폼 hidden 값과 업로드 동작을 연결합니다.
|
||||||
function initPortfolioEditor(options) {
|
function initPortfolioEditor(options) {
|
||||||
|
const requireRepresentative = options.requireRepresentative !== false;
|
||||||
const form = document.querySelector(options.formSelector);
|
const form = document.querySelector(options.formSelector);
|
||||||
const editorElement = document.querySelector(options.editorSelector);
|
const editorElement = document.querySelector(options.editorSelector);
|
||||||
const toolbar = document.querySelector(options.toolbarSelector);
|
const toolbar = document.querySelector(options.toolbarSelector);
|
||||||
const hiddenInput = document.querySelector(options.hiddenInputSelector);
|
const hiddenInput = document.querySelector(options.hiddenInputSelector);
|
||||||
const fileInput = document.querySelector(options.fileInputSelector);
|
const fileInput = document.querySelector(options.fileInputSelector);
|
||||||
const representativeIdInput = document.querySelector(
|
const representativeIdInput = requireRepresentative
|
||||||
options.representativeIdSelector,
|
? document.querySelector(options.representativeIdSelector)
|
||||||
);
|
: null;
|
||||||
const representativeSnInput = document.querySelector(
|
const representativeSnInput = requireRepresentative
|
||||||
options.representativeSnSelector,
|
? document.querySelector(options.representativeSnSelector)
|
||||||
);
|
: null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!form ||
|
!form ||
|
||||||
@@ -623,12 +636,13 @@ function initPortfolioEditor(options) {
|
|||||||
!toolbar ||
|
!toolbar ||
|
||||||
!hiddenInput ||
|
!hiddenInput ||
|
||||||
!fileInput ||
|
!fileInput ||
|
||||||
!representativeIdInput ||
|
(requireRepresentative && (!representativeIdInput || !representativeSnInput))
|
||||||
!representativeSnInput
|
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileInput.dataset.requireRepresentative = requireRepresentative ? "Y" : "";
|
||||||
|
|
||||||
const editor = new Editor({
|
const editor = new Editor({
|
||||||
element: editorElement,
|
element: editorElement,
|
||||||
extensions: [StarterKit, PortfolioImage],
|
extensions: [StarterKit, PortfolioImage],
|
||||||
@@ -639,7 +653,9 @@ function initPortfolioEditor(options) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
onCreate({ editor: createdEditor }) {
|
onCreate({ editor: createdEditor }) {
|
||||||
ensureLegacyRepresentative(createdEditor, form);
|
if (requireRepresentative) {
|
||||||
|
ensureLegacyRepresentative(createdEditor, form);
|
||||||
|
}
|
||||||
syncHiddenFields(
|
syncHiddenFields(
|
||||||
createdEditor,
|
createdEditor,
|
||||||
hiddenInput,
|
hiddenInput,
|
||||||
@@ -686,7 +702,7 @@ function initPortfolioEditor(options) {
|
|||||||
|
|
||||||
form.addEventListener("submit", (event) => {
|
form.addEventListener("submit", (event) => {
|
||||||
syncHiddenFields(editor, hiddenInput, representativeIdInput, representativeSnInput);
|
syncHiddenFields(editor, hiddenInput, representativeIdInput, representativeSnInput);
|
||||||
if (!representativeIdInput.value || !representativeSnInput.value) {
|
if (requireRepresentative && (!representativeIdInput.value || !representativeSnInput.value)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.alert("대표 이미지로 지정할 본문 이미지를 선택해주세요.");
|
window.alert("대표 이미지로 지정할 본문 이미지를 선택해주세요.");
|
||||||
}
|
}
|
||||||
@@ -695,4 +711,12 @@ function initPortfolioEditor(options) {
|
|||||||
return editor;
|
return editor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { initPortfolioEditor };
|
// 대표 이미지 없이 본문 HTML만 저장하는 에디터를 초기화합니다.
|
||||||
|
function initContentEditor(options) {
|
||||||
|
return initPortfolioEditor({
|
||||||
|
...options,
|
||||||
|
requireRepresentative: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initPortfolioEditor, initContentEditor };
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package kr.iotdoor.boot.mapper;
|
||||||
|
|
||||||
|
import kr.iotdoor.boot.model.popup.MainPopup;
|
||||||
|
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 MainPopupMapper {
|
||||||
|
|
||||||
|
/** 관리자 팝업 목록을 정렬 순서대로 조회합니다. */
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
p.POPUP_IDNTF_NO,
|
||||||
|
p.TTL AS TITLE,
|
||||||
|
p.CN AS CONTENT,
|
||||||
|
p.SORT_ORDR AS SORT_ORDER,
|
||||||
|
p.PSTG_BGNG_DT AS PUBLISH_START_AT,
|
||||||
|
p.PSTG_END_DT AS PUBLISH_END_AT,
|
||||||
|
p.USE_YN,
|
||||||
|
p.DEL_YN,
|
||||||
|
p.REG_DT AS REGISTERED_AT,
|
||||||
|
p.REG_USER_IDNTF_NO AS REGISTERED_BY_USER_IDNTF_NO,
|
||||||
|
u.USER_NM AS REGISTERED_BY_NAME,
|
||||||
|
p.UPD_DT AS UPDATED_AT
|
||||||
|
FROM TN_MAIN_POPUP p
|
||||||
|
JOIN TN_USER u ON u.USER_IDNTF_NO = p.REG_USER_IDNTF_NO
|
||||||
|
WHERE p.DEL_YN = 'N'
|
||||||
|
ORDER BY p.SORT_ORDR ASC, p.REG_DT ASC
|
||||||
|
""")
|
||||||
|
List<MainPopup> selectAdminPopups();
|
||||||
|
|
||||||
|
/** 공개 사이트에 노출할 팝업 목록을 조회합니다. */
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
p.POPUP_IDNTF_NO,
|
||||||
|
p.TTL AS TITLE,
|
||||||
|
p.CN AS CONTENT,
|
||||||
|
p.SORT_ORDR AS SORT_ORDER,
|
||||||
|
p.PSTG_BGNG_DT AS PUBLISH_START_AT,
|
||||||
|
p.PSTG_END_DT AS PUBLISH_END_AT,
|
||||||
|
p.USE_YN,
|
||||||
|
p.DEL_YN,
|
||||||
|
p.REG_DT AS REGISTERED_AT,
|
||||||
|
p.REG_USER_IDNTF_NO AS REGISTERED_BY_USER_IDNTF_NO,
|
||||||
|
u.USER_NM AS REGISTERED_BY_NAME,
|
||||||
|
p.UPD_DT AS UPDATED_AT
|
||||||
|
FROM TN_MAIN_POPUP p
|
||||||
|
JOIN TN_USER u ON u.USER_IDNTF_NO = p.REG_USER_IDNTF_NO
|
||||||
|
WHERE p.DEL_YN = 'N'
|
||||||
|
AND p.USE_YN = 'Y'
|
||||||
|
AND (p.PSTG_BGNG_DT IS NULL OR p.PSTG_BGNG_DT <= CURRENT_TIMESTAMP)
|
||||||
|
AND (p.PSTG_END_DT IS NULL OR p.PSTG_END_DT >= CURRENT_TIMESTAMP)
|
||||||
|
ORDER BY p.SORT_ORDR ASC, p.REG_DT ASC
|
||||||
|
""")
|
||||||
|
List<MainPopup> selectPublicPopups();
|
||||||
|
|
||||||
|
/** 식별번호로 팝업 단건을 조회합니다. */
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
p.POPUP_IDNTF_NO,
|
||||||
|
p.TTL AS TITLE,
|
||||||
|
p.CN AS CONTENT,
|
||||||
|
p.SORT_ORDR AS SORT_ORDER,
|
||||||
|
p.PSTG_BGNG_DT AS PUBLISH_START_AT,
|
||||||
|
p.PSTG_END_DT AS PUBLISH_END_AT,
|
||||||
|
p.USE_YN,
|
||||||
|
p.DEL_YN,
|
||||||
|
p.REG_DT AS REGISTERED_AT,
|
||||||
|
p.REG_USER_IDNTF_NO AS REGISTERED_BY_USER_IDNTF_NO,
|
||||||
|
u.USER_NM AS REGISTERED_BY_NAME,
|
||||||
|
p.UPD_DT AS UPDATED_AT
|
||||||
|
FROM TN_MAIN_POPUP p
|
||||||
|
JOIN TN_USER u ON u.USER_IDNTF_NO = p.REG_USER_IDNTF_NO
|
||||||
|
WHERE p.POPUP_IDNTF_NO = #{popupIdntfNo}
|
||||||
|
AND p.DEL_YN = 'N'
|
||||||
|
""")
|
||||||
|
MainPopup selectPopup(@Param("popupIdntfNo") String popupIdntfNo);
|
||||||
|
|
||||||
|
/** 현재 팝업보다 앞선 순서의 팝업을 조회합니다. */
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
p.POPUP_IDNTF_NO,
|
||||||
|
p.TTL AS TITLE,
|
||||||
|
p.CN AS CONTENT,
|
||||||
|
p.SORT_ORDR AS SORT_ORDER,
|
||||||
|
p.PSTG_BGNG_DT AS PUBLISH_START_AT,
|
||||||
|
p.PSTG_END_DT AS PUBLISH_END_AT,
|
||||||
|
p.USE_YN,
|
||||||
|
p.DEL_YN,
|
||||||
|
p.REG_DT AS REGISTERED_AT,
|
||||||
|
p.REG_USER_IDNTF_NO AS REGISTERED_BY_USER_IDNTF_NO,
|
||||||
|
u.USER_NM AS REGISTERED_BY_NAME,
|
||||||
|
p.UPD_DT AS UPDATED_AT
|
||||||
|
FROM TN_MAIN_POPUP p
|
||||||
|
JOIN TN_USER u ON u.USER_IDNTF_NO = p.REG_USER_IDNTF_NO
|
||||||
|
WHERE p.DEL_YN = 'N'
|
||||||
|
AND p.SORT_ORDR < #{sortOrder}
|
||||||
|
ORDER BY p.SORT_ORDR DESC, p.REG_DT DESC
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
MainPopup selectPreviousPopup(@Param("sortOrder") int sortOrder);
|
||||||
|
|
||||||
|
/** 현재 팝업보다 뒤선 순서의 팝업을 조회합니다. */
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
p.POPUP_IDNTF_NO,
|
||||||
|
p.TTL AS TITLE,
|
||||||
|
p.CN AS CONTENT,
|
||||||
|
p.SORT_ORDR AS SORT_ORDER,
|
||||||
|
p.PSTG_BGNG_DT AS PUBLISH_START_AT,
|
||||||
|
p.PSTG_END_DT AS PUBLISH_END_AT,
|
||||||
|
p.USE_YN,
|
||||||
|
p.DEL_YN,
|
||||||
|
p.REG_DT AS REGISTERED_AT,
|
||||||
|
p.REG_USER_IDNTF_NO AS REGISTERED_BY_USER_IDNTF_NO,
|
||||||
|
u.USER_NM AS REGISTERED_BY_NAME,
|
||||||
|
p.UPD_DT AS UPDATED_AT
|
||||||
|
FROM TN_MAIN_POPUP p
|
||||||
|
JOIN TN_USER u ON u.USER_IDNTF_NO = p.REG_USER_IDNTF_NO
|
||||||
|
WHERE p.DEL_YN = 'N'
|
||||||
|
AND p.SORT_ORDR > #{sortOrder}
|
||||||
|
ORDER BY p.SORT_ORDR ASC, p.REG_DT ASC
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
MainPopup selectNextPopup(@Param("sortOrder") int sortOrder);
|
||||||
|
|
||||||
|
/** 등록된 팝업의 최대 정렬 순서를 조회합니다. */
|
||||||
|
@Select("""
|
||||||
|
SELECT COALESCE(MAX(SORT_ORDR), 0)
|
||||||
|
FROM TN_MAIN_POPUP
|
||||||
|
WHERE DEL_YN = 'N'
|
||||||
|
""")
|
||||||
|
int selectMaxSortOrder();
|
||||||
|
|
||||||
|
/** 신규 팝업을 등록합니다. */
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO TN_MAIN_POPUP (
|
||||||
|
POPUP_IDNTF_NO,
|
||||||
|
TTL,
|
||||||
|
CN,
|
||||||
|
SORT_ORDR,
|
||||||
|
PSTG_BGNG_DT,
|
||||||
|
PSTG_END_DT,
|
||||||
|
USE_YN,
|
||||||
|
DEL_YN,
|
||||||
|
REG_DT,
|
||||||
|
REG_USER_IDNTF_NO,
|
||||||
|
UPD_DT
|
||||||
|
) VALUES (
|
||||||
|
#{popupIdntfNo},
|
||||||
|
#{title},
|
||||||
|
#{content},
|
||||||
|
#{sortOrder},
|
||||||
|
#{publishStartAt},
|
||||||
|
#{publishEndAt},
|
||||||
|
#{useYn},
|
||||||
|
'N',
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
#{registeredByUserIdntfNo},
|
||||||
|
NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
int insertPopup(MainPopup mainPopup);
|
||||||
|
|
||||||
|
/** 기존 팝업의 제목, 내용, 노출 여부를 수정합니다. */
|
||||||
|
@Update("""
|
||||||
|
UPDATE TN_MAIN_POPUP
|
||||||
|
SET
|
||||||
|
TTL = #{title},
|
||||||
|
CN = #{content},
|
||||||
|
PSTG_BGNG_DT = #{publishStartAt},
|
||||||
|
PSTG_END_DT = #{publishEndAt},
|
||||||
|
USE_YN = #{useYn},
|
||||||
|
UPD_DT = CURRENT_TIMESTAMP
|
||||||
|
WHERE POPUP_IDNTF_NO = #{popupIdntfNo}
|
||||||
|
AND DEL_YN = 'N'
|
||||||
|
""")
|
||||||
|
int updatePopup(MainPopup mainPopup);
|
||||||
|
|
||||||
|
/** 팝업 정렬 순서를 수정합니다. */
|
||||||
|
@Update("""
|
||||||
|
UPDATE TN_MAIN_POPUP
|
||||||
|
SET
|
||||||
|
SORT_ORDR = #{sortOrder},
|
||||||
|
UPD_DT = CURRENT_TIMESTAMP
|
||||||
|
WHERE POPUP_IDNTF_NO = #{popupIdntfNo}
|
||||||
|
AND DEL_YN = 'N'
|
||||||
|
""")
|
||||||
|
int updateSortOrder(
|
||||||
|
@Param("popupIdntfNo") String popupIdntfNo,
|
||||||
|
@Param("sortOrder") int sortOrder
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 팝업을 논리 삭제합니다. */
|
||||||
|
@Update("""
|
||||||
|
UPDATE TN_MAIN_POPUP
|
||||||
|
SET
|
||||||
|
DEL_YN = 'Y',
|
||||||
|
UPD_DT = CURRENT_TIMESTAMP
|
||||||
|
WHERE POPUP_IDNTF_NO = #{popupIdntfNo}
|
||||||
|
""")
|
||||||
|
int deletePopup(@Param("popupIdntfNo") String popupIdntfNo);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package kr.iotdoor.boot.model.popup;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public class MainPopup {
|
||||||
|
|
||||||
|
private String popupIdntfNo;
|
||||||
|
private String title;
|
||||||
|
private String content;
|
||||||
|
private int sortOrder;
|
||||||
|
private LocalDateTime publishStartAt;
|
||||||
|
private LocalDateTime publishEndAt;
|
||||||
|
private String useYn;
|
||||||
|
private String delYn;
|
||||||
|
private LocalDateTime registeredAt;
|
||||||
|
private String registeredByUserIdntfNo;
|
||||||
|
private String registeredByName;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** 팝업 식별번호를 반환합니다. */
|
||||||
|
public String getPopupIdntfNo() {
|
||||||
|
return popupIdntfNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 식별번호를 설정합니다. */
|
||||||
|
public void setPopupIdntfNo(String popupIdntfNo) {
|
||||||
|
this.popupIdntfNo = popupIdntfNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 제목을 반환합니다. */
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 제목을 설정합니다. */
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 본문 HTML을 반환합니다. */
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 본문 HTML을 설정합니다. */
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 정렬 순서를 반환합니다. */
|
||||||
|
public int getSortOrder() {
|
||||||
|
return sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 정렬 순서를 설정합니다. */
|
||||||
|
public void setSortOrder(int sortOrder) {
|
||||||
|
this.sortOrder = sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 시작 일시를 반환합니다. */
|
||||||
|
public LocalDateTime getPublishStartAt() {
|
||||||
|
return publishStartAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 시작 일시를 설정합니다. */
|
||||||
|
public void setPublishStartAt(LocalDateTime publishStartAt) {
|
||||||
|
this.publishStartAt = publishStartAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 종료 일시를 반환합니다. */
|
||||||
|
public LocalDateTime getPublishEndAt() {
|
||||||
|
return publishEndAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 종료 일시를 설정합니다. */
|
||||||
|
public void setPublishEndAt(LocalDateTime publishEndAt) {
|
||||||
|
this.publishEndAt = publishEndAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 노출 여부를 반환합니다. */
|
||||||
|
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 String getRegisteredByUserIdntfNo() {
|
||||||
|
return registeredByUserIdntfNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 작성자 식별번호를 설정합니다. */
|
||||||
|
public void setRegisteredByUserIdntfNo(String registeredByUserIdntfNo) {
|
||||||
|
this.registeredByUserIdntfNo = registeredByUserIdntfNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 작성자 이름을 반환합니다. */
|
||||||
|
public String getRegisteredByName() {
|
||||||
|
return registeredByName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 작성자 이름을 설정합니다. */
|
||||||
|
public void setRegisteredByName(String registeredByName) {
|
||||||
|
this.registeredByName = registeredByName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수정 일시를 반환합니다. */
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수정 일시를 설정합니다. */
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package kr.iotdoor.boot.model.popup;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public class MainPopupForm {
|
||||||
|
|
||||||
|
private String popupIdntfNo;
|
||||||
|
|
||||||
|
@NotBlank(message = "제목을 입력해주세요.")
|
||||||
|
@Size(max = 200, message = "제목은 200자 이하로 입력해주세요.")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@NotBlank(message = "내용을 입력해주세요.")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
|
||||||
|
private LocalDateTime publishStartAt;
|
||||||
|
|
||||||
|
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
|
||||||
|
private LocalDateTime publishEndAt;
|
||||||
|
|
||||||
|
private String useYn = "Y";
|
||||||
|
|
||||||
|
/** 팝업 식별번호를 반환합니다. */
|
||||||
|
public String getPopupIdntfNo() {
|
||||||
|
return popupIdntfNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 식별번호를 설정합니다. */
|
||||||
|
public void setPopupIdntfNo(String popupIdntfNo) {
|
||||||
|
this.popupIdntfNo = popupIdntfNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 제목을 반환합니다. */
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 제목을 설정합니다. */
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본문 HTML을 반환합니다. */
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본문 HTML을 설정합니다. */
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 시작 일시를 반환합니다. */
|
||||||
|
public LocalDateTime getPublishStartAt() {
|
||||||
|
return publishStartAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 시작 일시를 설정합니다. */
|
||||||
|
public void setPublishStartAt(LocalDateTime publishStartAt) {
|
||||||
|
this.publishStartAt = publishStartAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 종료 일시를 반환합니다. */
|
||||||
|
public LocalDateTime getPublishEndAt() {
|
||||||
|
return publishEndAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 종료 일시를 설정합니다. */
|
||||||
|
public void setPublishEndAt(LocalDateTime publishEndAt) {
|
||||||
|
this.publishEndAt = publishEndAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 노출 여부를 반환합니다. */
|
||||||
|
public String getUseYn() {
|
||||||
|
return useYn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 노출 여부를 설정합니다. */
|
||||||
|
public void setUseYn(String useYn) {
|
||||||
|
this.useYn = useYn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package kr.iotdoor.boot.service;
|
||||||
|
|
||||||
|
import kr.iotdoor.boot.mapper.MainPopupMapper;
|
||||||
|
import kr.iotdoor.boot.model.popup.MainPopup;
|
||||||
|
import kr.iotdoor.boot.model.popup.MainPopupForm;
|
||||||
|
import kr.iotdoor.boot.model.user.AdminUserPrincipal;
|
||||||
|
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 MainPopupService {
|
||||||
|
|
||||||
|
private final MainPopupMapper mainPopupMapper;
|
||||||
|
|
||||||
|
public MainPopupService(MainPopupMapper mainPopupMapper) {
|
||||||
|
this.mainPopupMapper = mainPopupMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공개 사이트에 노출할 팝업 목록을 반환합니다. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<MainPopup> getPublicPopups() {
|
||||||
|
return mainPopupMapper.selectPublicPopups();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관리자 팝업 목록을 반환합니다. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<MainPopup> getAdminPopups() {
|
||||||
|
return mainPopupMapper.selectAdminPopups();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 등록 또는 수정 화면에 사용할 폼 값을 반환합니다. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MainPopupForm getForm(String popupIdntfNo) {
|
||||||
|
MainPopupForm form = new MainPopupForm();
|
||||||
|
form.setUseYn("Y");
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(popupIdntfNo)) {
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
MainPopup popup = mainPopupMapper.selectPopup(popupIdntfNo);
|
||||||
|
if (popup == null) {
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setPopupIdntfNo(popup.getPopupIdntfNo());
|
||||||
|
form.setTitle(popup.getTitle());
|
||||||
|
form.setContent(popup.getContent());
|
||||||
|
form.setPublishStartAt(popup.getPublishStartAt());
|
||||||
|
form.setPublishEndAt(popup.getPublishEndAt());
|
||||||
|
form.setUseYn("Y".equals(popup.getUseYn()) ? "Y" : "N");
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 등록 또는 수정을 저장합니다. */
|
||||||
|
@Transactional
|
||||||
|
public String save(MainPopupForm form, AdminUserPrincipal principal) {
|
||||||
|
if (StringUtils.hasText(form.getPopupIdntfNo())) {
|
||||||
|
MainPopup existing = mainPopupMapper.selectPopup(form.getPopupIdntfNo());
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalArgumentException("수정할 팝업을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
MainPopup popup = toPopup(form);
|
||||||
|
popup.setPopupIdntfNo(existing.getPopupIdntfNo());
|
||||||
|
popup.setSortOrder(existing.getSortOrder());
|
||||||
|
popup.setRegisteredByUserIdntfNo(existing.getRegisteredByUserIdntfNo());
|
||||||
|
mainPopupMapper.updatePopup(popup);
|
||||||
|
return existing.getPopupIdntfNo();
|
||||||
|
}
|
||||||
|
|
||||||
|
MainPopup popup = toPopup(form);
|
||||||
|
popup.setPopupIdntfNo(newIdentifier());
|
||||||
|
popup.setSortOrder(mainPopupMapper.selectMaxSortOrder() + 10);
|
||||||
|
popup.setRegisteredByUserIdntfNo(principal.getUserIdntfNo());
|
||||||
|
mainPopupMapper.insertPopup(popup);
|
||||||
|
return popup.getPopupIdntfNo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업을 삭제 처리합니다. */
|
||||||
|
@Transactional
|
||||||
|
public void delete(String popupIdntfNo) {
|
||||||
|
mainPopupMapper.deletePopup(popupIdntfNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 노출 순서를 위 또는 아래로 이동합니다. */
|
||||||
|
@Transactional
|
||||||
|
public void move(String popupIdntfNo, String direction) {
|
||||||
|
MainPopup current = mainPopupMapper.selectPopup(popupIdntfNo);
|
||||||
|
if (current == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MainPopup target = "up".equals(direction)
|
||||||
|
? mainPopupMapper.selectPreviousPopup(current.getSortOrder())
|
||||||
|
: mainPopupMapper.selectNextPopup(current.getSortOrder());
|
||||||
|
|
||||||
|
if (target == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainPopupMapper.updateSortOrder(current.getPopupIdntfNo(), target.getSortOrder());
|
||||||
|
mainPopupMapper.updateSortOrder(target.getPopupIdntfNo(), current.getSortOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 폼 값을 DB 저장 모델로 변환합니다. */
|
||||||
|
private MainPopup toPopup(MainPopupForm form) {
|
||||||
|
MainPopup popup = new MainPopup();
|
||||||
|
popup.setTitle(form.getTitle().trim());
|
||||||
|
popup.setContent(form.getContent().trim());
|
||||||
|
popup.setPublishStartAt(form.getPublishStartAt());
|
||||||
|
popup.setPublishEndAt(form.getPublishEndAt());
|
||||||
|
popup.setUseYn("Y".equals(form.getUseYn()) ? "Y" : "N");
|
||||||
|
return popup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 하이픈 없는 UUID 식별번호를 생성합니다. */
|
||||||
|
private String newIdentifier() {
|
||||||
|
return UUID.randomUUID().toString().replace("-", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package kr.iotdoor.boot.web;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import kr.iotdoor.boot.model.popup.MainPopupForm;
|
||||||
|
import kr.iotdoor.boot.model.user.AdminUserPrincipal;
|
||||||
|
import kr.iotdoor.boot.service.MainPopupService;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
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 AdminMainPopupController {
|
||||||
|
|
||||||
|
private final MainPopupService mainPopupService;
|
||||||
|
|
||||||
|
public AdminMainPopupController(MainPopupService mainPopupService) {
|
||||||
|
this.mainPopupService = mainPopupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업관리 목록 화면을 표시합니다. */
|
||||||
|
@GetMapping("/mfuc/popup/list.do")
|
||||||
|
public String popupList(Model model) {
|
||||||
|
model.addAttribute("popups", mainPopupService.getAdminPopups());
|
||||||
|
model.addAttribute("publicPopups", mainPopupService.getPublicPopups());
|
||||||
|
model.addAttribute("adminMenu", "popup");
|
||||||
|
model.addAttribute("pageTitle", "팝업 관리");
|
||||||
|
model.addAttribute("pageDescription", "메인 진입 시 노출할 팝업의 내용, 노출 여부, 순서를 관리합니다.");
|
||||||
|
return "kr/iotdoor/boot/admin/popup/list";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 등록 또는 수정 화면을 표시합니다. */
|
||||||
|
@GetMapping("/mfuc/popup/form.do")
|
||||||
|
public String popupForm(
|
||||||
|
@RequestParam(name = "popupIdntfNo", required = false) String popupIdntfNo,
|
||||||
|
Model model
|
||||||
|
) {
|
||||||
|
MainPopupForm form = mainPopupService.getForm(popupIdntfNo);
|
||||||
|
model.addAttribute("form", form);
|
||||||
|
applyFormPage(model, form);
|
||||||
|
return "kr/iotdoor/boot/admin/popup/form";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 등록 또는 수정 요청을 저장합니다. */
|
||||||
|
@PostMapping("/mfuc/popup/save.do")
|
||||||
|
public String savePopup(
|
||||||
|
@Valid @ModelAttribute("form") MainPopupForm form,
|
||||||
|
BindingResult bindingResult,
|
||||||
|
@RequestParam(name = "useYn", required = false) String useYn,
|
||||||
|
Authentication authentication,
|
||||||
|
Model model
|
||||||
|
) {
|
||||||
|
form.setUseYn("Y".equals(useYn) ? "Y" : "N");
|
||||||
|
|
||||||
|
if (hasInvalidPublishPeriod(form)) {
|
||||||
|
bindingResult.rejectValue("publishEndAt", "period", "게시 종료일시는 시작일시 이후로 입력해주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindingResult.hasErrors()) {
|
||||||
|
applyFormPage(model, form);
|
||||||
|
return "kr/iotdoor/boot/admin/popup/form";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mainPopupService.save(form, (AdminUserPrincipal) authentication.getPrincipal());
|
||||||
|
return "redirect:/mfuc/popup/list.do?saved=1";
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
bindingResult.reject("saveError", exception.getMessage());
|
||||||
|
applyFormPage(model, form);
|
||||||
|
return "kr/iotdoor/boot/admin/popup/form";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 삭제 요청을 처리합니다. */
|
||||||
|
@PostMapping("/mfuc/popup/delete.do")
|
||||||
|
public String deletePopup(@RequestParam("popupIdntfNo") String popupIdntfNo) {
|
||||||
|
mainPopupService.delete(popupIdntfNo);
|
||||||
|
return "redirect:/mfuc/popup/list.do?deleted=1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 정렬 순서 변경 요청을 처리합니다. */
|
||||||
|
@PostMapping("/mfuc/popup/move.do")
|
||||||
|
public String movePopup(
|
||||||
|
@RequestParam("popupIdntfNo") String popupIdntfNo,
|
||||||
|
@RequestParam("direction") String direction
|
||||||
|
) {
|
||||||
|
mainPopupService.move(popupIdntfNo, direction);
|
||||||
|
return "redirect:/mfuc/popup/list.do?ordered=1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 폼 화면에 공통 페이지 속성을 추가합니다. */
|
||||||
|
private void applyFormPage(Model model, MainPopupForm form) {
|
||||||
|
model.addAttribute("adminMenu", "popup");
|
||||||
|
model.addAttribute("pageTitle", StringUtils.hasText(form.getPopupIdntfNo()) ? "팝업 수정" : "팝업 등록");
|
||||||
|
model.addAttribute("pageDescription", "메인 진입 시 모달로 노출할 팝업 내용을 작성합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게시 시작 일시가 종료 일시보다 늦은지 확인합니다. */
|
||||||
|
private boolean hasInvalidPublishPeriod(MainPopupForm form) {
|
||||||
|
return form.getPublishStartAt() != null
|
||||||
|
&& form.getPublishEndAt() != null
|
||||||
|
&& form.getPublishStartAt().isAfter(form.getPublishEndAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package kr.iotdoor.boot.web;
|
|||||||
|
|
||||||
import kr.iotdoor.boot.service.PortfolioService;
|
import kr.iotdoor.boot.service.PortfolioService;
|
||||||
import kr.iotdoor.boot.service.MainBannerService;
|
import kr.iotdoor.boot.service.MainBannerService;
|
||||||
|
import kr.iotdoor.boot.service.MainPopupService;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
@@ -13,17 +14,24 @@ import org.springframework.web.bind.annotation.ResponseBody;
|
|||||||
public class PublicPageController {
|
public class PublicPageController {
|
||||||
|
|
||||||
private final MainBannerService mainBannerService;
|
private final MainBannerService mainBannerService;
|
||||||
|
private final MainPopupService mainPopupService;
|
||||||
private final PortfolioService portfolioService;
|
private final PortfolioService portfolioService;
|
||||||
|
|
||||||
public PublicPageController(MainBannerService mainBannerService, PortfolioService portfolioService) {
|
public PublicPageController(MainBannerService mainBannerService, MainPopupService mainPopupService, PortfolioService portfolioService) {
|
||||||
this.mainBannerService = mainBannerService;
|
this.mainBannerService = mainBannerService;
|
||||||
|
this.mainPopupService = mainPopupService;
|
||||||
this.portfolioService = portfolioService;
|
this.portfolioService = portfolioService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping({"/", "/index.do"})
|
@GetMapping({"/", "/index.do"})
|
||||||
public String home(Model model) {
|
public String home(Model model) {
|
||||||
model.addAttribute("mainYn", "Y");
|
model.addAttribute("mainYn", "Y");
|
||||||
|
model.addAttribute("pageTitle", "대전 자동문 설치·수리 전문업체");
|
||||||
|
model.addAttribute("pageDescription", "국제오토텍은 대전·세종 지역 자동문 설치와 자동문 수리 전문업체입니다. 슬라이딩자동문, 산업용자동문, 내풍압셔터, 오버헤드도어, 고속셔터 시공과 유지보수를 제공합니다.");
|
||||||
|
model.addAttribute("pageOgDescription", "대전·세종 자동문 설치와 자동문 수리, 산업용자동문, 슬라이딩자동문, 내풍압셔터, 오버헤드도어, 고속셔터 시공 및 유지보수 전문업체입니다.");
|
||||||
|
model.addAttribute("canonicalUrl", "https://iotdoor.kr/");
|
||||||
model.addAttribute("mainBanners", mainBannerService.getPublicBanners());
|
model.addAttribute("mainBanners", mainBannerService.getPublicBanners());
|
||||||
|
model.addAttribute("mainPopups", mainPopupService.getPublicPopups());
|
||||||
return "kr/iotdoor/comn/site/mainPge";
|
return "kr/iotdoor/comn/site/mainPge";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +1,82 @@
|
|||||||
server:
|
server:
|
||||||
port: ${PORT:9090}
|
port: ${PORT:9090}
|
||||||
servlet:
|
servlet:
|
||||||
session:
|
session:
|
||||||
timeout: 60m
|
timeout: 60m
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: iotdoor-boot
|
name: iotdoor-boot
|
||||||
devtools:
|
devtools:
|
||||||
restart:
|
restart:
|
||||||
enabled: true
|
enabled: true
|
||||||
poll-interval: 2s
|
poll-interval: 2s
|
||||||
quiet-period: 500ms
|
quiet-period: 500ms
|
||||||
livereload:
|
livereload:
|
||||||
enabled: true
|
enabled: true
|
||||||
thymeleaf:
|
thymeleaf:
|
||||||
cache: false
|
cache: false
|
||||||
web:
|
web:
|
||||||
resources:
|
resources:
|
||||||
static-locations:
|
static-locations:
|
||||||
- file:src/main/resources/static/
|
- file:src/main/resources/static/
|
||||||
- classpath:/META-INF/resources/
|
- classpath:/META-INF/resources/
|
||||||
- classpath:/resources/
|
- classpath:/resources/
|
||||||
- classpath:/static/
|
- classpath:/static/
|
||||||
- classpath:/public/
|
- classpath:/public/
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: ${IOTDOOR_MAX_FILE_SIZE:20MB}
|
max-file-size: ${IOTDOOR_MAX_FILE_SIZE:20MB}
|
||||||
max-request-size: ${IOTDOOR_MAX_REQUEST_SIZE:100MB}
|
max-request-size: ${IOTDOOR_MAX_REQUEST_SIZE:100MB}
|
||||||
datasource:
|
datasource:
|
||||||
# Legacy context.xml resource name: java:comp/env/jdbc/serviceDB
|
# Legacy context.xml resource name: java:comp/env/jdbc/serviceDB
|
||||||
url: ${IOTDOOR_DB_URL:jdbc:mariadb://localhost:3306/iotdoor2_db}
|
url: ${IOTDOOR_DB_URL:jdbc:mariadb://localhost:3306/iotdoor2_db}
|
||||||
username: ${IOTDOOR_DB_USERNAME:iotdoor_user}
|
username: ${IOTDOOR_DB_USERNAME:iotdoor_user}
|
||||||
password: ${IOTDOOR_DB_PASSWORD:iotdoor_pass1!}
|
password: ${IOTDOOR_DB_PASSWORD:iotdoor_pass1!}
|
||||||
driver-class-name: ${IOTDOOR_DB_DRIVER:org.mariadb.jdbc.Driver}
|
driver-class-name: ${IOTDOOR_DB_DRIVER:org.mariadb.jdbc.Driver}
|
||||||
hikari:
|
hikari:
|
||||||
pool-name: ${IOTDOOR_DB_POOL_NAME:IotdoorHikariPool}
|
pool-name: ${IOTDOOR_DB_POOL_NAME:IotdoorHikariPool}
|
||||||
maximum-pool-size: ${IOTDOOR_DB_MAX_POOL_SIZE:20}
|
maximum-pool-size: ${IOTDOOR_DB_MAX_POOL_SIZE:20}
|
||||||
minimum-idle: ${IOTDOOR_DB_MIN_IDLE:10}
|
minimum-idle: ${IOTDOOR_DB_MIN_IDLE:10}
|
||||||
connection-test-query: SELECT 1
|
connection-test-query: SELECT 1
|
||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
mode: always
|
mode: always
|
||||||
encoding: UTF-8
|
encoding: UTF-8
|
||||||
jackson:
|
jackson:
|
||||||
time-zone: Asia/Seoul
|
time-zone: Asia/Seoul
|
||||||
mvc:
|
mvc:
|
||||||
problemdetails:
|
problemdetails:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
exposure:
|
exposure:
|
||||||
include: health,info
|
include: health,info
|
||||||
|
|
||||||
mybatis:
|
mybatis:
|
||||||
configuration:
|
configuration:
|
||||||
map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
|
|
||||||
iotdoor:
|
iotdoor:
|
||||||
site:
|
site:
|
||||||
name: 국제오토텍
|
name: 국제오토텍
|
||||||
url: https://iotdoor.kr
|
url: https://iotdoor.kr/
|
||||||
og-image: https://iotdoor.kr/resources/kr/iotdoor/comn/site/layout/image/template/logo.jpg
|
og-image: https://iotdoor.kr/resources/kr/iotdoor/comn/site/layout/image/template/logo.jpg
|
||||||
admin-title: 국제오토텍 관리자
|
admin-title: 국제오토텍 관리자
|
||||||
email: iotdoor1749@naver.com
|
email: iotdoor1749@naver.com
|
||||||
phone: 042-585-1749
|
phone: 042-585-1749
|
||||||
phone-alt: 010-3476-1749
|
phone-alt: 010-3476-1749
|
||||||
address: 대전광역시 서구 유등로 55 102호
|
address: 대전광역시 서구 유등로 55 102호
|
||||||
summary: 자동문 전문 업체로 철거, 시공, A/S까지 원스톱으로 대응합니다.
|
summary: 자동문 전문 업체로 철거, 시공, A/S까지 원스톱으로 대응합니다.
|
||||||
description: 슬라이딩자동문, 산업용자동문, 내풍압셔터, 오버헤드도어를 중심으로 시공과 유지보수를 제공합니다.
|
description: 슬라이딩자동문, 산업용자동문, 내풍압셔터, 오버헤드도어를 중심으로 시공과 유지보수를 제공합니다.
|
||||||
map-latitude: 36.2980733
|
map-latitude: 36.2980733
|
||||||
map-longitude: 127.3814560
|
map-longitude: 127.3814560
|
||||||
map-client-id: ${IOTDOOR_NAVER_MAP_CLIENT_ID:xkb35c3i3k}
|
map-client-id: ${IOTDOOR_NAVER_MAP_CLIENT_ID:xkb35c3i3k}
|
||||||
security:
|
security:
|
||||||
admin-username: ${IOTDOOR_ADMIN_USERNAME:admin}
|
admin-username: ${IOTDOOR_ADMIN_USERNAME:admin}
|
||||||
admin-password: ${IOTDOOR_ADMIN_PASSWORD:ChangeMe123!}
|
admin-password: ${IOTDOOR_ADMIN_PASSWORD:epdlxkdldma}
|
||||||
admin-display-name: ${IOTDOOR_ADMIN_DISPLAY_NAME:관리자}
|
admin-display-name: ${IOTDOOR_ADMIN_DISPLAY_NAME:관리자}
|
||||||
file:
|
file:
|
||||||
upload-path: ${IOTDOOR_FILE_UPLOAD_PATH:./uploads}
|
upload-path: ${IOTDOOR_FILE_UPLOAD_PATH:./uploads}
|
||||||
|
|||||||
@@ -97,6 +97,26 @@ CREATE TABLE IF NOT EXISTS TN_MAIN_BANNER (
|
|||||||
REFERENCES TN_ATCH_FILE (ATCH_FILE_IDNTF_NO)
|
REFERENCES TN_ATCH_FILE (ATCH_FILE_IDNTF_NO)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS TN_MAIN_POPUP (
|
||||||
|
POPUP_IDNTF_NO VARCHAR(32) NOT NULL,
|
||||||
|
TTL VARCHAR(200) NOT NULL,
|
||||||
|
CN TEXT NOT NULL,
|
||||||
|
SORT_ORDR INT NOT NULL DEFAULT 0,
|
||||||
|
PSTG_BGNG_DT DATETIME NULL,
|
||||||
|
PSTG_END_DT DATETIME NULL,
|
||||||
|
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
|
||||||
|
DEL_YN CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
REG_USER_IDNTF_NO VARCHAR(32) NOT NULL,
|
||||||
|
UPD_DT DATETIME NULL,
|
||||||
|
PRIMARY KEY (POPUP_IDNTF_NO),
|
||||||
|
CONSTRAINT FK_TN_MAIN_POPUP__USER FOREIGN KEY (REG_USER_IDNTF_NO)
|
||||||
|
REFERENCES TN_USER (USER_IDNTF_NO)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE TN_MAIN_POPUP ADD COLUMN IF NOT EXISTS PSTG_BGNG_DT DATETIME NULL;
|
||||||
|
ALTER TABLE TN_MAIN_POPUP ADD COLUMN IF NOT EXISTS PSTG_END_DT DATETIME NULL;
|
||||||
|
|
||||||
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_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_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_CONTACT_INQRY__SUBMIT_DT ON TN_CONTACT_INQRY (SUBMIT_DT);
|
||||||
@@ -106,6 +126,9 @@ CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__SESSION_ID ON TN_ACCESS_LOG (SESSI
|
|||||||
CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__REQUEST_URI ON TN_ACCESS_LOG (REQUEST_URI);
|
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__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);
|
CREATE INDEX IF NOT EXISTS IDX_TN_MAIN_BANNER__USE_DEL ON TN_MAIN_BANNER (USE_YN, DEL_YN);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_TN_MAIN_POPUP__SORT ON TN_MAIN_POPUP (SORT_ORDR);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_TN_MAIN_POPUP__USE_DEL ON TN_MAIN_POPUP (USE_YN, DEL_YN);
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_TN_MAIN_POPUP__PUBLISH ON TN_MAIN_POPUP (PSTG_BGNG_DT, PSTG_END_DT);
|
||||||
|
|
||||||
INSERT INTO TN_MAIN_BANNER (
|
INSERT INTO TN_MAIN_BANNER (
|
||||||
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
|
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
|
||||||
|
|||||||
@@ -250,10 +250,154 @@ body {
|
|||||||
min-width: 980px;
|
min-width: 980px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1220px;
|
||||||
|
}
|
||||||
|
|
||||||
.iotdoor-banner-list-content {
|
.iotdoor-banner-list-content {
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-list-content {
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 110px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-list-content img {
|
||||||
|
max-width: 140px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-modal.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-dialog {
|
||||||
|
width: min(760px, 92vw);
|
||||||
|
max-height: 78vh;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-title {
|
||||||
|
margin: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-body {
|
||||||
|
position: relative;
|
||||||
|
max-height: calc(78vh - 118px);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-track {
|
||||||
|
display: flex;
|
||||||
|
transition: transform 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-slide {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
padding: 26px 30px;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-slide img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(17, 24, 39, 0.72);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-nav-prev {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-nav-next {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-bullets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-bullet {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-bullet.is-active {
|
||||||
|
background: #1f5aa0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-empty {
|
||||||
|
padding: 34px 24px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.iotdoor-banner-form-grid {
|
.iotdoor-banner-form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -316,3 +460,40 @@ body {
|
|||||||
grid-template-columns: 360px 1fr;
|
grid-template-columns: 360px 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.admin-popup-preview-modal {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 84vh;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-header {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-body {
|
||||||
|
max-height: calc(84vh - 104px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-slide {
|
||||||
|
padding: 22px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-popup-preview-bullets {
|
||||||
|
padding: 0 18px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -71,6 +71,7 @@
|
|||||||
.btn-square,
|
.btn-square,
|
||||||
.btn-sm-square,
|
.btn-sm-square,
|
||||||
.btn-lg-square {
|
.btn-lg-square {
|
||||||
|
flex: 0 0 auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -81,6 +81,21 @@
|
|||||||
<span class="menu-item-text">배너관리</span>
|
<span class="menu-item-text">배너관리</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="menu-item group"
|
||||||
|
th:classappend="${adminMenu == 'popup'} ? ' menu-item-active' : ' menu-item-inactive'"
|
||||||
|
th:href="@{/mfuc/popup/list.do}">
|
||||||
|
<span class="menu-item-icon-size"
|
||||||
|
th:classappend="${adminMenu == 'popup'} ? ' 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="M6 6.6h12A1.8 1.8 0 0 1 19.8 8.4v7.2A1.8 1.8 0 0 1 18 17.4H6A1.8 1.8 0 0 1 4.2 15.6V8.4A1.8 1.8 0 0 1 6 6.6Z" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M8.4 10.2h7.2M8.4 13.2h4.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M8.4 19.8h7.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="menu-item-text">팝업관리</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="menu-item group"
|
<a class="menu-item group"
|
||||||
th:classappend="${adminMenu == 'contact'} ? ' menu-item-active' : ' menu-item-inactive'"
|
th:classappend="${adminMenu == 'contact'} ? ' menu-item-active' : ' menu-item-inactive'"
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<!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.popupIdntfNo != null and !#strings.isEmpty(form.popupIdntfNo) ? '팝업 수정' : '팝업 등록'}">팝업 등록</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/popup/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="mainPopupForm"
|
||||||
|
method="post"
|
||||||
|
th:action="@{/mfuc/popup/save.do}"
|
||||||
|
th:object="${form}"
|
||||||
|
th:attr="data-csrf-header=${_csrf.headerName},
|
||||||
|
data-csrf-token=${_csrf.token}">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<input type="hidden" th:field="*{popupIdntfNo}">
|
||||||
|
<textarea hidden th:field="*{content}" id="content"></textarea>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">본문 에디터</label>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">이미지와 텍스트를 함께 작성할 수 있으며, 작성 내용은 메인 팝업에 그대로 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="popupToolbar" class="iotdoor-editor-toolbar">
|
||||||
|
<button type="button" data-editor-action="paragraph">본문</button>
|
||||||
|
<button type="button" data-editor-action="heading" data-level="1">H1</button>
|
||||||
|
<button type="button" data-editor-action="heading" data-level="2">H2</button>
|
||||||
|
<button type="button" data-editor-action="heading" data-level="3">H3</button>
|
||||||
|
<button type="button" data-editor-action="bold">굵게</button>
|
||||||
|
<button type="button" data-editor-action="italic">기울임</button>
|
||||||
|
<button type="button" data-editor-action="strike">취소선</button>
|
||||||
|
<button type="button" data-editor-action="bulletList">목록</button>
|
||||||
|
<button type="button" data-editor-action="orderedList">번호</button>
|
||||||
|
<button type="button" data-editor-action="blockquote">인용</button>
|
||||||
|
<button type="button" data-editor-action="horizontalRule">구분선</button>
|
||||||
|
<button type="button" data-editor-action="undo">되돌리기</button>
|
||||||
|
<button type="button" data-editor-action="redo">다시하기</button>
|
||||||
|
<button type="button" data-editor-action="imageUpload">이미지 업로드</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="popupEditor" class="iotdoor-editor-surface"></div>
|
||||||
|
<input id="popupImageFile" type="file" accept="image/*" hidden>
|
||||||
|
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('content')}" th:errors="*{content}">내용 오류</div>
|
||||||
|
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasGlobalErrors()}">
|
||||||
|
<span th:each="error : ${#fields.globalErrors()}" th:text="${error}">오류 메시지</span>
|
||||||
|
</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>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700">게시기간</label>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
type="datetime-local"
|
||||||
|
th:field="*{publishStartAt}">
|
||||||
|
<p class="mt-2 text-sm text-gray-500">미입력 시 시작 제한 없이 노출됩니다.</p>
|
||||||
|
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('publishStartAt')}" th:errors="*{publishStartAt}">게시 시작 오류</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
type="datetime-local"
|
||||||
|
th:field="*{publishEndAt}">
|
||||||
|
<p class="mt-2 text-sm text-gray-500">미입력 시 종료 제한 없이 계속 노출됩니다.</p>
|
||||||
|
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('publishEndAt')}" th:errors="*{publishEndAt}">게시 종료 오류</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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/popup/list.do}">
|
||||||
|
취소
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<th:block layout:fragment="pageScripts">
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (!window.IotdoorAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.IotdoorAdmin.initContentEditor({
|
||||||
|
formSelector: '#mainPopupForm',
|
||||||
|
editorSelector: '#popupEditor',
|
||||||
|
toolbarSelector: '#popupToolbar',
|
||||||
|
hiddenInputSelector: '#content',
|
||||||
|
fileInputSelector: '#popupImageFile',
|
||||||
|
uploadUrl: '/mfuc/file/upload.do',
|
||||||
|
uploadDirectory: 'popup/editor',
|
||||||
|
uploadDescription: 'popup-editor-image'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<!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>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button id="adminPopupPreviewOpen"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-brand-200 bg-white px-4 py-2 text-sm font-medium text-brand-600 hover:bg-brand-25"
|
||||||
|
type="button">
|
||||||
|
팝업 목록 확인
|
||||||
|
</button>
|
||||||
|
<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/popup/form.do}">
|
||||||
|
새 팝업 등록
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</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(popups)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
|
||||||
|
아직 등록된 팝업이 없습니다.
|
||||||
|
</div>
|
||||||
|
<div th:unless="${#lists.isEmpty(popups)}" class="overflow-x-auto">
|
||||||
|
<table class="iotdoor-popup-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>
|
||||||
|
<th class="px-3 py-3">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-b border-gray-100 align-top last:border-0"
|
||||||
|
th:each="popup, stat : ${popups}">
|
||||||
|
<td class="px-3 py-4" style="width: 92px;">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<form method="post" th:action="@{/mfuc/popup/move.do}">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<input type="hidden" name="popupIdntfNo" th:value="${popup.popupIdntfNo}">
|
||||||
|
<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/popup/move.do}">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<input type="hidden" name="popupIdntfNo" th:value="${popup.popupIdntfNo}">
|
||||||
|
<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">
|
||||||
|
<div class="font-semibold text-gray-900" th:text="${popup.title}">팝업 제목</div>
|
||||||
|
<div class="iotdoor-popup-list-content mt-2 text-sm leading-6 text-gray-500" th:utext="${popup.content}">팝업 내용</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-gray-500">
|
||||||
|
<div th:if="${popup.publishStartAt == null and popup.publishEndAt == null}">기간 제한 없음</div>
|
||||||
|
<div th:unless="${popup.publishStartAt == null and popup.publishEndAt == null}">
|
||||||
|
<span th:text="${popup.publishStartAt != null ? #temporals.format(popup.publishStartAt, 'yyyy-MM-dd HH:mm') : '시작 제한 없음'}">시작</span>
|
||||||
|
<span class="mx-1">~</span>
|
||||||
|
<span th:text="${popup.publishEndAt != null ? #temporals.format(popup.publishEndAt, 'yyyy-MM-dd HH:mm') : '종료 제한 없음'}">종료</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-gray-500" th:text="${popup.registeredByName}">관리자</td>
|
||||||
|
<td class="whitespace-nowrap px-3 py-4 text-gray-500"
|
||||||
|
th:text="${#temporals.format(popup.registeredAt, 'yyyy-MM-dd HH:mm')}">2026-04-21 09:00</td>
|
||||||
|
<td class="px-3 py-4">
|
||||||
|
<span class="inline-flex rounded-full px-3 py-1 text-xs font-medium"
|
||||||
|
th:classappend="${popup.useYn == 'Y'} ? ' bg-success-50 text-success-700' : ' bg-gray-100 text-gray-500'"
|
||||||
|
th:text="${popup.useYn == 'Y'} ? '노출' : '미노출'">노출</span>
|
||||||
|
</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/popup/form.do(popupIdntfNo=${popup.popupIdntfNo})}">
|
||||||
|
수정
|
||||||
|
</a>
|
||||||
|
<form method="post" th:action="@{/mfuc/popup/delete.do}">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
|
<input type="hidden" name="popupIdntfNo" th:value="${popup.popupIdntfNo}">
|
||||||
|
<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 id="adminPopupPreviewModal"
|
||||||
|
class="admin-popup-preview-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="adminPopupPreviewTitle">
|
||||||
|
<div class="admin-popup-preview-dialog">
|
||||||
|
<div class="admin-popup-preview-header">
|
||||||
|
<h2 id="adminPopupPreviewTitle" class="admin-popup-preview-title">알림</h2>
|
||||||
|
<button id="adminPopupPreviewClose" class="admin-popup-preview-close" type="button" aria-label="팝업 닫기">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-popup-preview-empty" th:if="${#lists.isEmpty(publicPopups)}">
|
||||||
|
현재 사용자에게 노출되는 팝업이 없습니다.
|
||||||
|
</div>
|
||||||
|
<th:block th:unless="${#lists.isEmpty(publicPopups)}">
|
||||||
|
<div class="admin-popup-preview-body">
|
||||||
|
<div id="adminPopupPreviewTrack" class="admin-popup-preview-track">
|
||||||
|
<article class="admin-popup-preview-slide"
|
||||||
|
th:each="popup : ${publicPopups}"
|
||||||
|
th:attr="data-title=${popup.title}">
|
||||||
|
<div th:utext="${popup.content}">팝업 내용</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<button id="adminPopupPreviewPrev" class="admin-popup-preview-nav admin-popup-preview-nav-prev" type="button" aria-label="이전 팝업">‹</button>
|
||||||
|
<button id="adminPopupPreviewNext" class="admin-popup-preview-nav admin-popup-preview-nav-next" type="button" aria-label="다음 팝업">›</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminPopupPreviewBullets" class="admin-popup-preview-bullets" aria-label="팝업 목록"></div>
|
||||||
|
</th:block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<th:block layout:fragment="pageScripts">
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const openButton = document.getElementById('adminPopupPreviewOpen');
|
||||||
|
const modal = document.getElementById('adminPopupPreviewModal');
|
||||||
|
const closeButton = document.getElementById('adminPopupPreviewClose');
|
||||||
|
const track = document.getElementById('adminPopupPreviewTrack');
|
||||||
|
const title = document.getElementById('adminPopupPreviewTitle');
|
||||||
|
const prevButton = document.getElementById('adminPopupPreviewPrev');
|
||||||
|
const nextButton = document.getElementById('adminPopupPreviewNext');
|
||||||
|
const bulletContainer = document.getElementById('adminPopupPreviewBullets');
|
||||||
|
|
||||||
|
if (!openButton || !modal || !closeButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeIndex = 0;
|
||||||
|
let timerId = null;
|
||||||
|
const intervalMs = 5000;
|
||||||
|
const slides = track ? Array.from(track.querySelectorAll('.admin-popup-preview-slide')) : [];
|
||||||
|
const bullets = [];
|
||||||
|
|
||||||
|
// 지정한 순번의 팝업으로 미리보기 캐러셀을 이동합니다.
|
||||||
|
function goToSlide(index) {
|
||||||
|
if (!slides.length || !track || !title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeIndex = (index + slides.length) % slides.length;
|
||||||
|
track.style.transform = 'translateX(-' + (activeIndex * 100) + '%)';
|
||||||
|
title.textContent = slides[activeIndex].dataset.title || '알림';
|
||||||
|
bullets.forEach(function (bullet, bulletIndex) {
|
||||||
|
bullet.classList.toggle('is-active', bulletIndex === activeIndex);
|
||||||
|
bullet.setAttribute('aria-current', bulletIndex === activeIndex ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 롤링 타이머를 재시작합니다.
|
||||||
|
function restartTimer() {
|
||||||
|
if (timerId) {
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slides.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timerId = window.setInterval(function () {
|
||||||
|
goToSlide(activeIndex + 1);
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자가 선택한 팝업으로 이동하고 자동 롤링 기준 시간을 초기화합니다.
|
||||||
|
function moveTo(index) {
|
||||||
|
goToSlide(index);
|
||||||
|
restartTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미리보기 하단 불릿 버튼을 생성합니다.
|
||||||
|
function createBullets() {
|
||||||
|
if (!bulletContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
slides.forEach(function (slide, index) {
|
||||||
|
const bullet = document.createElement('button');
|
||||||
|
bullet.type = 'button';
|
||||||
|
bullet.className = 'admin-popup-preview-bullet';
|
||||||
|
bullet.setAttribute('aria-label', (index + 1) + '번 팝업 보기');
|
||||||
|
bullet.addEventListener('click', function () {
|
||||||
|
moveTo(index);
|
||||||
|
});
|
||||||
|
bulletContainer.appendChild(bullet);
|
||||||
|
bullets.push(bullet);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미리보기 모달을 엽니다.
|
||||||
|
function openPreview() {
|
||||||
|
modal.classList.add('is-open');
|
||||||
|
goToSlide(activeIndex);
|
||||||
|
restartTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미리보기 모달을 닫습니다.
|
||||||
|
function closePreview() {
|
||||||
|
modal.classList.remove('is-open');
|
||||||
|
if (timerId) {
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createBullets();
|
||||||
|
goToSlide(0);
|
||||||
|
|
||||||
|
openButton.addEventListener('click', openPreview);
|
||||||
|
closeButton.addEventListener('click', closePreview);
|
||||||
|
modal.addEventListener('click', function (event) {
|
||||||
|
if (event.target === modal) {
|
||||||
|
closePreview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prevButton) {
|
||||||
|
prevButton.addEventListener('click', function () {
|
||||||
|
moveTo(activeIndex - 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextButton) {
|
||||||
|
nextButton.addEventListener('click', function () {
|
||||||
|
moveTo(activeIndex + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
<meta name="description" th:content="${pageDescription != null ? pageDescription : siteInfo.description}" content="슬라이딩자동문, 산업용자동문, 내풍압셔터, 오버헤드도어를 중심으로 시공과 유지보수를 제공합니다.">
|
<meta name="description" th:content="${pageDescription != null ? pageDescription : siteInfo.description}" content="슬라이딩자동문, 산업용자동문, 내풍압셔터, 오버헤드도어를 중심으로 시공과 유지보수를 제공합니다.">
|
||||||
<meta property="og:title" th:content="${pageTitle != null ? pageTitle + ' | ' + siteInfo.name : siteInfo.name}" content="국제오토텍">
|
<meta property="og:title" th:content="${pageTitle != null ? pageTitle + ' | ' + siteInfo.name : siteInfo.name}" content="국제오토텍">
|
||||||
<meta property="og:description" th:content="${pageDescription != null ? pageDescription : siteInfo.description}" content="슬라이딩자동문, 산업용자동문, 내풍압셔터, 오버헤드도어를 중심으로 시공과 유지보수를 제공합니다.">
|
<meta property="og:description" th:content="${pageOgDescription != null ? pageOgDescription : (pageDescription != null ? pageDescription : siteInfo.description)}" content="슬라이딩자동문, 산업용자동문, 내풍압셔터, 오버헤드도어를 중심으로 시공과 유지보수를 제공합니다.">
|
||||||
<meta property="og:url" th:content="${canonicalUrl != null ? canonicalUrl : siteInfo.url}" content="https://iotdoor.kr">
|
<meta property="og:url" th:content="${canonicalUrl != null ? canonicalUrl : siteInfo.url}" content="https://iotdoor.kr/">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:image" th:content="${siteInfo.ogImage}" content="https://iotdoor.kr/resources/kr/iotdoor/comn/site/layout/image/template/logo.jpg">
|
<meta property="og:image" th:content="${siteInfo.ogImage}" content="https://iotdoor.kr/resources/kr/iotdoor/comn/site/layout/image/template/logo.jpg">
|
||||||
<link rel="canonical" th:href="${canonicalUrl != null ? canonicalUrl : siteInfo.url}" href="https://iotdoor.kr">
|
<link rel="canonical" th:href="${canonicalUrl != null ? canonicalUrl : siteInfo.url}" href="https://iotdoor.kr/">
|
||||||
<title th:text="${pageTitle != null ? pageTitle + ' | ' + siteInfo.name : siteInfo.name}">국제오토텍</title>
|
<title th:text="${pageTitle != null ? pageTitle + ' | ' + siteInfo.name : siteInfo.name}">국제오토텍</title>
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link href="/resources/kr/iotdoor/comn/site/layout/image/template/favicon.ico" rel="icon" />
|
<link href="/resources/kr/iotdoor/comn/site/layout/image/template/favicon.ico" rel="icon" />
|
||||||
|
|||||||
@@ -5,8 +5,226 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}" >
|
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}" >
|
||||||
|
|
||||||
|
<th:block layout:fragment="pageStyles">
|
||||||
|
<style>
|
||||||
|
.iotdoor-popup-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-modal.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-dialog {
|
||||||
|
width: min(760px, 92vw);
|
||||||
|
max-height: 78vh;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-title {
|
||||||
|
margin: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-body {
|
||||||
|
position: relative;
|
||||||
|
max-height: calc(78vh - 150px);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-track {
|
||||||
|
display: flex;
|
||||||
|
transition: transform 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-slide {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
padding: 26px 30px;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-slide img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(17, 24, 39, 0.72);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-nav-prev {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-nav-next {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px 20px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-bullets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-bullet {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-bullet.is-active {
|
||||||
|
background: #1f5aa0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-hide-today {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.iotdoor-popup-modal {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 84vh;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-header {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-body {
|
||||||
|
max-height: calc(84vh - 142px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-slide {
|
||||||
|
padding: 22px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-footer {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 14px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iotdoor-popup-bullets {
|
||||||
|
padding: 0 18px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
<th:block layout:fragment="content">
|
<th:block layout:fragment="content">
|
||||||
|
|
||||||
|
<div id="mainPopupModal"
|
||||||
|
class="iotdoor-popup-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="mainPopupTitle"
|
||||||
|
th:if="${!#lists.isEmpty(mainPopups)}">
|
||||||
|
<div class="iotdoor-popup-dialog">
|
||||||
|
<div class="iotdoor-popup-header">
|
||||||
|
<h2 id="mainPopupTitle" class="iotdoor-popup-title">알림</h2>
|
||||||
|
<button id="mainPopupClose" class="iotdoor-popup-close" type="button" aria-label="팝업 닫기">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="iotdoor-popup-body">
|
||||||
|
<div id="mainPopupTrack" class="iotdoor-popup-track">
|
||||||
|
<article class="iotdoor-popup-slide"
|
||||||
|
th:each="popup : ${mainPopups}"
|
||||||
|
th:attr="data-title=${popup.title}">
|
||||||
|
<div th:utext="${popup.content}">팝업 내용</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<button id="mainPopupPrev" class="iotdoor-popup-nav iotdoor-popup-nav-prev" type="button" aria-label="이전 팝업">‹</button>
|
||||||
|
<button id="mainPopupNext" class="iotdoor-popup-nav iotdoor-popup-nav-next" type="button" aria-label="다음 팝업">›</button>
|
||||||
|
</div>
|
||||||
|
<div id="mainPopupBullets" class="iotdoor-popup-bullets" aria-label="팝업 목록"></div>
|
||||||
|
<div class="iotdoor-popup-footer">
|
||||||
|
<label class="iotdoor-popup-hide-today">
|
||||||
|
<input id="mainPopupHideToday" type="checkbox">
|
||||||
|
오늘 하루 표시하지않기
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- About Start -->
|
<!-- About Start -->
|
||||||
<div class="container-fluid bg-light overflow-hidden my-5 px-lg-0">
|
<div class="container-fluid bg-light overflow-hidden my-5 px-lg-0">
|
||||||
<div class="container about px-lg-0">
|
<div class="container about px-lg-0">
|
||||||
@@ -32,13 +250,10 @@
|
|||||||
<div class="p-lg-5 pe-lg-0">
|
<div class="p-lg-5 pe-lg-0">
|
||||||
<h6 class="text-primary">About Us</h6>
|
<h6 class="text-primary">About Us</h6>
|
||||||
<h1 class="mb-4">
|
<h1 class="mb-4">
|
||||||
국제오토텍자동문
|
대전·세종 자동문 설치·수리 전문업체 국제오토텍
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
OVER-HEAD DOOR, AUTO GATE
|
국제오토텍은 대전·세종 지역을 중심으로 자동문 설치, 자동문 수리, 산업용 자동문, 슬라이딩 자동문, 내풍압셔터, 오버헤드도어, 고속셔터 시공 및 유지보수를 제공하는 자동문 전문업체입니다.
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
SLIDING DOOR, PARKING DOOR, ROLLER SHUTTER
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -78,9 +293,9 @@
|
|||||||
style="max-width: 600px"
|
style="max-width: 600px"
|
||||||
>
|
>
|
||||||
<h6 class="text-primary">Our Services</h6>
|
<h6 class="text-primary">Our Services</h6>
|
||||||
<h1 class="mb-4">
|
<h2 class="mb-4">
|
||||||
고객님이 원하시는 서비스는<br/>무엇이든 가능합니다
|
고객님이 원하시는 서비스는<br/>무엇이든 가능합니다
|
||||||
</h1>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div th:if="${#lists.isEmpty(mainBanners)}" class="col-12">
|
<div th:if="${#lists.isEmpty(mainBanners)}" class="col-12">
|
||||||
@@ -122,9 +337,9 @@
|
|||||||
>
|
>
|
||||||
<div class="p-lg-5 ps-lg-0">
|
<div class="p-lg-5 ps-lg-0">
|
||||||
<h6 class="text-primary">Why Choose Us!</h6>
|
<h6 class="text-primary">Why Choose Us!</h6>
|
||||||
<h1 class="mb-4">
|
<h2 class="mb-4">
|
||||||
고객여러분의 가치있는 선택에 보답하겠습니다.
|
고객여러분의 가치있는 선택에 보답하겠습니다.
|
||||||
</h1>
|
</h2>
|
||||||
<p class="mb-4 pb-2">
|
<p class="mb-4 pb-2">
|
||||||
자동문 전문 업체로 철거.시공.A/S까지 원스톱으로 상담해드립니다.
|
자동문 전문 업체로 철거.시공.A/S까지 원스톱으로 상담해드립니다.
|
||||||
<br>
|
<br>
|
||||||
@@ -316,6 +531,147 @@
|
|||||||
initMap();
|
initMap();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script th:if="${!#lists.isEmpty(mainPopups)}">
|
||||||
|
// 메인 팝업 캐러셀 전체 초기화를 캡슐화합니다.
|
||||||
|
(function () {
|
||||||
|
const storageKey = 'iotdoor.mainPopup.hiddenDate';
|
||||||
|
const intervalMs = 5000;
|
||||||
|
let activeIndex = 0;
|
||||||
|
let timerId = null;
|
||||||
|
|
||||||
|
// 오늘 날짜를 로컬스토리지 비교용 문자열로 반환합니다.
|
||||||
|
function getTodayKey() {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
return year + '-' + month + '-' + day;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오늘 하루 숨김 설정 여부를 확인합니다.
|
||||||
|
function isHiddenToday() {
|
||||||
|
return localStorage.getItem(storageKey) === getTodayKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오늘 하루 숨김 값을 저장합니다.
|
||||||
|
function saveHiddenToday() {
|
||||||
|
localStorage.setItem(storageKey, getTodayKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지정한 인덱스로 팝업 슬라이드를 이동합니다.
|
||||||
|
function goToSlide(index, state) {
|
||||||
|
activeIndex = (index + state.slides.length) % state.slides.length;
|
||||||
|
state.track.style.transform = 'translateX(-' + (activeIndex * 100) + '%)';
|
||||||
|
state.title.textContent = state.slides[activeIndex].dataset.title || '알림';
|
||||||
|
state.bullets.forEach(function (bullet, bulletIndex) {
|
||||||
|
bullet.classList.toggle('is-active', bulletIndex === activeIndex);
|
||||||
|
bullet.setAttribute('aria-current', bulletIndex === activeIndex ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 넘김 타이머를 다시 시작합니다.
|
||||||
|
function restartTimer(state) {
|
||||||
|
if (timerId) {
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.slides.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timerId = window.setInterval(function () {
|
||||||
|
goToSlide(activeIndex + 1, state);
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자가 선택한 슬라이드로 이동하고 자동 넘김 시간을 초기화합니다.
|
||||||
|
function moveTo(index, state) {
|
||||||
|
goToSlide(index, state);
|
||||||
|
restartTimer(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하단 불릿 버튼을 생성합니다.
|
||||||
|
function createBullets(state) {
|
||||||
|
state.slides.forEach(function (slide, index) {
|
||||||
|
const bullet = document.createElement('button');
|
||||||
|
bullet.type = 'button';
|
||||||
|
bullet.className = 'iotdoor-popup-bullet';
|
||||||
|
bullet.setAttribute('aria-label', (index + 1) + '번 팝업 보기');
|
||||||
|
bullet.addEventListener('click', function () {
|
||||||
|
moveTo(index, state);
|
||||||
|
});
|
||||||
|
state.bulletContainer.appendChild(bullet);
|
||||||
|
state.bullets.push(bullet);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 팝업을 닫고 필요 시 오늘 하루 숨김 값을 저장합니다.
|
||||||
|
function closePopup(state) {
|
||||||
|
if (state.hideToday.checked) {
|
||||||
|
saveHiddenToday();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.modal.classList.remove('is-open');
|
||||||
|
if (timerId) {
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM 준비 후 팝업 모달과 캐러셀 이벤트를 연결합니다.
|
||||||
|
function handleDomReady() {
|
||||||
|
if (isHiddenToday()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('mainPopupModal');
|
||||||
|
const track = document.getElementById('mainPopupTrack');
|
||||||
|
const title = document.getElementById('mainPopupTitle');
|
||||||
|
const closeButton = document.getElementById('mainPopupClose');
|
||||||
|
const prevButton = document.getElementById('mainPopupPrev');
|
||||||
|
const nextButton = document.getElementById('mainPopupNext');
|
||||||
|
const bulletContainer = document.getElementById('mainPopupBullets');
|
||||||
|
const hideToday = document.getElementById('mainPopupHideToday');
|
||||||
|
|
||||||
|
if (!modal || !track || !title || !closeButton || !prevButton || !nextButton || !bulletContainer || !hideToday) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
modal,
|
||||||
|
track,
|
||||||
|
title,
|
||||||
|
closeButton,
|
||||||
|
prevButton,
|
||||||
|
nextButton,
|
||||||
|
bulletContainer,
|
||||||
|
hideToday,
|
||||||
|
slides: Array.from(track.querySelectorAll('.iotdoor-popup-slide')),
|
||||||
|
bullets: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state.slides.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createBullets(state);
|
||||||
|
goToSlide(0, state);
|
||||||
|
restartTimer(state);
|
||||||
|
modal.classList.add('is-open');
|
||||||
|
|
||||||
|
closeButton.addEventListener('click', function () {
|
||||||
|
closePopup(state);
|
||||||
|
});
|
||||||
|
prevButton.addEventListener('click', function () {
|
||||||
|
moveTo(activeIndex - 1, state);
|
||||||
|
});
|
||||||
|
nextButton.addEventListener('click', function () {
|
||||||
|
moveTo(activeIndex + 1, state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', handleDomReady);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
<script th:src="@{/resources/kr/iotdoor/boot/contact/contact.js}"></script>
|
<script th:src="@{/resources/kr/iotdoor/boot/contact/contact.js}"></script>
|
||||||
</th:block>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class PublicPageControllerTest {
|
|||||||
mockMvc.perform(get("/robots.txt"))
|
mockMvc.perform(get("/robots.txt"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("User-agent: *")))
|
.andExpect(content().string(org.hamcrest.Matchers.containsString("User-agent: *")))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.containsString("User-agent: *\r\nAllow: /\r\n\r\nSitemap: https://iotdoor.kr/sitemap.xml")))
|
||||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("Sitemap: https://iotdoor.kr/sitemap.xml")));
|
.andExpect(content().string(org.hamcrest.Matchers.containsString("Sitemap: https://iotdoor.kr/sitemap.xml")));
|
||||||
|
|
||||||
mockMvc.perform(get("/sitemap.xml"))
|
mockMvc.perform(get("/sitemap.xml"))
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ iotdoor:
|
|||||||
upload-path: ./build/test-uploads
|
upload-path: ./build/test-uploads
|
||||||
security:
|
security:
|
||||||
admin-username: admin
|
admin-username: admin
|
||||||
admin-password: ChangeMe123!
|
admin-password: epdlxkdldma
|
||||||
admin-display-name: 관리자
|
admin-display-name: 관리자
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 859 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
Reference in New Issue
Block a user