This commit is contained in:
CSKIM
2026-05-07 09:44:53 +09:00
parent ed16fdafe6
commit 348288caeb
607 changed files with 7618 additions and 248 deletions
+1
View File
@@ -42,6 +42,7 @@ spring:
sql:
init:
mode: always
encoding: UTF-8
jackson:
time-zone: Asia/Seoul
mvc:
+187
View File
@@ -66,6 +66,193 @@ CREATE TABLE IF NOT EXISTS TN_CONTACT_INQRY (
PRIMARY KEY (CONTACT_IDNTF_NO)
);
CREATE TABLE IF NOT EXISTS TN_ACCESS_LOG (
ACCESS_LOG_IDNTF_NO VARCHAR(32) NOT NULL,
SESSION_ID VARCHAR(128) NULL,
REFERER_URL VARCHAR(1000) NULL,
ACCESS_IP VARCHAR(45) NULL,
HTTP_METHOD VARCHAR(10) NOT NULL,
REQUEST_URI VARCHAR(500) NOT NULL,
QUERY_STRING VARCHAR(1000) NULL,
USER_AGENT VARCHAR(1000) NULL,
STATUS_CD INT NULL,
REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (ACCESS_LOG_IDNTF_NO)
);
CREATE TABLE IF NOT EXISTS TN_MAIN_BANNER (
BANNER_IDNTF_NO VARCHAR(32) NOT NULL,
TTL VARCHAR(200) NOT NULL,
CN TEXT NULL,
IMAGE_ATCH_FILE_IDNTF_NO VARCHAR(32) NULL,
IMAGE_FILE_SN INT NULL,
IMAGE_URL VARCHAR(500) NULL,
SORT_ORDR INT NOT NULL DEFAULT 0,
USE_YN CHAR(1) NOT NULL DEFAULT 'Y',
DEL_YN CHAR(1) NOT NULL DEFAULT 'N',
REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UPD_DT DATETIME NULL,
PRIMARY KEY (BANNER_IDNTF_NO),
CONSTRAINT FK_TN_MAIN_BANNER__IMAGE FOREIGN KEY (IMAGE_ATCH_FILE_IDNTF_NO)
REFERENCES TN_ATCH_FILE (ATCH_FILE_IDNTF_NO)
);
CREATE INDEX IF NOT EXISTS IDX_TN_ATCH_FILE_DTL__PATH ON TN_ATCH_FILE_DTL (FILE_STRG_PATH_NM);
CREATE INDEX IF NOT EXISTS IDX_TN_PORTFOLIO__REG_DT ON TN_PORTFOLIO (REG_DT);
CREATE INDEX IF NOT EXISTS IDX_TN_CONTACT_INQRY__SUBMIT_DT ON TN_CONTACT_INQRY (SUBMIT_DT);
CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__REG_DT ON TN_ACCESS_LOG (REG_DT);
CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__ACCESS_IP ON TN_ACCESS_LOG (ACCESS_IP);
CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__SESSION_ID ON TN_ACCESS_LOG (SESSION_ID);
CREATE INDEX IF NOT EXISTS IDX_TN_ACCESS_LOG__REQUEST_URI ON TN_ACCESS_LOG (REQUEST_URI);
CREATE INDEX IF NOT EXISTS IDX_TN_MAIN_BANNER__SORT ON TN_MAIN_BANNER (SORT_ORDR);
CREATE INDEX IF NOT EXISTS IDX_TN_MAIN_BANNER__USE_DEL ON TN_MAIN_BANNER (USE_YN, DEL_YN);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000001',
'슬라이딩자동문',
'슬라이드 오토도어를 현장 조건에 맞춰 시공합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-1.jpg',
10,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000001'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000002',
'산업용자동문',
'공장, 물류 현장에 필요한 산업용 자동문을 제공합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-2.jpg',
20,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000002'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000003',
'고급산업용자동문',
'정밀 제어와 안정성이 필요한 고급 산업 현장에 대응합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-3.jpg',
30,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000003'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000004',
'내풍압고속셔터',
'바람과 사용 빈도가 높은 공간에 적합한 고속 셔터를 시공합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-4.jpg',
40,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000004'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000005',
'오버헤드도어',
'창고와 산업 시설에 맞는 오버헤드도어를 설치합니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-5.jpg?1',
50,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000005'
);
INSERT INTO TN_MAIN_BANNER (
BANNER_IDNTF_NO, TTL, CN, IMAGE_URL, SORT_ORDR, USE_YN, DEL_YN, REG_DT
)
SELECT
'00000000000000000000000000000006',
'방화용슬라이딩자동문',
'방화 성능이 필요한 출입 공간에 맞춘 자동문 솔루션입니다.',
'/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-6.jpg?1?1',
60,
'Y',
'N',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM TN_MAIN_BANNER WHERE BANNER_IDNTF_NO = '00000000000000000000000000000006'
);
UPDATE TN_MAIN_BANNER
SET
TTL = '슬라이딩자동문',
CN = '슬라이드 오토도어를 현장 조건에 맞춰 시공합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-1.jpg'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000001'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '산업용자동문',
CN = '공장, 물류 현장에 필요한 산업용 자동문을 제공합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-2.jpg'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000002'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '고급산업용자동문',
CN = '정밀 제어와 안정성이 필요한 고급 산업 현장에 대응합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-3.jpg'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000003'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '내풍압고속셔터',
CN = '바람과 사용 빈도가 높은 공간에 적합한 고속 셔터를 시공합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-4.jpg'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000004'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '오버헤드도어',
CN = '창고와 산업 시설에 맞는 오버헤드도어를 설치합니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-5.jpg?1'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000005'
AND (TTL LIKE '%%' OR CN LIKE '%%');
UPDATE TN_MAIN_BANNER
SET
TTL = '방화용슬라이딩자동문',
CN = '방화 성능이 필요한 출입 공간에 맞춘 자동문 솔루션입니다.',
IMAGE_URL = COALESCE(IMAGE_URL, '/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-6.jpg?1?1'),
UPD_DT = CURRENT_TIMESTAMP
WHERE BANNER_IDNTF_NO = '00000000000000000000000000000006'
AND (TTL LIKE '%%' OR CN LIKE '%%');
@@ -3,10 +3,15 @@ body {
overflow-x: hidden;
}
:root {
--iotdoor-admin-header-height: 104px;
}
.iotdoor-admin-shell {
min-height: 100vh;
width: 100%;
overflow-x: hidden;
padding-top: var(--iotdoor-admin-header-height);
}
.iotdoor-admin-sidebar {
@@ -14,6 +19,18 @@ body {
width: 100%;
}
.iotdoor-admin-header {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 9999;
min-height: var(--iotdoor-admin-header-height);
background: #ffffff;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
isolation: isolate;
}
.iotdoor-admin-main {
width: 100%;
max-width: 100%;
@@ -21,6 +38,10 @@ body {
}
@media (min-width: 1280px) {
.iotdoor-admin-shell {
padding-top: 0;
}
.iotdoor-admin-sidebar {
position: fixed;
top: 0;
@@ -33,6 +54,11 @@ body {
.iotdoor-admin-main {
margin-left: 290px;
width: calc(100% - 290px);
padding-top: var(--iotdoor-admin-header-height);
}
.iotdoor-admin-header {
left: 290px;
}
}
@@ -63,10 +89,23 @@ body {
}
.iotdoor-editor-surface {
position: relative;
border: 1px solid #d0d5dd;
border-radius: 1rem;
background: #ffffff;
overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.iotdoor-editor-surface.is-dragging-image {
border-color: #465fff;
background: #f8fbff;
box-shadow: 0 0 0 4px rgba(70, 95, 255, 0.14);
}
.iotdoor-editor-surface.is-uploading-image,
.iotdoor-editor-surface.is-uploading-image .ProseMirror {
cursor: progress;
}
.iotdoor-editor-surface .ProseMirror {
@@ -121,33 +160,159 @@ body {
}
.iotdoor-editor-image-node {
display: table;
max-width: 100%;
position: relative;
margin: 1.25rem 0;
}
.iotdoor-editor-image-node.is-representative::before {
content: "";
content: "✓ 대표";
position: absolute;
inset: 0;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(70, 95, 255, 0.18), rgba(22, 25, 80, 0.3));
top: 0.75rem;
left: 0.75rem;
z-index: 1;
padding: 0.45rem 0.7rem;
border-radius: 0.25rem;
background: #03c75a;
color: #ffffff;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0;
pointer-events: none;
}
.iotdoor-editor-image-node.is-representative::after {
content: "대표 이미지";
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.5rem 0.875rem;
border-radius: 999px;
background: rgba(16, 24, 40, 0.85);
color: #ffffff;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
content: none;
}
.iotdoor-editor-image-node.ProseMirror-selectednode img {
box-shadow: 0 0 0 4px rgba(70, 95, 255, 0.18);
}
.iotdoor-access-log-table {
table-layout: fixed;
width: 100%;
min-width: 1180px;
}
.iotdoor-access-log-table th,
.iotdoor-access-log-table td {
vertical-align: top;
}
.iotdoor-access-log-table .access-log-col-time {
width: 160px;
}
.iotdoor-access-log-table .access-log-col-ip {
width: 120px;
}
.iotdoor-access-log-table .access-log-col-request {
width: 150px;
}
.iotdoor-access-log-table .access-log-col-referer {
width: 260px;
}
.iotdoor-access-log-table .access-log-col-session {
width: 190px;
}
.iotdoor-access-log-table .access-log-col-status {
width: 70px;
}
.iotdoor-access-log-table .access-log-col-agent {
width: 230px;
}
.iotdoor-access-log-text {
display: block;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
line-height: 1.55;
}
.iotdoor-access-log-mono {
font-family: Consolas, "Courier New", monospace;
font-size: 0.8125rem;
}
.iotdoor-banner-table {
width: 100%;
min-width: 980px;
}
.iotdoor-banner-list-content {
max-width: 520px;
}
.iotdoor-banner-form-grid {
display: grid;
gap: 1.5rem;
}
.iotdoor-portfolio-table {
table-layout: fixed;
width: 100%;
min-width: 1040px;
}
.iotdoor-portfolio-table th,
.iotdoor-portfolio-table td {
vertical-align: top;
}
.iotdoor-portfolio-table .portfolio-col-image {
width: 170px;
}
.iotdoor-portfolio-table .portfolio-col-title {
width: auto;
}
.iotdoor-portfolio-table .portfolio-col-date {
width: 160px;
}
.iotdoor-portfolio-table .portfolio-col-author {
width: 100px;
}
.iotdoor-portfolio-table .portfolio-col-view {
width: 80px;
}
.iotdoor-portfolio-table .portfolio-col-action {
width: 92px;
}
.iotdoor-portfolio-table th {
white-space: nowrap;
}
.iotdoor-portfolio-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 64px;
}
.iotdoor-portfolio-actions a,
.iotdoor-portfolio-actions button {
min-width: 64px;
white-space: nowrap;
}
@media (min-width: 1280px) {
.iotdoor-banner-form-grid {
grid-template-columns: 360px 1fr;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,344 @@
.portfolio-post-list-panel {
background: #ffffff;
color: #202124;
}
.portfolio-post-list-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0 0 14px;
}
.portfolio-post-list-summary {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
font-size: 0.95rem;
}
.portfolio-post-list-summary a {
color: #111111;
font-weight: 500;
text-decoration: none;
}
.portfolio-post-list-summary span {
color: #333333;
}
.portfolio-post-list-toggle {
border: 0;
background: transparent;
color: #111111;
font-size: 0.95rem;
font-weight: 500;
line-height: 1;
padding: 0;
}
.portfolio-post-list-toggle:hover,
.portfolio-post-list-toggle:focus-visible {
color: #1f5aa0;
}
.portfolio-post-list-body {
border-top: 1px solid #d5d9df;
}
.portfolio-post-list-grid {
width: 100%;
height: auto;
min-height: 0;
}
.portfolio-post-list-grid.ag-theme-quartz {
--ag-font-family: "Noto Sans KR", "Helvetica Neue", Helvetica, Arial, sans-serif;
--ag-font-size: 14px;
--ag-foreground-color: #111111;
--ag-secondary-foreground-color: #94979d;
--ag-header-background-color: #ffffff;
--ag-header-foreground-color: #8c9097;
--ag-row-hover-color: #fafafa;
--ag-selected-row-background-color: #ffffff;
--ag-border-color: transparent;
--ag-row-border-color: #e6e6e6;
--ag-wrapper-border-radius: 0;
--ag-cell-horizontal-padding: 10px;
}
.portfolio-post-list-grid .ag-root-wrapper,
.portfolio-post-list-grid .ag-root,
.portfolio-post-list-grid .ag-body-viewport,
.portfolio-post-list-grid .ag-center-cols-viewport {
border: 0;
border-radius: 0;
}
.portfolio-post-list-grid .ag-header {
border-bottom: 1px solid #9da3ad;
}
.portfolio-post-list-grid .ag-header-cell {
padding-inline: 10px;
}
.portfolio-post-list-grid .ag-header-cell-label {
font-weight: 400;
}
.portfolio-post-number-header .ag-header-cell-label {
justify-content: flex-end;
}
.portfolio-post-date-header .ag-header-cell-label {
justify-content: center;
}
.portfolio-post-list-grid .ag-header-cell-resize,
.portfolio-post-list-grid .ag-sort-indicator-container {
display: none;
}
.portfolio-post-list-grid .ag-row {
border-bottom: 1px solid #e6e6e6;
}
.portfolio-post-list-grid .ag-cell {
display: flex;
align-items: center;
border: 0;
padding-inline: 10px;
}
.portfolio-post-list-grid .ag-row-current-post {
font-weight: 700;
}
.portfolio-post-number-cell {
justify-content: flex-end;
}
.portfolio-post-date-cell {
justify-content: center;
color: #111111;
}
.portfolio-post-title-link {
display: inline-block;
max-width: 100%;
overflow: hidden;
color: #111111;
text-decoration: none;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
.portfolio-post-title-link:hover {
color: #111111;
text-decoration: underline;
}
.portfolio-post-list-footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: start;
gap: 1rem;
padding-top: 12px;
}
.portfolio-post-list-footer-side {
min-height: 32px;
}
.portfolio-post-page-size-wrap {
display: flex;
justify-content: flex-end;
}
.portfolio-post-manage-link {
display: inline-flex;
align-items: center;
min-height: 32px;
border: 1px solid #d8d8d8;
background: #ffffff;
color: #333333;
padding: 0.35rem 0.85rem;
font-size: 0.875rem;
text-decoration: none;
}
.portfolio-post-manage-link:hover {
border-color: #aaaaaa;
color: #111111;
}
.portfolio-post-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 32px;
}
.portfolio-post-page-button {
min-width: 28px;
height: 28px;
border: 0;
background: transparent;
color: #111111;
font-size: 0.875rem;
line-height: 1;
padding: 0 0.35rem;
}
.portfolio-post-page-button:hover,
.portfolio-post-page-button:focus-visible {
color: #1f5aa0;
}
.portfolio-post-page-button.is-active {
border: 1px solid #dddddd;
background: #ffffff;
font-weight: 700;
}
.portfolio-post-page-size {
min-width: 106px;
height: 32px;
border: 1px solid #d8d8d8;
background: #ffffff;
color: #111111;
font-size: 0.875rem;
padding: 0 2rem 0 0.75rem;
}
@media (max-width: 575.98px) {
.portfolio-post-list-bar {
align-items: flex-start;
flex-direction: column;
}
.portfolio-post-list-toggle {
align-self: flex-end;
}
.portfolio-post-list-footer {
grid-template-columns: 1fr;
}
.portfolio-post-page-size-wrap {
justify-content: flex-start;
}
}
.portfolio-frame-card {
display: flex;
flex-direction: column;
width: 100%;
height: 520px;
overflow: hidden;
border-radius: 0.25rem;
background: #ffffff;
box-shadow: 0 0 45px rgba(0, 0, 0, 0.08);
color: inherit;
cursor: pointer;
text-decoration: none;
}
.portfolio-frame-card:hover {
color: inherit;
text-decoration: none;
}
.portfolio-frame-card:focus-visible {
outline: 3px solid rgba(31, 90, 160, 0.35);
outline-offset: 3px;
}
.portfolio-frame-image-link {
display: block;
flex: 0 0 285px;
width: 100%;
overflow: hidden;
background: #f8f9fa;
text-decoration: none;
}
.portfolio-frame-image {
display: block;
width: 100%;
height: 285px;
object-fit: cover;
transition: transform 0.35s ease;
}
.portfolio-frame-card:hover .portfolio-frame-image,
.portfolio-frame-card:focus-visible .portfolio-frame-image {
transform: scale(1.1);
}
.portfolio-frame-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: flex-end;
min-height: 0;
padding: 1.5rem;
}
.portfolio-frame-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
color: #667085;
font-size: 0.875rem;
}
.portfolio-frame-title {
display: -webkit-box;
min-height: 3.1rem;
margin: 0 0 0.9rem;
overflow: hidden;
color: #101828;
font-size: 1.25rem;
font-weight: 600;
line-height: 1.25;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.portfolio-frame-card:hover .portfolio-frame-title,
.portfolio-frame-card:focus-visible .portfolio-frame-title {
color: #1f5aa0;
}
.portfolio-frame-summary {
display: -webkit-box;
min-height: 5.4rem;
margin: 0;
overflow: hidden;
color: #667085;
font-size: 1rem;
line-height: 1.8;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
@media (max-width: 767.98px) {
.portfolio-frame-card {
height: 500px;
}
.portfolio-frame-image-link {
flex-basis: 265px;
}
.portfolio-frame-image {
height: 265px;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13,4 +13,90 @@
margin: auto 0;
border-radius: 20px;
box-shadow: 0 5px 6px rgba(0, 0, 0, .1019607843);
}
}
.portfolio-content {
color: #1d2939;
font-size: 1rem;
line-height: 1.8;
}
.portfolio-content p {
margin: 0 0 1rem;
}
.portfolio-content h1,
.portfolio-content h2,
.portfolio-content h3 {
color: #101828;
font-weight: 700;
margin: 1.5rem 0 1rem;
}
.portfolio-content h1 {
font-size: 2rem;
}
.portfolio-content h2 {
font-size: 1.5rem;
}
.portfolio-content h3 {
font-size: 1.25rem;
}
.portfolio-content blockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid #9cb9ff;
background: #f9fafb;
color: #475467;
}
.portfolio-content ul,
.portfolio-content ol {
padding-left: 1.5rem;
}
.portfolio-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1.25rem 0;
border-radius: 1rem;
}
.main-service-card {
height: 100%;
display: flex;
flex-direction: column;
}
.main-service-card-image {
width: 100%;
aspect-ratio: 3 / 2;
height: auto;
object-fit: cover;
}
.main-service-card-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 210px;
}
.main-service-card-body h4 {
word-break: keep-all;
}
.main-service-card-content {
flex: 1;
margin-bottom: 1rem;
line-height: 1.7;
word-break: keep-all;
}
.main-service-card-link {
margin-top: auto;
}
@@ -10,6 +10,25 @@
}, 1);
};
spinner();
// Mobile navbar fallback
$(document).on('click', '.navbar-toggler[data-bs-target]', function () {
if (window.bootstrap && window.bootstrap.Collapse && window.bootstrap.Collapse.DATA_KEY === 'bs.collapse') {
return;
}
var targetSelector = $(this).attr('data-bs-target');
var $target = targetSelector ? $(targetSelector) : $();
if (!$target.length) {
return;
}
var isOpen = $target.hasClass('show');
$target.toggleClass('show', !isOpen);
$(this).attr('aria-expanded', String(!isOpen));
});
// Initiate the wowjs
@@ -95,4 +114,3 @@
});
})(jQuery);
@@ -0,0 +1,181 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/boot/admin/layout/adminLayout}">
<body>
<div layout:fragment="content" class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-gray-800"
th:text="${form.bannerIdntfNo != null and !#strings.isEmpty(form.bannerIdntfNo) ? '배너 수정' : '배너 등록'}">배너 등록</h2>
<p class="mt-1 text-sm text-gray-500">메인 서비스 영역에 노출할 이미지, 제목, 내용을 입력합니다.</p>
</div>
<a class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
th:href="@{/mfuc/banner/list.do}">
목록으로
</a>
</div>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="border-b border-gray-100 px-6 py-5">
<h3 class="text-base font-medium text-gray-800">배너 정보</h3>
</div>
<div class="p-4 sm:p-6">
<form id="mainBannerForm"
method="post"
th:action="@{/mfuc/banner/save.do}"
th:object="${form}"
th:attr="data-csrf-header=${_csrf.headerName},
data-csrf-token=${_csrf.token},
data-current-image-url=${currentImageUrl}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" th:field="*{bannerIdntfNo}">
<input type="hidden" th:field="*{imageAtchFileIdntfNo}" id="imageAtchFileIdntfNo">
<input type="hidden" th:field="*{imageFileSn}" id="imageFileSn">
<input type="hidden" th:field="*{imageUrl}" id="imageUrl">
<div class="iotdoor-banner-form-grid">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700">배너 이미지</label>
<div class="rounded-2xl border border-gray-200 bg-gray-50 p-4">
<img id="bannerImagePreview"
class="aspect-4/3 w-full rounded-xl object-cover"
th:src="${currentImageUrl}"
alt="배너 이미지 미리보기">
<input id="bannerImageFile" type="file" accept="image/*" hidden>
<button id="bannerImageUploadButton"
class="mt-4 inline-flex w-full items-center justify-center rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600"
type="button">
이미지 업로드
</button>
</div>
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('imageAtchFileIdntfNo')}" th:errors="*{imageAtchFileIdntfNo}">이미지 오류</div>
</div>
<div class="space-y-6">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="title">제목</label>
<input class="h-11 w-full rounded-lg border border-gray-300 bg-white px-4 text-sm text-gray-800 outline-none transition focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10"
id="title"
th:field="*{title}"
placeholder="배너 제목을 입력해주세요.">
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">제목 오류</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="content">내용</label>
<textarea class="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm leading-6 text-gray-800 outline-none transition focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10"
style="min-height: 160px;"
id="content"
th:field="*{content}"
placeholder="배너 내용을 입력해주세요."></textarea>
<div class="mt-2 text-sm text-error-600" th:if="${#fields.hasErrors('content')}" th:errors="*{content}">내용 오류</div>
</div>
<div>
<label class="inline-flex items-center gap-3 text-sm font-medium text-gray-700">
<input class="size-5 rounded border-gray-300 text-brand-500"
type="checkbox"
th:field="*{useYn}"
value="Y">
메인에 노출
</label>
</div>
<div class="text-sm text-error-600" th:if="${#fields.hasGlobalErrors()}">
<span th:each="error : ${#fields.globalErrors()}" th:text="${error}">오류 메시지</span>
</div>
<div class="flex flex-wrap gap-3">
<button class="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600"
type="submit">
저장
</button>
<a class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
th:href="@{/mfuc/banner/list.do}">
취소
</a>
</div>
</div>
</div>
</form>
</div>
</section>
</div>
<th:block layout:fragment="pageScripts">
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('mainBannerForm');
const uploadButton = document.getElementById('bannerImageUploadButton');
const fileInput = document.getElementById('bannerImageFile');
const preview = document.getElementById('bannerImagePreview');
const atchFileIdInput = document.getElementById('imageAtchFileIdntfNo');
const fileSnInput = document.getElementById('imageFileSn');
const imageUrlInput = document.getElementById('imageUrl');
if (!form || !uploadButton || !fileInput || !preview || !atchFileIdInput || !fileSnInput || !imageUrlInput) {
return;
}
uploadButton.addEventListener('click', function () {
fileInput.click();
});
fileInput.addEventListener('change', async function () {
if (!fileInput.files || fileInput.files.length === 0) {
return;
}
const file = fileInput.files[0];
if (!file.type.startsWith('image/')) {
window.alert('이미지 파일만 업로드할 수 있습니다.');
fileInput.value = '';
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('directory', 'banner');
formData.append('description', 'main-banner-image');
const headers = {};
const csrfHeader = form.dataset.csrfHeader;
const csrfToken = form.dataset.csrfToken;
if (csrfHeader && csrfToken) {
headers[csrfHeader] = csrfToken;
}
uploadButton.disabled = true;
uploadButton.textContent = '업로드 중';
try {
const response = await fetch('/mfuc/file/upload.do', {
method: 'POST',
headers,
body: formData
});
if (!response.ok) {
throw new Error('이미지 업로드에 실패했습니다.');
}
const result = await response.json();
atchFileIdInput.value = result.atchFileIdntfNo || '';
fileSnInput.value = result.fileSn || '';
imageUrlInput.value = result.url || '';
preview.src = result.url;
} catch (error) {
window.alert(error.message || '이미지 업로드에 실패했습니다.');
} finally {
uploadButton.disabled = false;
uploadButton.textContent = '이미지 업로드';
fileInput.value = '';
}
});
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/boot/admin/layout/adminLayout}">
<body>
<div layout:fragment="content" class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-gray-800">배너관리</h2>
<p class="mt-1 text-sm text-gray-500">메인 서비스 배너의 노출 순서와 내용을 관리합니다.</p>
</div>
<a class="inline-flex items-center gap-2 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600"
th:href="@{/mfuc/banner/form.do}">
새 배너 등록
</a>
</div>
<div class="rounded-xl border border-success-100 bg-success-50 px-4 py-3 text-sm text-success-700" th:if="${param.saved}">
배너가 저장되었습니다.
</div>
<div class="rounded-xl border border-success-100 bg-success-50 px-4 py-3 text-sm text-success-700" th:if="${param.deleted}">
배너가 삭제되었습니다.
</div>
<div class="rounded-xl border border-success-100 bg-success-50 px-4 py-3 text-sm text-success-700" th:if="${param.ordered}">
배너 순서가 변경되었습니다.
</div>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="border-b border-gray-100 px-6 py-5">
<h3 class="text-base font-medium text-gray-800">등록 목록</h3>
<p class="mt-1 text-sm text-gray-500">위/아래 버튼으로 메인 노출 순서를 변경할 수 있습니다.</p>
</div>
<div class="p-4 sm:p-6">
<div th:if="${#lists.isEmpty(banners)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
아직 등록된 배너가 없습니다.
</div>
<div th:unless="${#lists.isEmpty(banners)}" class="overflow-x-auto">
<table class="iotdoor-banner-table text-sm">
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">순서</th>
<th class="px-3 py-3">이미지</th>
<th class="px-3 py-3">제목/내용</th>
<th class="px-3 py-3">노출</th>
<th class="px-3 py-3">등록일시</th>
<th class="px-3 py-3">관리</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 align-top last:border-0"
th:each="banner, stat : ${banners}">
<td class="px-3 py-4" style="width: 92px;">
<div class="flex flex-col gap-2">
<form method="post" th:action="@{/mfuc/banner/move.do}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="bannerIdntfNo" th:value="${banner.bannerIdntfNo}">
<input type="hidden" name="direction" value="up">
<button class="inline-flex w-full items-center justify-center rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
type="submit"
th:disabled="${stat.first}">
</button>
</form>
<form method="post" th:action="@{/mfuc/banner/move.do}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="bannerIdntfNo" th:value="${banner.bannerIdntfNo}">
<input type="hidden" name="direction" value="down">
<button class="inline-flex w-full items-center justify-center rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
type="submit"
th:disabled="${stat.last}">
아래
</button>
</form>
</div>
</td>
<td class="px-3 py-4" style="width: 190px;">
<img class="h-28 w-40 rounded-xl object-cover"
th:src="${banner.imageUrl}"
th:alt="${banner.title}">
</td>
<td class="px-3 py-4">
<div class="font-semibold text-gray-900" th:text="${banner.title}">배너 제목</div>
<p class="iotdoor-banner-list-content mt-2 text-sm leading-6 text-gray-500" th:text="${banner.content}">배너 내용</p>
</td>
<td class="px-3 py-4">
<span class="inline-flex rounded-full px-3 py-1 text-xs font-medium"
th:classappend="${banner.useYn == 'Y'} ? ' bg-success-50 text-success-700' : ' bg-gray-100 text-gray-500'"
th:text="${banner.useYn == 'Y'} ? '노출' : '미노출'">노출</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-gray-500"
th:text="${#temporals.format(banner.registeredAt, 'yyyy-MM-dd HH:mm')}">2026-04-21 09:00</td>
<td class="px-3 py-4">
<div class="flex flex-col gap-2">
<a class="inline-flex items-center justify-center rounded-lg border border-brand-200 px-3 py-2 text-sm font-medium text-brand-600 hover:bg-brand-25"
th:href="@{/mfuc/banner/form.do(bannerIdntfNo=${banner.bannerIdntfNo})}">
수정
</a>
<form method="post" th:action="@{/mfuc/banner/delete.do}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="bannerIdntfNo" th:value="${banner.bannerIdntfNo}">
<button class="inline-flex w-full items-center justify-center rounded-lg border border-error-200 px-3 py-2 text-sm font-medium text-error-600 hover:bg-error-25"
type="submit"
onclick="return confirm('해당 배너를 삭제하시겠습니까?');">
삭제
</button>
</form>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</body>
</html>
@@ -67,6 +67,20 @@
<span class="menu-item-text">포트폴리오 관리</span>
</a>
</li>
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'banner'} ? ' menu-item-active' : ' menu-item-inactive'"
th:href="@{/mfuc/banner/list.do}">
<span class="menu-item-icon-size"
th:classappend="${adminMenu == 'banner'} ? ' menu-item-icon-active' : ' menu-item-icon-inactive'">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.8 5.4h14.4A1.8 1.8 0 0 1 21 7.2v9.6a1.8 1.8 0 0 1-1.8 1.8H4.8A1.8 1.8 0 0 1 3 16.8V7.2a1.8 1.8 0 0 1 1.8-1.8Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M7.2 9h9.6M7.2 12h6M7.2 15h3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</span>
<span class="menu-item-text">배너관리</span>
</a>
</li>
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'contact'} ? ' menu-item-active' : ' menu-item-inactive'"
@@ -83,11 +97,30 @@
</li>
</ul>
</nav>
<nav>
<h2 class="mb-4 text-xs uppercase leading-[20px] text-gray-400">통계</h2>
<ul class="flex flex-col gap-4">
<li>
<a class="menu-item group"
th:classappend="${adminMenu == 'statistics'} ? ' menu-item-active' : ' menu-item-inactive'"
th:href="@{/mfuc/statistics/access.do}">
<span class="menu-item-icon-size"
th:classappend="${adminMenu == 'statistics'} ? ' menu-item-icon-active' : ' menu-item-icon-inactive'">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.8 19.2V12M12 19.2V4.8M19.2 19.2v-9.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M3.6 19.2h16.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</span>
<span class="menu-item-text">접속 통계</span>
</a>
</li>
</ul>
</nav>
</div>
</aside>
<div class="iotdoor-admin-main">
<header class="sticky top-0 z-999 bg-white/95 border-b border-gray-200 backdrop-blur-sm">
<header class="iotdoor-admin-header bg-white border-b border-gray-200">
<div class="flex flex-col gap-4 px-4 py-4 md:flex-row md:items-center md:justify-between md:px-6">
<div>
<p class="text-sm text-gray-500">운영 중인 관리자</p>
@@ -34,6 +34,7 @@
data-current-thumbnail-file-sn=*{thumbnailFileSn}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" th:field="*{portfolioIdntfNo}">
<input type="hidden" th:field="*{registeredAt}">
<input type="hidden" th:field="*{thumbnailAtchFileIdntfNo}" id="thumbnailAtchFileIdntfNo">
<input type="hidden" th:field="*{thumbnailFileSn}" id="thumbnailFileSn">
<textarea hidden th:field="*{content}" id="content"></textarea>
@@ -33,7 +33,15 @@
아직 등록된 포트폴리오가 없습니다.
</div>
<div th:unless="${#lists.isEmpty(portfolioItems)}" class="overflow-x-auto">
<table class="min-w-full text-sm">
<table class="iotdoor-portfolio-table text-sm">
<colgroup>
<col class="portfolio-col-image">
<col class="portfolio-col-title">
<col class="portfolio-col-date">
<col class="portfolio-col-author">
<col class="portfolio-col-view">
<col class="portfolio-col-action">
</colgroup>
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">대표이미지</th>
@@ -47,7 +55,7 @@
<tbody>
<tr class="border-b border-gray-100 align-top last:border-0"
th:each="item : ${portfolioItems}">
<td class="px-3 py-4" style="width: 160px;">
<td class="px-3 py-4">
<img class="h-24 w-36 rounded-xl object-cover"
th:src="${item.imageUrl}"
th:alt="${item.title}">
@@ -61,12 +69,12 @@
공개 페이지 보기
</a>
</td>
<td class="px-3 py-4 text-gray-500"
<td class="whitespace-nowrap px-3 py-4 text-gray-500"
th:text="${#temporals.format(item.registeredAt, 'yyyy-MM-dd HH:mm')}">2026-04-17 09:00</td>
<td class="px-3 py-4 text-gray-500" th:text="${item.registeredByName}">관리자</td>
<td class="px-3 py-4 text-gray-500" th:text="${item.viewCount}">0</td>
<td class="whitespace-nowrap px-3 py-4 text-gray-500" th:text="${item.registeredByName}">관리자</td>
<td class="whitespace-nowrap px-3 py-4 text-gray-500" th:text="${item.viewCount}">0</td>
<td class="px-3 py-4">
<div class="flex flex-col gap-2">
<div class="iotdoor-portfolio-actions">
<a class="inline-flex items-center justify-center rounded-lg border border-brand-200 px-3 py-2 text-sm font-medium text-brand-600 hover:bg-brand-25"
th:href="@{/mfuc/portfolio/form.do(portfolioIdntfNo=${item.portfolioIdntfNo})}">
수정
@@ -0,0 +1,167 @@
<!doctype html>
<html lang="ko"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/boot/admin/layout/adminLayout}">
<body>
<div layout:fragment="content" class="space-y-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">전체 접속</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${summary.totalAccessCount}">0</h3>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">오늘 접속</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${summary.todayAccessCount}">0</h3>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">고유 IP</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${summary.uniqueIpCount}">0</h3>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">고유 세션</p>
<h3 class="mt-3 text-3xl font-semibold text-gray-900" th:text="${summary.uniqueSessionCount}">0</h3>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-3">
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="px-6 py-5">
<h2 class="text-base font-medium text-gray-800">일별 접속</h2>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(dailyStats)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
접속 기록이 없습니다.
</div>
<div th:unless="${#lists.isEmpty(dailyStats)}" class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">날짜</th>
<th class="px-3 py-3 text-right">접속수</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 last:border-0"
th:each="stat : ${dailyStats}">
<td class="px-3 py-4 text-gray-600"
th:text="${#temporals.format(stat.accessDate, 'yyyy-MM-dd')}">2026-04-21</td>
<td class="px-3 py-4 text-right font-medium text-gray-900" th:text="${stat.accessCount}">0</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="px-6 py-5">
<h2 class="text-base font-medium text-gray-800">상위 페이지</h2>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(topPages)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
접속 기록이 없습니다.
</div>
<div th:unless="${#lists.isEmpty(topPages)}" class="space-y-4">
<div class="flex items-start justify-between gap-4"
th:each="page : ${topPages}">
<span class="min-w-0 flex-1 break-all text-sm text-gray-600" th:text="${page.label}">/</span>
<span class="text-sm font-medium text-gray-900" th:text="${page.accessCount}">0</span>
</div>
</div>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="px-6 py-5">
<h2 class="text-base font-medium text-gray-800">상위 유입</h2>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(topReferers)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
접속 기록이 없습니다.
</div>
<div th:unless="${#lists.isEmpty(topReferers)}" class="space-y-4">
<div class="flex items-start justify-between gap-4"
th:each="referer : ${topReferers}">
<span class="min-w-0 flex-1 break-all text-sm text-gray-600" th:text="${referer.label}">직접 접속</span>
<span class="text-sm font-medium text-gray-900" th:text="${referer.accessCount}">0</span>
</div>
</div>
</div>
</section>
</div>
<section class="rounded-2xl border border-gray-200 bg-white">
<div class="flex flex-wrap items-center justify-between gap-3 px-6 py-5">
<div>
<h2 class="text-base font-medium text-gray-800">최근 접속 기록</h2>
<p class="mt-1 text-sm text-gray-500">최근 100건 기준</p>
</div>
<a class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
th:href="@{/mfuc/index.do}">
대시보드
</a>
</div>
<div class="border-t border-gray-100 p-4 sm:p-6">
<div th:if="${#lists.isEmpty(recentLogs)}" class="rounded-xl bg-gray-50 px-4 py-6 text-sm text-gray-500">
접속 기록이 없습니다.
</div>
<div th:unless="${#lists.isEmpty(recentLogs)}" class="overflow-x-auto">
<table class="iotdoor-access-log-table text-sm">
<colgroup>
<col class="access-log-col-time">
<col class="access-log-col-ip">
<col class="access-log-col-request">
<col class="access-log-col-referer">
<col class="access-log-col-session">
<col class="access-log-col-status">
<col class="access-log-col-agent">
</colgroup>
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="px-3 py-3">등록시점</th>
<th class="px-3 py-3">IP</th>
<th class="px-3 py-3">요청</th>
<th class="px-3 py-3">Referer</th>
<th class="px-3 py-3">Session ID</th>
<th class="px-3 py-3">상태</th>
<th class="px-3 py-3">User-Agent</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 align-top last:border-0"
th:each="log : ${recentLogs}">
<td class="whitespace-nowrap px-3 py-4 text-gray-500"
th:text="${#temporals.format(log.registeredAt, 'yyyy-MM-dd HH:mm:ss')}">2026-04-21 10:00:00</td>
<td class="px-3 py-4 font-medium text-gray-900">
<span class="iotdoor-access-log-text iotdoor-access-log-mono" th:text="${log.accessIp}">127.0.0.1</span>
</td>
<td class="px-3 py-4 text-gray-600">
<div class="font-medium text-gray-900" th:text="${log.httpMethod}">GET</div>
<div class="iotdoor-access-log-text mt-1" th:title="${log.fullRequestPath}" th:text="${log.fullRequestPath}">/</div>
</td>
<td class="px-3 py-4 text-gray-500">
<span class="iotdoor-access-log-text"
th:title="${#strings.isEmpty(log.refererUrl) ? '-' : log.refererUrl}"
th:text="${#strings.isEmpty(log.refererUrl) ? '-' : log.refererUrl}">-</span>
</td>
<td class="px-3 py-4 text-gray-500">
<span class="iotdoor-access-log-text iotdoor-access-log-mono"
th:title="${#strings.isEmpty(log.sessionId) ? '-' : log.sessionId}"
th:text="${#strings.isEmpty(log.sessionId) ? '-' : log.sessionId}">session</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-gray-500" th:text="${log.statusCode}">200</td>
<td class="px-3 py-4 text-gray-500">
<span class="iotdoor-access-log-text"
th:title="${#strings.isEmpty(log.userAgent) ? '-' : log.userAgent}"
th:text="${#strings.isEmpty(log.userAgent) ? '-' : log.userAgent}">Mozilla/5.0</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</body>
</html>
@@ -5,13 +5,54 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}">
<th:block layout:fragment="pageStyles">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.css?v=20260421}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-post-list.css?v=20260421}">
</th:block>
<th:block layout:fragment="content">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="mb-4">
<a class="btn btn-sm btn-outline-secondary rounded-pill" th:href="@{/portfolio.do}">목록으로</a>
</div>
<section class="portfolio-post-list-panel mb-4"
data-portfolio-list-panel
th:data-current-id="${portfolio.portfolioIdntfNo}"
th:data-list-url="@{/portfolio/items.json}">
<div class="portfolio-post-list-bar">
<div class="portfolio-post-list-summary">
<a th:href="@{/portfolio.do}">전체보기</a>
<span th:text="|${portfolioCount}개의 글|">0개의 글</span>
</div>
<button class="portfolio-post-list-toggle"
type="button"
aria-expanded="false"
data-portfolio-list-toggle>목록열기</button>
</div>
<div class="portfolio-post-list-body" hidden data-portfolio-list-body>
<div class="portfolio-post-list-grid ag-theme-quartz" data-portfolio-list-grid></div>
<div class="portfolio-post-list-footer">
<div class="portfolio-post-list-footer-side">
<a class="portfolio-post-manage-link"
sec:authorize="hasRole('ADMIN')"
th:href="@{/mfuc/portfolio/list.do}">글 관리 열기</a>
</div>
<nav class="portfolio-post-pagination"
aria-label="포트폴리오 목록 페이지"
data-portfolio-pagination></nav>
<div class="portfolio-post-list-footer-side portfolio-post-page-size-wrap">
<label class="visually-hidden" for="portfolioPageSize">페이지당 글 수</label>
<select id="portfolioPageSize"
class="portfolio-post-page-size"
data-portfolio-page-size>
<option value="5">5줄 보기</option>
<option value="10">10줄 보기</option>
<option value="20">20줄 보기</option>
<option value="50">50줄 보기</option>
</select>
</div>
</div>
</div>
</section>
<article class="bg-white rounded shadow-sm overflow-hidden">
<!-- <img class="img-fluid w-100" th:src="${portfolio.imageUrl}" th:alt="${portfolio.title}"> -->
@@ -29,4 +70,8 @@
</div>
</div>
</th:block>
<th:block layout:fragment="script">
<script th:src="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.js?v=20260421}"></script>
</th:block>
</html>
@@ -5,6 +5,11 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{kr/iotdoor/comn/site/layout/cmmnSiteLayout.html}">
<th:block layout:fragment="pageStyles">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.css?v=20260421}">
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-post-list.css?v=20260421}">
</th:block>
<th:block layout:fragment="content">
<div class="container-xxl py-5">
<div class="container">
@@ -14,6 +19,45 @@
<p class="mb-0">대표 이미지, 상세 내용, 등록일시, 조회수까지 포함한 포트폴리오를 관리자 화면에서 직접 관리할 수 있습니다.</p>
</div>
<section class="portfolio-post-list-panel mb-5"
data-portfolio-list-panel
th:data-list-url="@{/portfolio/items.json}">
<div class="portfolio-post-list-bar">
<div class="portfolio-post-list-summary">
<a th:href="@{/portfolio.do}">전체보기</a>
<span th:text="|${#lists.size(portfolioItems)}개의 글|">0개의 글</span>
</div>
<button class="portfolio-post-list-toggle"
type="button"
aria-expanded="false"
data-portfolio-list-toggle>목록열기</button>
</div>
<div class="portfolio-post-list-body" hidden data-portfolio-list-body>
<div class="portfolio-post-list-grid ag-theme-quartz" data-portfolio-list-grid></div>
<div class="portfolio-post-list-footer">
<div class="portfolio-post-list-footer-side">
<a class="portfolio-post-manage-link"
sec:authorize="hasRole('ADMIN')"
th:href="@{/mfuc/portfolio/list.do}">글 관리 열기</a>
</div>
<nav class="portfolio-post-pagination"
aria-label="포트폴리오 목록 페이지"
data-portfolio-pagination></nav>
<div class="portfolio-post-list-footer-side portfolio-post-page-size-wrap">
<label class="visually-hidden" for="portfolioPageSize">페이지당 글 수</label>
<select id="portfolioPageSize"
class="portfolio-post-page-size"
data-portfolio-page-size>
<option value="5">5줄 보기</option>
<option value="10">10줄 보기</option>
<option value="20">20줄 보기</option>
<option value="50">50줄 보기</option>
</select>
</div>
</div>
</div>
</section>
<div th:if="${#lists.isEmpty(portfolioItems)}" class="text-center text-secondary py-5">
아직 등록된 포트폴리오가 없습니다.
</div>
@@ -22,25 +66,20 @@
<div class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.1s"
th:each="item : ${portfolioItems}">
<div class="service-item rounded overflow-hidden h-100 align-content-center">
<a th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}">
<img class="img-fluid" th:src="${item.imageUrl}" th:alt="${item.title}">
</a>
<div class="position-relative p-4 pt-0">
<div class="d-flex justify-content-between align-items-center mb-2 small text-secondary">
<a class="portfolio-frame-card"
th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}">
<span class="portfolio-frame-image-link">
<img class="portfolio-frame-image" th:src="${item.imageUrl}" th:alt="${item.title}">
</span>
<div class="portfolio-frame-body">
<div class="portfolio-frame-meta">
<span th:text="${#temporals.format(item.registeredAt, 'yyyy-MM-dd')}">2026-04-16</span>
<span>조회 <span th:text="${item.viewCount}">0</span></span>
</div>
<h4 class="mb-3">
<a class="text-dark" th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}" th:text="${item.title}">제목</a>
</h4>
<p class="mb-3" th:text="${item.summary}">설명</p>
<a class="btn btn-sm btn-outline-primary rounded-pill"
th:href="@{/portfolio/{portfolioId}(portfolioId=${item.portfolioIdntfNo})}">
자세히 보기
</a>
<h4 class="portfolio-frame-title" th:text="${item.title}">제목</h4>
<p class="portfolio-frame-summary" th:text="${item.summary}">설명</p>
</div>
</div>
</a>
</div>
</div>
@@ -50,4 +89,8 @@
</div>
</div>
</th:block>
<th:block layout:fragment="script">
<script th:src="@{/resources/kr/iotdoor/cmty/portfolio/portfolio-public.js?v=20260421}"></script>
</th:block>
</html>
@@ -1,5 +1,6 @@
<!-- Spinner Start -->
<div id="spinner" class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
<div id="spinner"
class="show bg-white position-fixed translate-middle w-100 vh-100 top-50 start-50 d-flex align-items-center justify-content-center">
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
<span class="sr-only">Loading...</span>
</div>
@@ -30,9 +31,12 @@
</small>
</div>
<div class="h-100 d-inline-flex align-items-center mx-n2">
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i class="fab fa-facebook-f"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i class="fab fa-twitter"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i class="fab fa-linkedin-in"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i
class="fab fa-facebook-f"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i
class="fab fa-twitter"></i></a>
<a class="btn btn-square btn-link rounded-0 border-0 border-end border-secondary" href=""><i
class="fab fa-linkedin-in"></i></a>
<a class="btn btn-square btn-link rounded-0" href=""><i class="fab fa-instagram"></i></a>
</div>
</div>
@@ -47,13 +51,14 @@
<!-- <h2 class="m-0 text-primary">국제오토텍</h2> -->
<img src="/resources/kr/iotdoor/comn/site/layout/image/template/logo.jpg" alt="로고" style="height:75px;">
</a>
<button type="button" class="navbar-toggler me-4" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
<button type="button" class="navbar-toggler me-4" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="메뉴 열기">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<div class="navbar-nav ms-auto p-4 p-lg-0">
<a href="/" class="nav-item nav-link">Home</a>
<a href="/" class="nav-item nav-link">About</a>
<!-- <a href="/" class="nav-item nav-link">About</a> -->
<a href="/portfolio.do" class="nav-item nav-link">Portfolio</a>
<a href="/contact.do" class="nav-item nav-link">Contact</a>
<a href="/mfuc/index.do" class="nav-item nav-link">ADMIN</a>
@@ -38,6 +38,8 @@
<!-- Template Stylesheet -->
<link href="/resources/kr/iotdoor/comn/site/layout/css/templateStyle.css?220712" rel="stylesheet" />
<link rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/site/layout/css/custom.css?v=20260421}">
<th:block layout:fragment="pageStyles"></th:block>
<!-- <link th:if="${mainYn == 'Y' && popupList.size > 0}" rel="stylesheet" th:href="@{/resources/kr/iotdoor/comn/popup/css/popupListLayout.css}"> -->
<!-- <script th:if="${not #strings.isEmpty(checkMessage)}">alert('[[${checkMessage}]]');</script> -->
@@ -101,7 +103,7 @@
<script th:src="@{'//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js'}"></script>
<!-- Includes all JS & CSS for the JavaScript Data Grid -->
<script th:src="@{/resources/kr/iotdoor/comn/site/layout/js/common.js?v=20240716}"></script>
<script th:src="@{/resources/kr/iotdoor/comn/site/layout/js/main.js?v=20240716}"></script>
<script th:src="@{/resources/kr/iotdoor/comn/site/layout/js/main.js?v=20260507}"></script>
<script th:if="${mainYn == 'Y' and !#lists.isEmpty(popupList)}" th:src="@{/resources/kr/iotdoor/comn/popup/js/popupListLayout.js}"></script>
<th:block layout:fragment="script"></th:block>
</body>
@@ -83,139 +83,27 @@
</h1>
</div>
<div class="row g-4">
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.1s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-1.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-solar-panel fa-3x"></i>
</div>
<h4 class="mb-3">슬라이딩자동문</h4>
<!-- <p>슬라이드 오토도어를 전문적으로 다룹니다.</p> -->
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i class="fa fa-arrow-right ms-2"></i></a>
</div>
<div th:if="${#lists.isEmpty(mainBanners)}" class="col-12">
<div class="rounded bg-light p-4 text-center text-muted">
등록된 배너가 없습니다.
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.3s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-2.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="col-md-6 col-lg-4 wow fadeInUp"
th:each="banner, bannerStat : ${mainBanners}"
th:attr="data-wow-delay=${bannerStat.index % 3 == 0 ? '0.1s' : (bannerStat.index % 3 == 1 ? '0.3s' : '0.5s')}">
<div class="service-item main-service-card rounded overflow-hidden">
<img class="img-fluid main-service-card-image"
th:src="${banner.imageUrl}"
th:alt="${banner.title}"/>
<div class="main-service-card-body position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-wind fa-3x"></i>
<i th:class="${bannerStat.index % 3 == 0 ? 'fa fa-solar-panel fa-3x' : (bannerStat.index % 3 == 1 ? 'fa fa-wind fa-3x' : 'fa fa-lightbulb fa-3x')}"></i>
</div>
<h4 class="mb-3">산업용자동문</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.5s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-3.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-lightbulb fa-3x"></i>
</div>
<h4 class="mb-3">고급산업용자동문</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.1s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-4.jpg"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-solar-panel fa-3x"></i>
</div>
<h4 class="mb-3">내풍압고속셔터</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.3s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-5.jpg?1"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-wind fa-3x"></i>
</div>
<h4 class="mb-3">오버헤드도어</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
</div>
</div>
</div>
<div
class="col-md-6 col-lg-4 wow fadeInUp"
data-wow-delay="0.5s"
>
<div class="service-item rounded overflow-hidden">
<img
class="img-fluid"
src="/resources/kr/iotdoor/comn/site/layout/image/template/img-600x400-6.jpg?1?1"
alt=""
/>
<div class="position-relative p-4 pt-0">
<div class="service-icon">
<i class="fa fa-lightbulb fa-3x"></i>
</div>
<h4 class="mb-3">방화용슬라이딩자동문</h4>
<a class="small fw-medium" th:href="@{/portfolio.do}"
>Read More<i
class="fa fa-arrow-right ms-2"
></i
></a>
<h4 class="mb-3" th:text="${banner.title}">배너 제목</h4>
<p class="main-service-card-content" th:if="${!#strings.isEmpty(banner.content)}" th:text="${banner.content}">배너 내용</p>
<a class="main-service-card-link small fw-medium" th:href="@{/portfolio.do}">
Read More<i class="fa fa-arrow-right ms-2"></i>
</a>
</div>
</div>
</div>
@@ -340,17 +228,17 @@
<h6 class="text-primary">Contact Us</h6>
<p class="mb-4">내용을 자세하게 입력해주시면 상담진행시에 좀 더 원활하게 상담이 가능합니다. 연중무휴 24시간 주저말고 남겨주세요. </p>
<form id="contactForm">
<input type="hidden" name="title" value="국제오토텍 문의 접수">
<input type="hidden" name="title">
<div class="row g-3">
<div class="col-md-6">
<div class="form-floating">
<input type="text" class="form-control" id="name" name="name" placeholder="Your Name" value="김찬솔">
<input type="text" class="form-control" id="name" name="name" placeholder="Your Name">
<label for="name">Name</label>
</div>
</div>
<div class="col-md-6">
<div class="form-floating">
<input type="email" class="form-control" id="email" name="email" placeholder="Your Email" value="so__l91@naver.com">
<input type="email" class="form-control" id="email" name="email" placeholder="Your Email">
<label for="email">Email</label>
</div>
<div id="emailError" style="color:red; font-size:13px; display:none;">
@@ -359,7 +247,7 @@
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control" id="phone" name="phone" placeholder="Phone" value="010-7722-0709">
<input type="text" class="form-control" id="phone" name="phone" placeholder="Phone">
<label for="subject">Phone</label>
</div>
<div id="phoneError" style="color:red; font-size:13px; display:none;">
@@ -368,13 +256,13 @@
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control" id="subject" name="subject" placeholder="Subject" value="제목324">
<input type="text" class="form-control" id="subject" name="subject" placeholder="Subject">
<label for="subject">Subject</label>
</div>
</div>
<div class="col-12">
<div class="form-floating">
<textarea class="form-control" placeholder="Leave a message here" id="message" name="message" style="height: 100px">내용324234</textarea>
<textarea class="form-control" placeholder="Leave a message here" id="message" name="message" style="height: 100px"></textarea>
<label for="message">Message</label>
</div>
</div>