포트폴리오 관련 수정

This commit is contained in:
CSKIM
2026-04-21 00:39:33 +09:00
parent 07452b60d9
commit c7a5fa5e6a
27 changed files with 3079 additions and 356 deletions
+1
View File
@@ -4,3 +4,4 @@ out/
target/
.idea/
*.iml
node_modules/
+1131
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "iotdoor-admin-assets",
"private": true,
"scripts": {
"build:admin-js": "esbuild src/main/frontend/iotdoor.js --bundle --format=iife --global-name=IotdoorAdmin --target=es2019 --minify --outfile=src/main/resources/static/resources/kr/iotdoor/boot/admin/js/iotdoor.js"
},
"dependencies": {
"@tiptap/core": "^3.12.0",
"@tiptap/extension-image": "^3.12.0",
"@tiptap/pm": "^3.12.0",
"@tiptap/starter-kit": "^3.12.0"
},
"devDependencies": {
"esbuild": "^0.25.9"
}
}
+548
View File
@@ -0,0 +1,548 @@
import { Editor, mergeAttributes } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { NodeSelection } from "@tiptap/pm/state";
const PORTFOLIO_IMAGE_TYPE = "portfolioImage";
function parseNumber(value) {
return Number.parseInt(String(value || "0"), 10) || 0;
}
function parseStoredFileFromUrl(src) {
if (!src) {
return null;
}
let path = String(src);
try {
path = new URL(path, window.location.origin).pathname;
} catch {
// Relative paths are still matched as-is.
}
const match = path.match(/\/comn\/file\/view\/([^/?#]+)\/(\d+)/);
if (!match) {
return null;
}
return {
atchFileIdntfNo: decodeURIComponent(match[1]),
fileSn: parseNumber(match[2]),
};
}
const PortfolioImage = Image.extend({
name: PORTFOLIO_IMAGE_TYPE,
group: "block",
draggable: true,
selectable: true,
addAttributes() {
return {
...this.parent?.(),
atchFileIdntfNo: {
default: "",
parseHTML: (element) => {
const storedFile = parseStoredFileFromUrl(element.getAttribute("src"));
return (
element.getAttribute("data-atch-file-idntf-no") ||
storedFile?.atchFileIdntfNo ||
""
);
},
renderHTML: (attributes) =>
attributes.atchFileIdntfNo
? { "data-atch-file-idntf-no": attributes.atchFileIdntfNo }
: {},
},
fileSn: {
default: 0,
parseHTML: (element) => {
const dataFileSn = parseNumber(element.getAttribute("data-file-sn"));
if (dataFileSn > 0) {
return dataFileSn;
}
return parseStoredFileFromUrl(element.getAttribute("src"))?.fileSn || 0;
},
renderHTML: (attributes) =>
attributes.fileSn > 0 ? { "data-file-sn": String(attributes.fileSn) } : {},
},
isRepresentative: {
default: false,
parseHTML: (element) =>
element.getAttribute("data-is-representative") === "true",
renderHTML: (attributes) =>
attributes.isRepresentative ? { "data-is-representative": "true" } : {},
},
};
},
parseHTML() {
return [{ tag: "img[src]" }];
},
renderHTML({ HTMLAttributes }) {
return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
addNodeView() {
return ({ node, editor, getPos }) => {
let currentNode = node;
const figure = document.createElement("figure");
const image = document.createElement("img");
figure.className = "iotdoor-editor-image-node";
figure.appendChild(image);
const selectImageNode = (event) => {
event.preventDefault();
event.stopPropagation();
if (typeof getPos !== "function") {
return;
}
const position = getPos();
if (typeof position !== "number") {
return;
}
const transaction = editor.state.tr.setSelection(
NodeSelection.create(editor.state.doc, position),
);
editor.view.dispatch(transaction);
editor.view.focus();
};
const syncNode = () => {
image.src = currentNode.attrs.src || "";
image.alt = currentNode.attrs.alt || "";
figure.classList.toggle(
"is-representative",
currentNode.attrs.isRepresentative === true,
);
};
syncNode();
figure.addEventListener("mousedown", selectImageNode);
return {
dom: figure,
update(updatedNode) {
if (updatedNode.type.name !== currentNode.type.name) {
return false;
}
currentNode = updatedNode;
syncNode();
return true;
},
selectNode() {
figure.classList.add("ProseMirror-selectednode");
},
deselectNode() {
figure.classList.remove("ProseMirror-selectednode");
},
destroy() {
figure.removeEventListener("mousedown", selectImageNode);
},
};
};
},
});
function getSelectedImagePosition(editor) {
const { selection } = editor.state;
if (
selection instanceof NodeSelection &&
selection.node.type.name === PORTFOLIO_IMAGE_TYPE
) {
return selection.from;
}
return null;
}
function getRepresentativeImage(editor) {
let representative = null;
editor.state.doc.descendants((node) => {
if (node.type.name !== PORTFOLIO_IMAGE_TYPE) {
return true;
}
if (node.attrs.isRepresentative) {
representative = node.attrs;
return false;
}
return true;
});
return representative;
}
function findImagePosition(editor, matcher) {
let foundPosition = null;
editor.state.doc.descendants((node, position) => {
if (node.type.name !== PORTFOLIO_IMAGE_TYPE) {
return true;
}
if (matcher(node, position)) {
foundPosition = position;
return false;
}
return true;
});
return foundPosition;
}
function setRepresentativeAtPosition(editor, targetPosition) {
const transaction = editor.state.tr;
let changed = false;
editor.state.doc.descendants((node, position) => {
if (node.type.name !== PORTFOLIO_IMAGE_TYPE) {
return true;
}
const shouldBeRepresentative = position === targetPosition;
const storedFile = parseStoredFileFromUrl(node.attrs.src);
const nextAtchFileIdntfNo =
node.attrs.atchFileIdntfNo || storedFile?.atchFileIdntfNo || "";
const nextFileSn = parseNumber(node.attrs.fileSn) || storedFile?.fileSn || 0;
const nextAttrs = {
...node.attrs,
atchFileIdntfNo: nextAtchFileIdntfNo,
fileSn: nextFileSn,
isRepresentative: shouldBeRepresentative,
};
if (
Boolean(node.attrs.isRepresentative) !== shouldBeRepresentative ||
node.attrs.atchFileIdntfNo !== nextAtchFileIdntfNo ||
parseNumber(node.attrs.fileSn) !== nextFileSn
) {
transaction.setNodeMarkup(position, undefined, nextAttrs);
changed = true;
}
return true;
});
if (changed) {
editor.view.dispatch(transaction);
}
return changed;
}
function syncToolbar(editor, toolbar) {
toolbar
.querySelectorAll("button[data-editor-action]")
.forEach((button) => {
const action = button.dataset.editorAction;
let active = false;
if (action === "paragraph") {
active = editor.isActive("paragraph");
} else if (action === "heading") {
active = editor.isActive("heading", {
level: parseNumber(button.dataset.level),
});
} else if (action === "bold") {
active = editor.isActive("bold");
} else if (action === "italic") {
active = editor.isActive("italic");
} else if (action === "strike") {
active = editor.isActive("strike");
} else if (action === "bulletList") {
active = editor.isActive("bulletList");
} else if (action === "orderedList") {
active = editor.isActive("orderedList");
} else if (action === "blockquote") {
active = editor.isActive("blockquote");
}
button.classList.toggle("is-active", active);
});
}
function syncHiddenFields(editor, hiddenInput, representativeIdInput, representativeSnInput) {
hiddenInput.value = editor.getHTML();
const representative = getRepresentativeImage(editor);
representativeIdInput.value = representative?.atchFileIdntfNo || "";
representativeSnInput.value = representative?.fileSn ? String(representative.fileSn) : "";
}
async function uploadImage(file, options, form) {
const formData = new FormData();
formData.append("file", file);
formData.append("directory", options.uploadDirectory);
formData.append("description", options.uploadDescription);
const headers = {};
const csrfHeader = form.dataset.csrfHeader;
const csrfToken = form.dataset.csrfToken;
if (csrfHeader && csrfToken) {
headers[csrfHeader] = csrfToken;
}
const response = await fetch(options.uploadUrl, {
method: "POST",
headers,
body: formData,
});
if (!response.ok) {
throw new Error("이미지 업로드에 실패했습니다.");
}
return response.json();
}
function ensureLegacyRepresentative(editor, form) {
const currentUrl = form.dataset.currentThumbnailUrl || "";
const currentAtchFileIdntfNo =
form.dataset.currentThumbnailAtchFileIdntfNo || "";
const currentFileSn = parseNumber(form.dataset.currentThumbnailFileSn);
if (!currentAtchFileIdntfNo || currentFileSn <= 0) {
return;
}
if (getRepresentativeImage(editor)) {
return;
}
const existingPosition = findImagePosition(
editor,
(node) =>
node.attrs.atchFileIdntfNo === currentAtchFileIdntfNo &&
parseNumber(node.attrs.fileSn) === currentFileSn,
);
if (existingPosition !== null) {
setRepresentativeAtPosition(editor, existingPosition);
return;
}
if (!currentUrl) {
return;
}
editor.commands.insertContent({
type: PORTFOLIO_IMAGE_TYPE,
attrs: {
src: currentUrl,
alt: "대표 이미지",
atchFileIdntfNo: currentAtchFileIdntfNo,
fileSn: currentFileSn,
isRepresentative: true,
},
});
}
function attachToolbarActions(editor, toolbar, fileInput) {
toolbar.addEventListener("mousedown", (event) => {
if (event.target.closest("button[data-editor-action]")) {
event.preventDefault();
}
});
toolbar.addEventListener("click", (event) => {
const button = event.target.closest("button[data-editor-action]");
if (!button) {
return;
}
const action = button.dataset.editorAction;
const chain = editor.chain().focus();
if (action === "paragraph") {
chain.setParagraph().run();
return;
}
if (action === "heading") {
chain.toggleHeading({ level: parseNumber(button.dataset.level) }).run();
return;
}
if (action === "bold") {
chain.toggleBold().run();
return;
}
if (action === "italic") {
chain.toggleItalic().run();
return;
}
if (action === "strike") {
chain.toggleStrike().run();
return;
}
if (action === "bulletList") {
chain.toggleBulletList().run();
return;
}
if (action === "orderedList") {
chain.toggleOrderedList().run();
return;
}
if (action === "blockquote") {
chain.toggleBlockquote().run();
return;
}
if (action === "horizontalRule") {
chain.setHorizontalRule().run();
return;
}
if (action === "undo") {
chain.undo().run();
return;
}
if (action === "redo") {
chain.redo().run();
return;
}
if (action === "imageUpload") {
fileInput.click();
return;
}
if (action === "setRepresentative") {
const selectedPosition = getSelectedImagePosition(editor);
if (selectedPosition === null) {
window.alert("대표 이미지로 지정할 이미지를 먼저 선택해주세요.");
return;
}
setRepresentativeAtPosition(editor, selectedPosition);
}
});
}
function initPortfolioEditor(options) {
const form = document.querySelector(options.formSelector);
const editorElement = document.querySelector(options.editorSelector);
const toolbar = document.querySelector(options.toolbarSelector);
const hiddenInput = document.querySelector(options.hiddenInputSelector);
const fileInput = document.querySelector(options.fileInputSelector);
const representativeIdInput = document.querySelector(
options.representativeIdSelector,
);
const representativeSnInput = document.querySelector(
options.representativeSnSelector,
);
if (
!form ||
!editorElement ||
!toolbar ||
!hiddenInput ||
!fileInput ||
!representativeIdInput ||
!representativeSnInput
) {
return null;
}
const editor = new Editor({
element: editorElement,
extensions: [StarterKit, PortfolioImage],
content: hiddenInput.value || "<p></p>",
editorProps: {
attributes: {
class: "ProseMirror",
},
},
onCreate({ editor: createdEditor }) {
ensureLegacyRepresentative(createdEditor, form);
syncHiddenFields(
createdEditor,
hiddenInput,
representativeIdInput,
representativeSnInput,
);
syncToolbar(createdEditor, toolbar);
},
onUpdate({ editor: updatedEditor }) {
syncHiddenFields(
updatedEditor,
hiddenInput,
representativeIdInput,
representativeSnInput,
);
syncToolbar(updatedEditor, toolbar);
},
onSelectionUpdate({ editor: selectedEditor }) {
syncToolbar(selectedEditor, toolbar);
},
});
attachToolbarActions(editor, toolbar, fileInput);
fileInput.addEventListener("change", async () => {
if (!fileInput.files?.length) {
return;
}
const [file] = fileInput.files;
try {
const payload = await uploadImage(file, options, form);
const shouldBeRepresentative = !getRepresentativeImage(editor);
editor
.chain()
.focus()
.insertContent({
type: PORTFOLIO_IMAGE_TYPE,
attrs: {
src: payload.url,
alt: payload.orgnlFileNm || file.name,
atchFileIdntfNo: payload.atchFileIdntfNo,
fileSn: payload.fileSn,
isRepresentative: shouldBeRepresentative,
},
})
.run();
} catch (error) {
window.alert(error.message || "이미지 업로드에 실패했습니다.");
} finally {
fileInput.value = "";
}
});
form.addEventListener("submit", (event) => {
syncHiddenFields(editor, hiddenInput, representativeIdInput, representativeSnInput);
if (!representativeIdInput.value || !representativeSnInput.value) {
event.preventDefault();
window.alert("대표 이미지로 지정할 본문 이미지를 선택해주세요.");
}
});
return editor;
}
export { initPortfolioEditor };
@@ -7,6 +7,8 @@ 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 UserMapper {
@@ -37,6 +39,72 @@ public interface UserMapper {
""")
int countByUserId(@Param("userId") String userId);
@Select("""
<script>
SELECT
USER_IDNTF_NO,
USER_ID,
PSWD,
USER_NM,
EML_ADDR,
USER_STTS_CD,
USER_TYPE_CD,
JOIN_DT,
INDVDL_INFO_AGRE_DT,
LOCK_YN,
LGN_FAIL_NMTM,
LOCK_DT,
LAST_LGN_DT
FROM TN_USER
<where>
<if test="keyword != null and keyword != ''">
(
USER_ID LIKE CONCAT('%', #{keyword}, '%')
OR USER_NM LIKE CONCAT('%', #{keyword}, '%')
OR EML_ADDR LIKE CONCAT('%', #{keyword}, '%')
)
</if>
</where>
ORDER BY JOIN_DT DESC, USER_ID ASC
</script>
""")
List<UserAccount> selectUserList(@Param("keyword") String keyword);
@Select("""
SELECT
USER_IDNTF_NO,
USER_ID,
PSWD,
USER_NM,
EML_ADDR,
USER_STTS_CD,
USER_TYPE_CD,
JOIN_DT,
INDVDL_INFO_AGRE_DT,
LOCK_YN,
LGN_FAIL_NMTM,
LOCK_DT,
LAST_LGN_DT
FROM TN_USER
WHERE USER_IDNTF_NO = #{userIdntfNo}
""")
UserAccount selectByUserIdntfNo(@Param("userIdntfNo") String userIdntfNo);
@Select("""
SELECT COUNT(*)
FROM TN_USER
WHERE USER_STTS_CD IN ('A', 'P')
""")
int countActiveUsers();
@Select("""
SELECT COUNT(*)
FROM TN_USER
WHERE USER_TYPE_CD = 'ADM'
AND USER_STTS_CD IN ('A', 'P')
""")
int countAdminUsers();
@Insert("""
INSERT INTO TN_USER (
USER_IDNTF_NO,
@@ -76,4 +144,24 @@ public interface UserMapper {
WHERE USER_IDNTF_NO = #{userIdntfNo}
""")
int updateLastLoginDt(@Param("userIdntfNo") String userIdntfNo);
@Update("""
<script>
UPDATE TN_USER
SET
USER_NM = #{userNm},
EML_ADDR = #{emlAddr},
USER_STTS_CD = #{userSttsCd},
USER_TYPE_CD = #{userTypeCd},
INDVDL_INFO_AGRE_DT = COALESCE(INDVDL_INFO_AGRE_DT, CURRENT_TIMESTAMP),
LOCK_YN = 'N',
LGN_FAIL_NMTM = 0,
LOCK_DT = NULL
<if test="pswd != null and pswd != ''">
, PSWD = #{pswd}
</if>
WHERE USER_IDNTF_NO = #{userIdntfNo}
</script>
""")
int updateUser(UserAccount userAccount);
}
@@ -13,6 +13,7 @@ public class PortfolioForm {
private String content;
private String thumbnailAtchFileIdntfNo;
private int thumbnailFileSn;
public String getPortfolioIdntfNo() {
return portfolioIdntfNo;
@@ -45,4 +46,12 @@ public class PortfolioForm {
public void setThumbnailAtchFileIdntfNo(String thumbnailAtchFileIdntfNo) {
this.thumbnailAtchFileIdntfNo = thumbnailAtchFileIdntfNo;
}
public int getThumbnailFileSn() {
return thumbnailFileSn;
}
public void setThumbnailFileSn(int thumbnailFileSn) {
this.thumbnailFileSn = thumbnailFileSn;
}
}
@@ -0,0 +1,81 @@
package kr.iotdoor.boot.model.user;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public class AdminUserForm {
private String userIdntfNo;
@NotBlank(message = "아이디는 필수입니다.")
private String userId;
@NotBlank(message = "이름은 필수입니다.")
private String userNm;
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
private String emlAddr;
private String userTypeCd = "USR";
private String userSttsCd = "A";
private String password;
public String getUserIdntfNo() {
return userIdntfNo;
}
public void setUserIdntfNo(String userIdntfNo) {
this.userIdntfNo = userIdntfNo;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUserNm() {
return userNm;
}
public void setUserNm(String userNm) {
this.userNm = userNm;
}
public String getEmlAddr() {
return emlAddr;
}
public void setEmlAddr(String emlAddr) {
this.emlAddr = emlAddr;
}
public String getUserTypeCd() {
return userTypeCd;
}
public void setUserTypeCd(String userTypeCd) {
this.userTypeCd = userTypeCd;
}
public String getUserSttsCd() {
return userSttsCd;
}
public void setUserSttsCd(String userSttsCd) {
this.userSttsCd = userSttsCd;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
@@ -23,6 +23,9 @@ public class AdminUserDetailsService implements UserDetailsService {
if (userAccount == null) {
throw new UsernameNotFoundException("관리자 계정을 찾을 수 없습니다.");
}
if (!"ADM".equalsIgnoreCase(userAccount.getUserTypeCd())) {
throw new UsernameNotFoundException("관리자 계정만 로그인할 수 있습니다.");
}
return new AdminUserPrincipal(userAccount);
}
@@ -7,7 +7,6 @@ import kr.iotdoor.boot.model.user.AdminUserPrincipal;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.List;
@@ -62,19 +61,11 @@ public class PortfolioService {
}
@Transactional
public void save(PortfolioForm form, MultipartFile thumbnailFile, AdminUserPrincipal principal) {
public void save(PortfolioForm form, AdminUserPrincipal principal) {
String thumbnailAtchFileIdntfNo = form.getThumbnailAtchFileIdntfNo();
int thumbnailFileSn = 1;
int thumbnailFileSn = form.getThumbnailFileSn();
if (thumbnailFile != null && !thumbnailFile.isEmpty()) {
thumbnailAtchFileIdntfNo = fileStorageService.storeSingleFile(
thumbnailFile,
"portfolio/thumbnail",
"portfolio-thumbnail"
).getAtchFileIdntfNo();
}
if (!StringUtils.hasText(thumbnailAtchFileIdntfNo)) {
if (!StringUtils.hasText(thumbnailAtchFileIdntfNo) || thumbnailFileSn <= 0) {
throw new IllegalArgumentException("대표 이미지는 필수입니다.");
}
@@ -0,0 +1,128 @@
package kr.iotdoor.boot.service;
import kr.iotdoor.boot.mapper.UserMapper;
import kr.iotdoor.boot.model.user.AdminUserForm;
import kr.iotdoor.boot.model.user.UserAccount;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Service
public class UserManagementService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
public UserManagementService(UserMapper userMapper, PasswordEncoder passwordEncoder) {
this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder;
}
@Transactional(readOnly = true)
public List<UserAccount> findUsers(String keyword) {
return userMapper.selectUserList(StringUtils.hasText(keyword) ? keyword.trim() : null);
}
@Transactional(readOnly = true)
public AdminUserForm getForm(String userIdntfNo) {
if (!StringUtils.hasText(userIdntfNo)) {
return new AdminUserForm();
}
UserAccount userAccount = userMapper.selectByUserIdntfNo(userIdntfNo);
if (userAccount == null) {
return new AdminUserForm();
}
AdminUserForm form = new AdminUserForm();
form.setUserIdntfNo(userAccount.getUserIdntfNo());
form.setUserId(userAccount.getUserId());
form.setUserNm(userAccount.getUserNm());
form.setEmlAddr(userAccount.getEmlAddr());
form.setUserTypeCd(userAccount.getUserTypeCd());
form.setUserSttsCd(userAccount.getUserSttsCd());
return form;
}
@Transactional(readOnly = true)
public int countActiveUsers() {
return userMapper.countActiveUsers();
}
@Transactional(readOnly = true)
public int countAdminUsers() {
return userMapper.countAdminUsers();
}
@Transactional
public String save(AdminUserForm form) {
if (!StringUtils.hasText(form.getUserIdntfNo())) {
return createUser(form);
}
return updateUser(form);
}
private String createUser(AdminUserForm form) {
if (!StringUtils.hasText(form.getPassword())) {
throw new IllegalArgumentException("신규 사용자는 비밀번호가 필요합니다.");
}
String userId = form.getUserId().trim();
if (userMapper.countByUserId(userId) > 0) {
throw new IllegalArgumentException("이미 사용 중인 아이디입니다.");
}
UserAccount userAccount = new UserAccount();
userAccount.setUserIdntfNo(newIdentifier());
userAccount.setUserId(userId);
userAccount.setPswd(passwordEncoder.encode(form.getPassword().trim()));
userAccount.setUserNm(form.getUserNm().trim());
userAccount.setEmlAddr(form.getEmlAddr().trim());
userAccount.setUserTypeCd(defaultUserType(form.getUserTypeCd()));
userAccount.setUserSttsCd(defaultUserStatus(form.getUserSttsCd()));
userAccount.setJoinDt(LocalDateTime.now());
userAccount.setIndvdlInfoAgreDt(LocalDateTime.now());
userAccount.setLockYn("N");
userAccount.setLgnFailNmtm(0);
userMapper.insertUser(userAccount);
return userAccount.getUserIdntfNo();
}
private String updateUser(AdminUserForm form) {
UserAccount existing = userMapper.selectByUserIdntfNo(form.getUserIdntfNo());
if (existing == null) {
throw new IllegalArgumentException("수정할 사용자를 찾을 수 없습니다.");
}
existing.setUserNm(form.getUserNm().trim());
existing.setEmlAddr(form.getEmlAddr().trim());
existing.setUserTypeCd(defaultUserType(form.getUserTypeCd()));
existing.setUserSttsCd(defaultUserStatus(form.getUserSttsCd()));
if (StringUtils.hasText(form.getPassword())) {
existing.setPswd(passwordEncoder.encode(form.getPassword().trim()));
} else {
existing.setPswd(null);
}
userMapper.updateUser(existing);
return existing.getUserIdntfNo();
}
private String defaultUserType(String userTypeCd) {
return StringUtils.hasText(userTypeCd) ? userTypeCd.trim() : "USR";
}
private String defaultUserStatus(String userSttsCd) {
return StringUtils.hasText(userSttsCd) ? userSttsCd.trim() : "A";
}
private String newIdentifier() {
return UUID.randomUUID().toString().replace("-", "");
}
}
@@ -1,8 +1,8 @@
package kr.iotdoor.boot.web;
import kr.iotdoor.boot.service.ContactSubmissionService;
import kr.iotdoor.boot.service.MigrationCatalogService;
import kr.iotdoor.boot.service.PortfolioService;
import kr.iotdoor.boot.service.UserManagementService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -11,31 +11,39 @@ import org.springframework.web.bind.annotation.GetMapping;
public class AdminController {
private final ContactSubmissionService contactSubmissionService;
private final MigrationCatalogService migrationCatalogService;
private final PortfolioService portfolioService;
private final UserManagementService userManagementService;
public AdminController(
ContactSubmissionService contactSubmissionService,
MigrationCatalogService migrationCatalogService,
PortfolioService portfolioService
PortfolioService portfolioService,
UserManagementService userManagementService
) {
this.contactSubmissionService = contactSubmissionService;
this.migrationCatalogService = migrationCatalogService;
this.portfolioService = portfolioService;
this.userManagementService = userManagementService;
}
@GetMapping({"/mfuc/index.do", "/mngr/index.do"})
public String dashboard(Model model) {
model.addAttribute("recentContacts", contactSubmissionService.recentSubmissions(10));
model.addAttribute("recentPortfolios", portfolioService.getAdminPortfolioItems().stream().limit(5).toList());
model.addAttribute("contactCount", contactSubmissionService.count());
model.addAttribute("portfolioCount", portfolioService.count());
model.addAttribute("migrationFeatures", migrationCatalogService.getFeatures());
model.addAttribute("activeUserCount", userManagementService.countActiveUsers());
model.addAttribute("adminUserCount", userManagementService.countAdminUsers());
model.addAttribute("adminMenu", "dashboard");
model.addAttribute("pageTitle", "Dashboard");
model.addAttribute("pageDescription", "관리자, 포트폴리오, 문의 현황을 한 화면에서 확인합니다.");
return "kr/iotdoor/boot/admin/dashboard";
}
@GetMapping("/mfuc/contact/list.do")
public String contactList(Model model) {
model.addAttribute("contacts", contactSubmissionService.findAll());
model.addAttribute("adminMenu", "contact");
model.addAttribute("pageTitle", "문의 내역");
model.addAttribute("pageDescription", "홈페이지 문의 접수 내역과 연락처 정보를 확인합니다.");
return "kr/iotdoor/boot/admin/contact/list";
}
}
@@ -13,7 +13,6 @@ 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;
import org.springframework.web.multipart.MultipartFile;
@Controller
public class AdminPortfolioController {
@@ -27,6 +26,9 @@ public class AdminPortfolioController {
@GetMapping("/mfuc/portfolio/list.do")
public String portfolioList(Model model) {
model.addAttribute("portfolioItems", portfolioService.getAdminPortfolioItems());
model.addAttribute("adminMenu", "portfolio");
model.addAttribute("pageTitle", "포트폴리오 관리");
model.addAttribute("pageDescription", "포트폴리오 콘텐츠를 작성하고 대표 이미지를 에디터 안에서 지정합니다.");
return "kr/iotdoor/boot/admin/portfolio/list";
}
@@ -43,12 +45,15 @@ public class AdminPortfolioController {
form.setTitle(portfolio.getTitle());
form.setContent(portfolio.getContent());
form.setThumbnailAtchFileIdntfNo(portfolio.getThumbnailAtchFileIdntfNo());
form.setThumbnailFileSn(portfolio.getThumbnailFileSn());
model.addAttribute("currentThumbnailUrl", portfolio.getImageUrl());
model.addAttribute("portfolioMeta", portfolio);
}
}
model.addAttribute("form", form);
model.addAttribute("adminMenu", "portfolio");
model.addAttribute("pageTitle", form.getPortfolioIdntfNo() != null && !form.getPortfolioIdntfNo().isBlank() ? "포트폴리오 수정" : "포트폴리오 등록");
model.addAttribute("pageDescription", "Tiptap 에디터에서 본문 이미지를 업로드하고 대표 이미지를 지정할 수 있습니다.");
return "kr/iotdoor/boot/admin/portfolio/form";
}
@@ -56,22 +61,24 @@ public class AdminPortfolioController {
public String savePortfolio(
@Valid @ModelAttribute("form") PortfolioForm form,
BindingResult bindingResult,
@RequestParam(name = "thumbnailFile", required = false) MultipartFile thumbnailFile,
Authentication authentication,
Model model
) {
if ((thumbnailFile == null || thumbnailFile.isEmpty()) && (form.getThumbnailAtchFileIdntfNo() == null || form.getThumbnailAtchFileIdntfNo().isBlank())) {
if ((form.getThumbnailAtchFileIdntfNo() == null || form.getThumbnailAtchFileIdntfNo().isBlank()) || form.getThumbnailFileSn() <= 0) {
bindingResult.rejectValue("thumbnailAtchFileIdntfNo", "required", "대표 이미지는 필수입니다.");
}
if (bindingResult.hasErrors()) {
if (form.getThumbnailAtchFileIdntfNo() != null && !form.getThumbnailAtchFileIdntfNo().isBlank()) {
model.addAttribute("currentThumbnailUrl", portfolioService.resolveThumbnailUrl(form.getThumbnailAtchFileIdntfNo(), 1));
if (form.getThumbnailAtchFileIdntfNo() != null && !form.getThumbnailAtchFileIdntfNo().isBlank() && form.getThumbnailFileSn() > 0) {
model.addAttribute("currentThumbnailUrl", portfolioService.resolveThumbnailUrl(form.getThumbnailAtchFileIdntfNo(), form.getThumbnailFileSn()));
}
model.addAttribute("adminMenu", "portfolio");
model.addAttribute("pageTitle", form.getPortfolioIdntfNo() != null && !form.getPortfolioIdntfNo().isBlank() ? "포트폴리오 수정" : "포트폴리오 등록");
model.addAttribute("pageDescription", "Tiptap 에디터에서 본문 이미지를 업로드하고 대표 이미지를 지정할 수 있습니다.");
return "kr/iotdoor/boot/admin/portfolio/form";
}
portfolioService.save(form, thumbnailFile, (AdminUserPrincipal) authentication.getPrincipal());
portfolioService.save(form, (AdminUserPrincipal) authentication.getPrincipal());
return "redirect:/mfuc/portfolio/list.do?saved=1";
}
@@ -0,0 +1,75 @@
package kr.iotdoor.boot.web;
import jakarta.validation.Valid;
import kr.iotdoor.boot.model.user.AdminUserForm;
import kr.iotdoor.boot.service.UserManagementService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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 AdminUserManagementController {
private final UserManagementService userManagementService;
public AdminUserManagementController(UserManagementService userManagementService) {
this.userManagementService = userManagementService;
}
@GetMapping("/mfuc/user/list.do")
public String userList(
@RequestParam(name = "keyword", required = false) String keyword,
@RequestParam(name = "userIdntfNo", required = false) String userIdntfNo,
Model model
) {
model.addAttribute("users", userManagementService.findUsers(keyword));
model.addAttribute("keyword", keyword == null ? "" : keyword);
model.addAttribute("form", userManagementService.getForm(userIdntfNo));
applyPage(model);
return "kr/iotdoor/boot/admin/user/list";
}
@PostMapping("/mfuc/user/save.do")
public String saveUser(
@Valid @ModelAttribute("form") AdminUserForm form,
BindingResult bindingResult,
@RequestParam(name = "keyword", required = false) String keyword,
Model model
) {
if (form.getUserIdntfNo() == null || form.getUserIdntfNo().isBlank()) {
if (form.getPassword() == null || form.getPassword().isBlank()) {
bindingResult.rejectValue("password", "required", "신규 사용자는 비밀번호가 필요합니다.");
}
}
if (bindingResult.hasErrors()) {
model.addAttribute("users", userManagementService.findUsers(keyword));
model.addAttribute("keyword", keyword == null ? "" : keyword);
applyPage(model);
return "kr/iotdoor/boot/admin/user/list";
}
try {
String savedId = userManagementService.save(form);
String redirectKeyword = keyword == null || keyword.isBlank() ? "" : "&keyword=" + keyword.trim();
return "redirect:/mfuc/user/list.do?userIdntfNo=" + savedId + "&saved=1" + redirectKeyword;
} catch (IllegalArgumentException exception) {
bindingResult.reject("saveError", exception.getMessage());
model.addAttribute("users", userManagementService.findUsers(keyword));
model.addAttribute("keyword", keyword == null ? "" : keyword);
applyPage(model);
return "kr/iotdoor/boot/admin/user/list";
}
}
private void applyPage(Model model) {
model.addAttribute("adminMenu", "user");
model.addAttribute("adminSubMenu", "new");
model.addAttribute("pageTitle", "사용자 관리");
model.addAttribute("pageDescription", "관리자와 일반 사용자 계정을 등록하고 기본 정보를 관리합니다.");
}
}
+8
View File
@@ -16,6 +16,14 @@ spring:
enabled: true
thymeleaf:
cache: false
web:
resources:
static-locations:
- file:src/main/resources/static/
- classpath:/META-INF/resources/
- classpath:/resources/
- classpath:/static/
- classpath:/public/
servlet:
multipart:
max-file-size: ${IOTDOOR_MAX_FILE_SIZE:20MB}
@@ -0,0 +1,153 @@
body {
margin: 0;
overflow-x: hidden;
}
.iotdoor-admin-shell {
min-height: 100vh;
width: 100%;
overflow-x: hidden;
}
.iotdoor-admin-sidebar {
position: static;
width: 100%;
}
.iotdoor-admin-main {
width: 100%;
max-width: 100%;
min-width: 0;
}
@media (min-width: 1280px) {
.iotdoor-admin-sidebar {
position: fixed;
top: 0;
left: 0;
width: 290px;
height: 100vh;
overflow-y: auto;
}
.iotdoor-admin-main {
margin-left: 290px;
width: calc(100% - 290px);
}
}
.iotdoor-editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.iotdoor-editor-toolbar button {
border: 1px solid #d0d5dd;
background: #ffffff;
color: #344054;
border-radius: 0.75rem;
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
transition: all 0.2s ease;
}
.iotdoor-editor-toolbar button:hover,
.iotdoor-editor-toolbar button.is-active {
border-color: #9cb9ff;
background: #ecf3ff;
color: #3641f5;
}
.iotdoor-editor-surface {
border: 1px solid #d0d5dd;
border-radius: 1rem;
background: #ffffff;
overflow: hidden;
}
.iotdoor-editor-surface .ProseMirror {
min-height: 520px;
padding: 1.5rem;
outline: none;
color: #1d2939;
line-height: 1.8;
}
.iotdoor-editor-surface .ProseMirror p {
margin: 0 0 1rem;
}
.iotdoor-editor-surface .ProseMirror h1,
.iotdoor-editor-surface .ProseMirror h2,
.iotdoor-editor-surface .ProseMirror h3 {
color: #101828;
font-weight: 700;
margin: 1.5rem 0 1rem;
}
.iotdoor-editor-surface .ProseMirror h1 {
font-size: 2rem;
}
.iotdoor-editor-surface .ProseMirror h2 {
font-size: 1.5rem;
}
.iotdoor-editor-surface .ProseMirror h3 {
font-size: 1.25rem;
}
.iotdoor-editor-surface .ProseMirror blockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid #9cb9ff;
background: #f9fafb;
color: #475467;
}
.iotdoor-editor-surface .ProseMirror ul,
.iotdoor-editor-surface .ProseMirror ol {
padding-left: 1.5rem;
}
.iotdoor-editor-surface .ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 1rem;
}
.iotdoor-editor-image-node {
position: relative;
margin: 1.25rem 0;
}
.iotdoor-editor-image-node.is-representative::before {
content: "";
position: absolute;
inset: 0;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(70, 95, 255, 0.18), rgba(22, 25, 80, 0.3));
pointer-events: none;
}
.iotdoor-editor-image-node.is-representative::after {
content: "대표 이미지";
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.5rem 0.875rem;
border-radius: 999px;
background: rgba(16, 24, 40, 0.85);
color: #ffffff;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.iotdoor-editor-image-node.ProseMirror-selectednode img {
box-shadow: 0 0 0 4px rgba(70, 95, 255, 0.18);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,57 +1,55 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>문의 내역</title>
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/iotdoor.css?v=202404021718}">
</head>
<body class="bg-light">
<div class="container py-5">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
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">
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="flex flex-wrap items-center justify-between gap-3 px-6 py-5">
<div>
<h1 class="h3 mb-1">문의 내역</h1>
<p class="text-secondary mb-0">홈페이지 문의 접수 내용을 MariaDB에서 조회합니다.</p>
<h2 class="text-base font-medium text-gray-800">문의 접수 목록</h2>
<p class="mt-1 text-sm text-gray-500">저장된 문의 내용을 날짜순으로 확인할 수 있습니다.</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" th:href="@{/mfuc/index.do}">대시보드</a>
<a class="btn btn-outline-primary" th:href="@{/mfuc/portfolio/list.do}">포트폴리오 관리</a>
<a class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
th:href="@{/mfuc/index.do}">
대시보드
</a>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<div th:if="${#lists.isEmpty(contacts)}" class="text-secondary">
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(contacts)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
아직 저장된 문의가 없습니다.
</div>
<div th:unless="${#lists.isEmpty(contacts)}" class="table-responsive">
<table class="table align-middle">
<div th:unless="${#lists.isEmpty(contacts)}" class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr>
<th>접수일시</th>
<th>이름</th>
<th>연락처</th>
<th>이메일</th>
<th>제목</th>
<th>내용</th>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">접수일시</th>
<th class="px-3 py-3">이름</th>
<th class="px-3 py-3">연락처</th>
<th class="px-3 py-3">이메일</th>
<th class="px-3 py-3">제목</th>
<th class="px-3 py-3">내용</th>
</tr>
</thead>
<tbody>
<tr th:each="contact : ${contacts}">
<td th:text="${#temporals.format(contact.submittedAt, 'yyyy-MM-dd HH:mm')}">2026-04-16 09:00</td>
<td th:text="${contact.name}">홍길동</td>
<td th:text="${contact.phone}">010-0000-0000</td>
<td th:text="${contact.email}">sample@example.com</td>
<td th:text="${contact.subject}">문의 제목</td>
<td style="min-width: 260px; white-space: pre-wrap;" th:text="${contact.message}">문의 내용</td>
<tr class="border-b border-gray-100 align-top last:border-0"
th:each="contact : ${contacts}">
<td class="px-3 py-4 text-gray-500"
th:text="${#temporals.format(contact.submittedAt, 'yyyy-MM-dd HH:mm')}">2026-04-17 09:00</td>
<td class="px-3 py-4 font-medium text-gray-900" th:text="${contact.name}">홍길동</td>
<td class="px-3 py-4 text-gray-500" th:text="${contact.phone}">010-0000-0000</td>
<td class="px-3 py-4 text-gray-500" th:text="${contact.email}">sample@example.com</td>
<td class="px-3 py-4 text-gray-900" th:text="${contact.subject}">문의 제목</td>
<td class="px-3 py-4 whitespace-pre-wrap leading-6 text-gray-500"
style="min-width: 320px;"
th:text="${contact.message}">문의 내용</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</body>
</html>
@@ -1,126 +1,107 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${siteInfo.adminTitle}">국제오토텍 관리자</title>
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/iotdoor.css?v=202404021718}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/custom.css?v=202404021718}">
</head>
<body class="bg-light">
<div class="container py-5">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/boot/admin/layout/adminLayout}">
<body>
<div layout:fragment="content" class="space-y-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">활성 사용자</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${activeUserCount}">0</h3>
<p class="mt-2 text-sm text-gray-500">현재 사용 가능한 사용자 계정 수입니다.</p>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">관리자 계정</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${adminUserCount}">0</h3>
<p class="mt-2 text-sm text-gray-500">`ADM` 유형으로 등록된 계정 수입니다.</p>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">포트폴리오</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${portfolioCount}">0</h3>
<p class="mt-2 text-sm text-gray-500">공개 중인 포트폴리오 문서 수입니다.</p>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">문의 접수</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${contactCount}">0</h3>
<p class="mt-2 text-sm text-gray-500">홈페이지에서 접수된 전체 문의 수입니다.</p>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="flex items-center justify-between gap-3 px-6 py-5">
<div>
<p class="text-primary fw-semibold mb-2">ADMIN DASHBOARD</p>
<h1 class="mb-2" th:text="${siteInfo.adminTitle}">국제오토텍 관리자</h1>
<p class="text-secondary mb-0">현재 관리자/포트폴리오/문의저장 기능은 MariaDB 기준으로 연결된 상태입니다.</p>
<h2 class="text-base font-medium text-gray-800">최근 문의</h2>
<p class="mt-1 text-sm text-gray-500">최근 접수된 문의 10건을 바로 확인할 수 있습니다.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-outline-secondary" th:href="@{/}">공개 사이트</a>
<a class="btn btn-outline-primary" th:href="@{/mfuc/portfolio/list.do}">포트폴리오 관리</a>
<a class="btn btn-outline-primary" th:href="@{/mfuc/contact/list.do}">문의 내역</a>
<form method="post" th:action="@{/comn/user/login/logout.do}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-primary" type="submit">로그아웃</button>
</form>
<a class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
th:href="@{/mfuc/contact/list.do}">
전체 보기
</a>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<p class="text-secondary mb-2">문의 건수</p>
<h2 class="mb-0" th:text="${contactCount}">0</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<p class="text-secondary mb-2">포트폴리오 건수</p>
<h2 class="mb-0" th:text="${portfolioCount}">0</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<p class="text-secondary mb-2">이행 항목 수</p>
<h2 class="mb-0" th:text="${migrationFeatures.size()}">0</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<p class="text-secondary mb-2">로그인 관리자</p>
<h2 class="mb-0" th:text="${currentAdminName}">관리자</h2>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<h3 class="h5 mb-3">마이그레이션 진행 현황</h3>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>영역</th>
<th>상태</th>
<th>요약</th>
</tr>
</thead>
<tbody>
<tr th:each="feature : ${migrationFeatures}">
<td class="fw-semibold" th:text="${feature.name()}">영역</td>
<td><span class="badge text-bg-primary" th:text="${feature.status()}">상태</span></td>
<td>
<div th:text="${feature.summary()}">요약</div>
<small class="text-secondary" th:text="${feature.routes()}">라우트</small>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="h5 mb-0">최근 문의</h3>
<a class="btn btn-sm btn-outline-primary" th:href="@{/mfuc/contact/list.do}">전체 보기</a>
</div>
<div th:if="${#lists.isEmpty(recentContacts)}" class="text-secondary">
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(recentContacts)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
아직 접수된 문의가 없습니다.
</div>
<div th:unless="${#lists.isEmpty(recentContacts)}" class="d-flex flex-column gap-3">
<div class="border rounded p-3 bg-white" th:each="contact : ${recentContacts}">
<div class="d-flex justify-content-between align-items-start gap-3">
<div th:unless="${#lists.isEmpty(recentContacts)}" class="space-y-4">
<article class="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-4"
th:each="contact : ${recentContacts}">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div class="fw-semibold" th:text="${contact.subject}">문의 제목</div>
<div class="small text-secondary">
<span th:text="${contact.name}">이름</span>
<span>·</span>
<span th:text="${contact.phone}">연락처</span>
<h3 class="text-base font-semibold text-gray-900" th:text="${contact.subject}">문의 제목</h3>
<p class="mt-1 text-sm text-gray-500">
<span th:text="${contact.name}">홍길동</span>
<span> · </span>
<span th:text="${contact.phone}">010-0000-0000</span>
<span> · </span>
<span th:text="${contact.email}">sample@example.com</span>
</p>
</div>
<span class="text-sm text-gray-400"
th:text="${#temporals.format(contact.submittedAt, 'yyyy-MM-dd HH:mm')}">2026-04-17 09:00</span>
</div>
<p class="mt-3 whitespace-pre-wrap text-sm leading-6 text-gray-600" th:text="${contact.message}">문의 내용</p>
</article>
</div>
</div>
<small class="text-secondary" th:text="${#temporals.format(contact.submittedAt, 'MM-dd HH:mm')}">04-16 10:30</small>
</div>
<p class="mb-0 mt-2 text-secondary" th:text="${contact.message}">문의 내용</p>
</section>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="flex items-center justify-between gap-3 px-6 py-5">
<div>
<h2 class="text-base font-medium text-gray-800">최근 포트폴리오</h2>
<p class="mt-1 text-sm text-gray-500">대표 이미지와 조회수까지 빠르게 점검할 수 있습니다.</p>
</div>
<a class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
th:href="@{/mfuc/portfolio/list.do}">
관리 이동
</a>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(recentPortfolios)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
아직 등록된 포트폴리오가 없습니다.
</div>
<div th:unless="${#lists.isEmpty(recentPortfolios)}" class="space-y-4">
<a class="flex gap-4 rounded-2xl border border-gray-100 bg-gray-50 p-4 transition hover:border-brand-200 hover:bg-brand-25"
th:each="portfolio : ${recentPortfolios}"
th:href="@{/mfuc/portfolio/form.do(portfolioIdntfNo=${portfolio.portfolioIdntfNo})}">
<img class="h-20 w-28 rounded-xl object-cover"
th:src="${portfolio.imageUrl}"
th:alt="${portfolio.title}">
<div class="min-w-0 flex-1">
<h3 class="truncate text-base font-semibold text-gray-900" th:text="${portfolio.title}">포트폴리오 제목</h3>
<p class="mt-2 text-sm leading-6 text-gray-500" th:text="${portfolio.summary}">설명</p>
<div class="mt-3 flex flex-wrap items-center gap-3 text-xs text-gray-400">
<span th:text="${portfolio.registeredByName}">관리자</span>
<span th:text="${#temporals.format(portfolio.registeredAt, 'yyyy-MM-dd HH:mm')}">2026-04-17 09:00</span>
<span th:text="|조회 ${portfolio.viewCount}|">조회 0</span>
</div>
</div>
</a>
</div>
</div>
</section>
</div>
</div>
</body>
@@ -0,0 +1,127 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${siteInfo.adminTitle + ' | ' + (pageTitle != null ? pageTitle : '관리자')}">국제오토텍 관리자</title>
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/boot/admin/css/admin-template.css}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/boot/admin/css/admin-custom.css}">
<th:block layout:fragment="pageStyles"></th:block>
</head>
<body class="bg-gray-50">
<div class="iotdoor-admin-shell">
<aside class="iotdoor-admin-sidebar border-r border-gray-200 bg-white">
<div class="border-b border-gray-200 px-5 py-8">
<a class="block" th:href="@{/mfuc/index.do}">
<div class="text-[11px] font-semibold uppercase tracking-[0.32em] text-brand-500">IOTDOOR</div>
<div class="mt-2 text-xl font-semibold text-gray-900" th:text="${siteInfo.adminTitle}">국제오토텍 관리자</div>
<p class="mt-2 text-sm text-gray-500">외부 관리자 템플릿 스타일을 적용한 운영 콘솔입니다.</p>
</a>
</div>
<div class="px-5 py-6">
<nav class="mb-6">
<h2 class="mb-4 text-xs uppercase leading-[20px] text-gray-400">Admin Menu</h2>
<ul class="flex flex-col gap-4">
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'dashboard'} ? ' menu-item-active' : ' menu-item-inactive'"
th:href="@{/mfuc/index.do}">
<span class="menu-item-icon-size"
th:classappend="${adminMenu == 'dashboard'} ? ' 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="M3 13.2C3 11.33 3 10.394 3.402 9.658c.352-.645.91-1.17 1.589-1.495C5.765 7.8 6.65 7.8 8.421 7.8h7.158c1.771 0 2.656 0 3.43.363.678.325 1.236.85 1.588 1.495.403.736.403 1.671.403 3.542v2.4c0 1.68 0 2.52-.327 3.162a3 3 0 0 1-1.311 1.311C18.72 20.4 17.88 20.4 16.2 20.4H7.8c-1.68 0-2.52 0-3.162-.327a3 3 0 0 1-1.311-1.311C3 18.12 3 17.28 3 15.6v-2.4Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M7.2 7.8V6.6a4.8 4.8 0 1 1 9.6 0v1.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</span>
<span class="menu-item-text">Dashboard</span>
</a>
</li>
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'user'} ? ' menu-item-active' : ' menu-item-inactive'"
th:href="@{/mfuc/user/list.do}">
<span class="menu-item-icon-size"
th:classappend="${adminMenu == 'user'} ? ' 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="M12 12a4.2 4.2 0 1 0 0-8.4 4.2 4.2 0 0 0 0 8.4ZM4.8 20.4a7.2 7.2 0 0 1 14.4 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</span>
<span class="menu-item-text">사용자 관리</span>
</a>
</li>
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'portfolio'} ? ' menu-item-active' : ' menu-item-inactive'"
th:href="@{/mfuc/portfolio/list.do}">
<span class="menu-item-icon-size"
th:classappend="${adminMenu == 'portfolio'} ? ' menu-item-icon-active' : ' menu-item-icon-inactive'">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.8 5.4h14.4A1.8 1.8 0 0 1 21 7.2v9.6a1.8 1.8 0 0 1-1.8 1.8H4.8A1.8 1.8 0 0 1 3 16.8V7.2a1.8 1.8 0 0 1 1.8-1.8Z" stroke="currentColor" stroke-width="1.5"/>
<path d="m7.2 14.4 2.7-2.7 2.1 2.1 3.6-3.6 1.8 1.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="8.1" cy="9" r="1.2" fill="currentColor"/>
</svg>
</span>
<span class="menu-item-text">포트폴리오 관리</span>
</a>
</li>
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'contact'} ? ' menu-item-active' : ' menu-item-inactive'"
th:href="@{/mfuc/contact/list.do}">
<span class="menu-item-icon-size"
th:classappend="${adminMenu == 'contact'} ? ' menu-item-icon-active' : ' menu-item-icon-inactive'">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.8 6h14.4A1.8 1.8 0 0 1 21 7.8v8.4a1.8 1.8 0 0 1-1.8 1.8H4.8A1.8 1.8 0 0 1 3 16.2V7.8A1.8 1.8 0 0 1 4.8 6Z" stroke="currentColor" stroke-width="1.5"/>
<path d="m5.4 8.1 5.532 4.147a1.8 1.8 0 0 0 2.136 0L18.6 8.1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</span>
<span class="menu-item-text">문의내역 관리</span>
</a>
</li>
</ul>
</nav>
</div>
</aside>
<div class="iotdoor-admin-main">
<header class="sticky top-0 z-999 bg-white/95 border-b border-gray-200 backdrop-blur-sm">
<div class="flex flex-col gap-4 px-4 py-4 md:flex-row md:items-center md:justify-between md:px-6">
<div>
<p class="text-sm text-gray-500">운영 중인 관리자</p>
<h1 class="text-xl font-semibold text-gray-900" th:text="${pageTitle != null ? pageTitle : '관리자'}">관리자</h1>
<p class="mt-1 text-sm text-gray-500" th:text="${pageDescription != null ? pageDescription : ''}"></p>
</div>
<div class="flex flex-wrap items-center gap-3">
<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="@{/}">
공개 사이트
</a>
<div class="inline-flex items-center gap-3 rounded-full bg-gray-100 px-4 py-2 text-sm text-gray-600">
<span class="inline-flex size-8 items-center justify-center rounded-full bg-brand-500 text-xs font-semibold text-white"
th:text="${currentAdminName != null and !#strings.isEmpty(currentAdminName) ? #strings.substring(currentAdminName, 0, 1) : 'A'}"></span>
<span th:text="${currentAdminName}">관리자</span>
</div>
<form method="post" th:action="@{/comn/user/login/logout.do}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<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>
</form>
</div>
</div>
</header>
<main class="mx-auto max-w-(--breakpoint-2xl) p-4 md:p-6">
<div layout:fragment="content"></div>
</main>
</div>
</div>
<script th:src="@{/resources/kr/iotdoor/boot/admin/js/iotdoor.js}"></script>
<th:block layout:fragment="pageScripts"></th:block>
</body>
</html>
@@ -1,144 +1,128 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
<title>포트폴리오 작성</title>
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/iotdoor.css?v=202404021718}">
</head>
<body class="bg-light">
<div class="container py-5">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
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>
<h1 class="h3 mb-1" th:text="${form.portfolioIdntfNo != null and !#strings.isEmpty(form.portfolioIdntfNo) ? '포트폴리오 수정' : '포트폴리오 등록'}">포트폴리오 등록</h1>
<p class="text-secondary mb-0">대표이미지는 별도 파일로 저장되고, 본문은 HTML로 저장됩니다.</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" th:href="@{/mfuc/portfolio/list.do}">목록</a>
<a class="btn btn-outline-primary" th:href="@{/mfuc/index.do}">대시보드</a>
<h2 class="text-xl font-semibold text-gray-800"
th:text="${form.portfolioIdntfNo != null and !#strings.isEmpty(form.portfolioIdntfNo) ? '포트폴리오 수정' : '포트폴리오 등록'}">포트폴리오 등록</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/portfolio/list.do}">
목록으로
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<form method="post" enctype="multipart/form-data" th:action="@{/mfuc/portfolio/save.do}" th:object="${form}">
<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">
<form id="portfolioForm"
method="post"
th:action="@{/mfuc/portfolio/save.do}"
th:object="${form}"
th:attr="data-csrf-header=${_csrf.headerName},
data-csrf-token=${_csrf.token},
data-current-thumbnail-url=${currentThumbnailUrl},
data-current-thumbnail-atch-file-idntf-no=*{thumbnailAtchFileIdntfNo},
data-current-thumbnail-file-sn=*{thumbnailFileSn}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" th:field="*{portfolioIdntfNo}">
<input type="hidden" th:field="*{thumbnailAtchFileIdntfNo}">
<input type="hidden" th:field="*{thumbnailAtchFileIdntfNo}" id="thumbnailAtchFileIdntfNo">
<input type="hidden" th:field="*{thumbnailFileSn}" id="thumbnailFileSn">
<textarea hidden th:field="*{content}" id="content"></textarea>
<div class="mb-4">
<label class="form-label fw-semibold" for="title">제목</label>
<input class="form-control" id="title" th:field="*{title}" placeholder="포트폴리오 제목을 입력해주세요.">
<div class="text-danger small mt-1" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">제목 오류</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold" for="thumbnailFile">대표이미지(썸네일)</label>
<input class="form-control" type="file" id="thumbnailFile" name="thumbnailFile" accept="image/*">
<div class="text-danger small mt-1" th:if="${#fields.hasErrors('thumbnailAtchFileIdntfNo')}" th:errors="*{thumbnailAtchFileIdntfNo}">썸네일 오류</div>
<div class="mt-3" th:if="${currentThumbnailUrl != null}">
<p class="small text-secondary mb-2">현재 대표이미지</p>
<img class="img-fluid rounded border" th:src="${currentThumbnailUrl}" alt="대표이미지 미리보기" style="max-width: 280px;">
</div>
</div>
<div class="mb-4 p-3 rounded border bg-white">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div class="space-y-6">
<div>
<h2 class="h6 mb-1">본문 이미지 업로드</h2>
<p class="text-secondary small mb-0">이미지를 올리면 URL이 발급되고, 버튼으로 본문에 바로 삽입할 수 있습니다.</p>
<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 class="d-flex flex-column flex-md-row gap-2">
<input class="form-control" type="file" id="contentImageFile" accept="image/*">
<button class="btn btn-outline-primary" id="uploadContentImageBtn" type="button">본문 이미지 업로드</button>
</div>
</div>
<div class="mt-3" id="contentImageResult" style="display:none;">
<div class="input-group">
<input class="form-control" type="text" id="contentImageUrl" readonly>
<button class="btn btn-outline-secondary" type="button" id="insertContentImageBtn">본문에 삽입</button>
<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 class="rounded-xl bg-brand-25 px-4 py-3 text-sm text-brand-700">
대표 이미지는 반드시 본문 안에 있어야 합니다.
</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold" for="content">내용</label>
<textarea class="form-control" id="content" th:field="*{content}" rows="18" placeholder="HTML 포함 본문을 입력해주세요."></textarea>
<div class="form-text">예시: &lt;p&gt;설명&lt;/p&gt; 또는 &lt;img src=&quot;/comn/file/view/...&quot; alt=&quot;&quot;&gt;</div>
<div class="text-danger small mt-1" th:if="${#fields.hasErrors('content')}" th:errors="*{content}">내용 오류</div>
<div id="portfolioToolbar" 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>
<button type="button" data-editor-action="setRepresentative">대표 이미지 지정</button>
</div>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-primary px-4" type="submit">저장</button>
<a class="btn btn-outline-secondary px-4" th:href="@{/mfuc/portfolio/list.do}">취소</a>
<div id="portfolioEditor" class="iotdoor-editor-surface"></div>
<input id="portfolioImageFile" 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.hasErrors('thumbnailAtchFileIdntfNo')}" th:errors="*{thumbnailAtchFileIdntfNo}">대표 이미지 오류</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 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/portfolio/list.do}">
취소
</a>
</div>
</div>
</form>
</div>
</div>
</section>
</div>
<script>
(function () {
function csrf() {
return {
token: document.querySelector('meta[name="_csrf"]').getAttribute('content'),
header: document.querySelector('meta[name="_csrf_header"]').getAttribute('content')
};
}
const uploadButton = document.getElementById('uploadContentImageBtn');
const insertButton = document.getElementById('insertContentImageBtn');
const resultBox = document.getElementById('contentImageResult');
const urlInput = document.getElementById('contentImageUrl');
const fileInput = document.getElementById('contentImageFile');
const contentField = document.getElementById('content');
uploadButton.addEventListener('click', async function () {
if (!fileInput.files.length) {
alert('업로드할 이미지를 먼저 선택해주세요.');
<th:block layout:fragment="pageScripts">
<script>
document.addEventListener('DOMContentLoaded', function () {
if (!window.IotdoorAdmin) {
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('directory', 'portfolio/content');
formData.append('description', 'portfolio-content-image');
const csrfInfo = csrf();
const response = await fetch('/mfuc/file/upload.do', {
method: 'POST',
body: formData,
headers: {
[csrfInfo.header]: csrfInfo.token
}
window.IotdoorAdmin.initPortfolioEditor({
formSelector: '#portfolioForm',
editorSelector: '#portfolioEditor',
toolbarSelector: '#portfolioToolbar',
hiddenInputSelector: '#content',
fileInputSelector: '#portfolioImageFile',
representativeIdSelector: '#thumbnailAtchFileIdntfNo',
representativeSnSelector: '#thumbnailFileSn',
uploadUrl: '/mfuc/file/upload.do',
uploadDirectory: 'portfolio/editor',
uploadDescription: 'portfolio-editor-image'
});
if (!response.ok) {
alert('본문 이미지 업로드에 실패했습니다.');
return;
}
const payload = await response.json();
urlInput.value = payload.url;
resultBox.style.display = 'block';
});
insertButton.addEventListener('click', function () {
if (!urlInput.value) {
return;
}
const imageTag = '<p><img src="' + urlInput.value + '" alt=""></p>';
const start = contentField.selectionStart || 0;
const end = contentField.selectionEnd || 0;
const original = contentField.value;
contentField.value = original.substring(0, start) + imageTag + original.substring(end);
contentField.focus();
});
})();
</script>
</script>
</th:block>
</body>
</html>
@@ -1,73 +1,81 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>포트폴리오 관리</title>
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/iotdoor.css?v=202404021718}">
</head>
<body class="bg-light">
<div class="container py-5">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
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>
<h1 class="h3 mb-1">포트폴리오 관리</h1>
<p class="text-secondary mb-0">대표이미지, 본문, 등록자, 조회수를 포함한 포트폴리오를 직접 관리합니다.</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" th:href="@{/mfuc/index.do}">대시보드</a>
<a class="btn btn-primary" th:href="@{/mfuc/portfolio/form.do}">새 포트폴리오 등록</a>
<h2 class="text-xl font-semibold text-gray-800">포트폴리오 관리</h2>
<p class="mt-1 text-sm text-gray-500">대표 이미지, 작성자, 조회수, 공개 링크를 함께 관리합니다.</p>
</div>
<a class="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600"
th:href="@{/mfuc/portfolio/form.do}">
새 포트폴리오 등록
</a>
</div>
<div class="alert alert-success" th:if="${param.saved}" role="alert">
<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="alert alert-success" th:if="${param.deleted}" role="alert">
<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="card border-0 shadow-sm">
<div class="card-body">
<div th:if="${#lists.isEmpty(portfolioItems)}" class="text-secondary">
<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(portfolioItems)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
아직 등록된 포트폴리오가 없습니다.
</div>
<div th:unless="${#lists.isEmpty(portfolioItems)}" class="table-responsive">
<table class="table align-middle">
<div th:unless="${#lists.isEmpty(portfolioItems)}" class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr>
<th>대표이미지</th>
<th>제목</th>
<th>등록일시</th>
<th>등록자</th>
<th>조회수</th>
<th>관리</th>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">대표이미지</th>
<th class="px-3 py-3">제목</th>
<th class="px-3 py-3">등록일시</th>
<th class="px-3 py-3">등록자</th>
<th class="px-3 py-3">조회수</th>
<th class="px-3 py-3">관리</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${portfolioItems}">
<td style="width: 140px;">
<img class="img-fluid rounded" th:src="${item.imageUrl}" th:alt="${item.title}">
<tr class="border-b border-gray-100 align-top last:border-0"
th:each="item : ${portfolioItems}">
<td class="px-3 py-4" style="width: 160px;">
<img class="h-24 w-36 rounded-xl object-cover"
th:src="${item.imageUrl}"
th:alt="${item.title}">
</td>
<td>
<div class="fw-semibold mb-1" th:text="${item.title}">제목</div>
<div class="small text-secondary" th:text="${item.summary}">요약</div>
<a class="small" th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}" target="_blank">공개 페이지 보기</a>
<td class="px-3 py-4">
<div class="font-semibold text-gray-900" th:text="${item.title}">제목</div>
<div class="mt-2 text-sm leading-6 text-gray-500" th:text="${item.summary}">요약</div>
<a class="mt-3 inline-flex items-center gap-2 text-sm font-medium text-brand-500 hover:text-brand-600"
th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}"
target="_blank">
공개 페이지 보기
</a>
</td>
<td th:text="${#temporals.format(item.registeredAt, 'yyyy-MM-dd HH:mm')}">2026-04-16 09:00</td>
<td th:text="${item.registeredByName}">관리자</td>
<td th:text="${item.viewCount}">0</td>
<td>
<div class="d-flex flex-column gap-2">
<a class="btn btn-sm btn-outline-primary"
<td class="px-3 py-4 text-gray-500"
th:text="${#temporals.format(item.registeredAt, 'yyyy-MM-dd HH:mm')}">2026-04-17 09:00</td>
<td class="px-3 py-4 text-gray-500" th:text="${item.registeredByName}">관리자</td>
<td class="px-3 py-4 text-gray-500" th:text="${item.viewCount}">0</td>
<td class="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/portfolio/form.do(portfolioIdntfNo=${item.portfolioIdntfNo})}">
수정
</a>
<form method="post" th:action="@{/mfuc/portfolio/delete.do}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="portfolioIdntfNo" th:value="${item.portfolioIdntfNo}">
<button class="btn btn-sm btn-outline-danger" type="submit"
<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>
@@ -79,7 +87,7 @@
</table>
</div>
</div>
</div>
</section>
</div>
</body>
</html>
@@ -0,0 +1,176 @@
<!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="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="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="px-6 py-5">
<h2 class="text-base font-medium text-gray-800">사용자 목록</h2>
<p class="mt-1 text-sm text-gray-500">현재 등록된 사용자 계정을 검색하고 수정 화면으로 이동할 수 있습니다.</p>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<form class="mb-5 flex flex-col gap-3 md:flex-row md:items-end" method="get" th:action="@{/mfuc/user/list.do}">
<div class="flex-1">
<label class="mb-2 block text-sm font-medium text-gray-700" for="keyword">검색</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="keyword"
name="keyword"
th:value="${keyword}"
placeholder="아이디, 이름, 이메일 검색">
</div>
<button class="inline-flex items-center justify-center 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 justify-center 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/user/list.do}">
신규 등록
</a>
</form>
<div th:if="${#lists.isEmpty(users)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
조회된 사용자가 없습니다.
</div>
<div th:unless="${#lists.isEmpty(users)}" class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-2 py-3">아이디</th>
<th class="px-2 py-3">이름</th>
<th class="px-2 py-3">이메일</th>
<th class="px-2 py-3">유형</th>
<th class="px-2 py-3">상태</th>
<th class="px-2 py-3">가입일</th>
<th class="px-2 py-3">관리</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 last:border-0"
th:each="user : ${users}">
<td class="px-2 py-3 font-medium text-gray-900" th:text="${user.userId}">admin</td>
<td class="px-2 py-3 text-gray-500" th:text="${user.userNm}">관리자</td>
<td class="px-2 py-3 text-gray-500" th:text="${user.emlAddr}">admin@example.com</td>
<td class="px-2 py-3 text-gray-500" th:text="${user.userTypeCd}">ADM</td>
<td class="px-2 py-3">
<span class="inline-flex rounded-full px-2.5 py-1 text-xs font-medium"
th:classappend="${user.userSttsCd == 'A' or user.userSttsCd == 'P'} ? ' bg-success-50 text-success-700' : ' bg-error-50 text-error-700'"
th:text="${user.userSttsCd == 'A' ? '사용중' : (user.userSttsCd == 'P' ? '대기' : '비활성')}">사용중</span>
</td>
<td class="px-2 py-3 text-gray-500"
th:text="${user.joinDt != null ? #temporals.format(user.joinDt, 'yyyy-MM-dd HH:mm') : '-'}">2026-04-17 09:00</td>
<td class="px-2 py-3">
<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/user/list.do(userIdntfNo=${user.userIdntfNo},keyword=${keyword})}">
수정
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="px-6 py-5">
<h2 class="text-base font-medium text-gray-800"
th:text="${form.userIdntfNo != null and !#strings.isEmpty(form.userIdntfNo) ? '사용자 수정' : '신규 사용자 등록'}">신규 사용자 등록</h2>
<p class="mt-1 text-sm text-gray-500">관리자 콘솔에서 사용할 계정과 일반 사용자 계정을 함께 등록할 수 있습니다.</p>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<form class="space-y-5" method="post" th:action="@{/mfuc/user/save.do}" th:object="${form}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" th:field="*{userIdntfNo}">
<input type="hidden" name="keyword" th:value="${keyword}">
<div class="text-sm text-error-600" th:if="${#fields.hasGlobalErrors()}">
<span th:each="error : ${#fields.globalErrors()}" th:text="${error}">오류</span>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="userId">아이디</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="userId"
th:field="*{userId}"
th:readonly="${form.userIdntfNo != null and !#strings.isEmpty(form.userIdntfNo)}"
placeholder="로그인 아이디">
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('userId')}" th:errors="*{userId}">아이디 오류</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="userNm">이름</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="userNm"
th:field="*{userNm}"
placeholder="사용자 이름">
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('userNm')}" th:errors="*{userNm}">이름 오류</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="emlAddr">이메일</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="emlAddr"
th:field="*{emlAddr}"
placeholder="user@example.com">
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('emlAddr')}" th:errors="*{emlAddr}">이메일 오류</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="userTypeCd">유형</label>
<select 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="userTypeCd"
th:field="*{userTypeCd}">
<option value="USR">일반 사용자</option>
<option value="ADM">관리자</option>
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="userSttsCd">상태</label>
<select 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="userSttsCd"
th:field="*{userSttsCd}">
<option value="A">사용중</option>
<option value="P">대기</option>
<option value="I">비활성</option>
</select>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="password">
비밀번호
<span th:text="${form.userIdntfNo != null and !#strings.isEmpty(form.userIdntfNo) ? '(변경 시에만 입력)' : ''}"></span>
</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="password"
th:field="*{password}"
type="password"
th:placeholder="${form.userIdntfNo != null and !#strings.isEmpty(form.userIdntfNo) ? '새 비밀번호를 입력할 때만 변경됩니다.' : '초기 비밀번호'}">
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('password')}" th:errors="*{password}">비밀번호 오류</div>
</div>
<div class="flex flex-wrap gap-3">
<button class="inline-flex items-center justify-center 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 justify-center 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/user/list.do(keyword=${keyword})}">
초기화
</a>
</div>
</form>
</div>
</section>
</div>
</div>
</body>
</html>
@@ -1,9 +1,7 @@
<!doctype html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
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="content">
@@ -26,13 +24,15 @@
<div class="row g-3">
<div class="col-md-6">
<div class="form-floating">
<input type="text" class="form-control" id="name" name="name" placeholder="Your Name">
<input type="text" class="form-control" id="name" name="name"
placeholder="Your Name">
<label for="name">Name</label>
</div>
</div>
<div class="col-md-6">
<div class="form-floating">
<input type="email" class="form-control" id="email" name="email" placeholder="Your Email" >
<input type="email" class="form-control" id="email" name="email"
placeholder="Your Email">
<label for="email">Email</label>
</div>
<div id="emailError" style="color:red; font-size:13px; display:none;">
@@ -41,7 +41,8 @@
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control" id="phone" name="phone" placeholder="Phone" >
<input type="text" class="form-control" id="phone" name="phone"
placeholder="Phone">
<label for="subject">Phone</label>
</div>
<div id="phoneError" style="color:red; font-size:13px; display:none;">
@@ -50,18 +51,21 @@
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control" id="subject" name="subject" placeholder="Subject">
<input type="text" class="form-control" id="subject" name="subject"
placeholder="Subject">
<label for="subject">Subject</label>
</div>
</div>
<div class="col-12">
<div class="form-floating">
<textarea class="form-control" placeholder="Leave a message here" id="message" name="message" style="height: 100px"></textarea>
<textarea class="form-control" placeholder="Leave a message here" id="message"
name="message" style="height: 100px"></textarea>
<label for="message">Message</label>
</div>
</div>
<div class="col-12">
<button id="btnSendMail" class="btn btn-primary rounded-pill py-3 px-5" type="submit">Send Message</button>
<button id="btnSendMail" class="btn btn-primary rounded-pill py-3 px-5"
type="submit">Send Message</button>
</div>
</div>
</form>
@@ -106,10 +110,11 @@
});
}
$(document).ready(function(){
$(document).ready(function () {
initMap();
});
</script>
<script th:src="@{/resources/kr/iotdoor/boot/contact/contact.js}"></script>
</th:block>
</html>
@@ -14,7 +14,7 @@
</div>
<article class="bg-white rounded shadow-sm overflow-hidden">
<img class="img-fluid w-100" th:src="${portfolio.imageUrl}" th:alt="${portfolio.title}">
<!-- <img class="img-fluid w-100" th:src="${portfolio.imageUrl}" th:alt="${portfolio.title}"> -->
<div class="p-4 p-lg-5">
<div class="d-flex flex-wrap gap-3 text-secondary small mb-3">
<span th:text="${#temporals.format(portfolio.registeredAt, 'yyyy-MM-dd HH:mm')}">2026-04-16 09:00</span>
@@ -22,14 +22,11 @@
<div class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.1s"
th:each="item : ${portfolioItems}">
<div class="service-item rounded overflow-hidden h-100">
<div class="service-item rounded overflow-hidden h-100 align-content-center">
<a th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}">
<img class="img-fluid" th:src="${item.imageUrl}" th:alt="${item.title}">
</a>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-tools fa-3x"></i>
</div>
<div class="d-flex justify-content-between align-items-center mb-2 small text-secondary">
<span th:text="${#temporals.format(item.registeredAt, 'yyyy-MM-dd')}">2026-04-16</span>
<span>조회 <span th:text="${item.viewCount}">0</span></span>
@@ -0,0 +1,57 @@
package kr.iotdoor.boot.web;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class AdminPageControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void dashboardRendersWithNewAdminLayout() throws Exception {
mockMvc.perform(get("/mfuc/index.do"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Admin Menu")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("최근 문의")));
}
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void userManagementPageRenders() throws Exception {
mockMvc.perform(get("/mfuc/user/list.do"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("신규 사용자 등록")));
}
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void portfolioManagementPagesRender() throws Exception {
mockMvc.perform(get("/mfuc/portfolio/list.do"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("포트폴리오 관리")));
mockMvc.perform(get("/mfuc/portfolio/form.do"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("대표 이미지 지정")));
}
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void contactManagementPageRenders() throws Exception {
mockMvc.perform(get("/mfuc/contact/list.do"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("문의 접수 목록")));
}
}