feat: GTSIT 사이트 구축
14
public/.htaccess
Normal file
@@ -0,0 +1,14 @@
|
||||
Options -Indexes
|
||||
|
||||
DirectoryIndex index.php index.html
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
RewriteCond %{HTTP_HOST} ^(?:www\.)?gtsit\.co\.kr$ [NC]
|
||||
RewriteCond %{HTTPS} !=on [OR]
|
||||
RewriteCond %{HTTP_HOST} ^www\.gtsit\.co\.kr$ [NC]
|
||||
RewriteRule ^ https://gtsit.co.kr%{REQUEST_URI} [R=301,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [L,QSA]
|
||||
5
public/assets/css/admin.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.admin-body{background:#f8fafc}.admin-header{background:#fff;border-bottom:1px solid var(--border)}.admin-header-inner{width:min(100% - 48px,1100px);height:64px;margin:auto;display:flex;align-items:center;justify-content:space-between;gap:20px}.admin-brand{display:flex;align-items:center;gap:10px;text-decoration:none}.admin-brand .brand-mark{width:38px;height:38px}.admin-account{display:flex;align-items:center;gap:14px;font-size:13px}.admin-account form{margin:0}.admin-account form button{border:0;background:none;cursor:pointer;color:var(--secondary)}.admin-nav{background:#fff;border-bottom:1px solid var(--border)}.admin-nav>div{width:min(100% - 48px,1100px);margin:auto;display:flex;gap:6px;overflow:auto}.admin-nav a{padding:14px 12px;text-decoration:none;color:var(--secondary);white-space:nowrap}.admin-nav a[aria-current=page]{color:var(--primary);font-weight:800;border-bottom:2px solid var(--primary)}.admin-main{width:min(100% - 48px,1100px);margin:auto;padding:38px 0 80px}.login-panel{max-width:420px;margin:50px auto;padding:34px;background:#fff;border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow)}.login-heading{text-align:center}.login-heading .brand-mark{margin:auto}.login-heading h1{margin:18px 0 0}.login-heading p{margin:7px 0 24px;color:var(--muted)}.login-panel label{display:block;margin-bottom:17px;color:var(--secondary);font-size:13px;font-weight:700}.login-panel input{width:100%;height:44px;margin-top:7px;padding:10px 12px;border:1px solid #d4d4d8;border-radius:9px}.admin-error{padding:12px 14px;border-radius:9px;background:#fef2f2;color:#991b1b;font-size:13px}.admin-page-heading{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:28px}.admin-page-heading span{color:var(--primary);font-size:12px;font-weight:800;letter-spacing:.1em}.admin-page-heading h1{margin:5px 0 0;font-size:30px}.admin-page-heading p{margin:6px 0 0;color:var(--muted)}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.metric-grid a{padding:24px;background:#fff;border:1px solid var(--border);border-radius:var(--radius);text-decoration:none}.metric-grid span{display:block;color:var(--muted);font-size:13px}.metric-grid strong{display:block;margin-top:8px;font-size:32px}.admin-table-wrap{overflow:hidden;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.admin-table{width:100%;border-collapse:collapse}.admin-table th,.admin-table td{padding:14px 16px;border-bottom:1px solid var(--border);text-align:left;font-size:13px}.admin-table th{background:#f8fafc;color:var(--muted)}.admin-table tr:last-child td{border:0}.admin-table td:first-child{font-size:14px}.admin-table td small{display:block;margin-top:3px;color:var(--muted)}.admin-table td a{color:var(--primary);font-weight:700}.status{display:inline-flex;padding:4px 9px;border-radius:999px;background:#f1f5f9;color:#475569;font-size:12px;font-weight:700}.status-published,.status-replied{background:#dcfce7;color:#15803d}.status-pending{background:#fef3c7;color:#b45309}.status-in_progress{background:#dbeafe;color:#1d4ed8}.inquiry-admin-list{display:grid;gap:14px}.inquiry-admin-list article{padding:20px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.inquiry-admin-list article>div{display:flex;justify-content:space-between}.inquiry-admin-list small{color:var(--muted)}.inquiry-admin-list h2{margin:12px 0 0;font-size:17px}.inquiry-admin-list h2 a{text-decoration:none}.inquiry-admin-list p{margin:7px 0 0;color:var(--secondary);font-size:13px}.admin-placeholder{padding:28px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}
|
||||
@media(max-width:760px){.admin-header-inner,.admin-nav>div,.admin-main{width:min(100% - 32px,1100px)}.admin-account>span{display:none}.metric-grid{grid-template-columns:1fr 1fr}.admin-page-heading{align-items:stretch;flex-direction:column}.admin-table-wrap{overflow:visible;background:transparent;border:0}.admin-table thead{display:none}.admin-table,.admin-table tbody,.admin-table tr,.admin-table td{display:block}.admin-table tr{margin-bottom:14px;padding:16px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.admin-table td{display:grid;grid-template-columns:72px 1fr;gap:10px;padding:7px 0;border:0}.admin-table td::before{content:attr(data-label);color:var(--muted);font-size:12px}.admin-table td:first-child{display:block;padding-bottom:12px;margin-bottom:6px;border-bottom:1px solid var(--border)}.admin-table td:first-child::before{display:none}}
|
||||
@media(max-width:480px){.admin-account .button{display:none}.admin-main{padding-top:28px}.metric-grid{grid-template-columns:1fr 1fr;gap:10px}.metric-grid a{padding:18px}.login-panel{margin:26px 0;padding:24px}}
|
||||
.admin-form{max-width:780px;padding:28px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.admin-form label{display:block;margin-bottom:17px;color:var(--secondary);font-size:13px;font-weight:700}.admin-form label>span{color:#dc2626}.admin-form input,.admin-form select,.admin-form textarea{width:100%;margin-top:7px;padding:10px 12px;border:1px solid #d4d4d8;border-radius:9px;background:#fff}.admin-form input,.admin-form select{height:44px}.admin-form textarea{resize:vertical;line-height:1.6}.admin-form label small{display:block;margin-top:5px;color:var(--muted);font-weight:400}.admin-field-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.admin-form-actions{display:flex;gap:10px;margin-top:24px}.danger-zone{max-width:780px;margin-top:24px;padding:20px 24px;display:flex;align-items:center;justify-content:space-between;gap:20px;border:1px solid #fecaca;border-radius:var(--radius);background:#fff}.danger-zone strong{color:#991b1b}.danger-zone p{margin:4px 0 0;color:var(--muted);font-size:13px}.danger-zone button{padding:9px 14px;border:1px solid #fecaca;border-radius:8px;background:#fff;color:#b91c1c;cursor:pointer}.inquiry-heading{margin-top:22px}.inquiry-detail-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:20px;align-items:start}.inquiry-content{padding:26px;background:#fff;border:1px solid var(--border);border-radius:var(--radius)}.contact-summary{padding:16px;background:#f8fafc;border-radius:9px}.contact-summary>span{float:right;color:var(--primary);font-size:12px;font-weight:800}.contact-summary p{margin:0;line-height:1.8}.inquiry-content h2{margin-top:25px;font-size:17px}.inquiry-content>p{line-height:1.85}.reply-form{padding:26px}.reply-form h2{margin:0 0 20px}
|
||||
@media(max-width:760px){.admin-field-grid,.inquiry-detail-grid{grid-template-columns:1fr}.danger-zone{align-items:stretch;flex-direction:column}.danger-zone button{width:100%}}
|
||||
67
public/assets/css/content.css
Normal file
@@ -0,0 +1,67 @@
|
||||
.post-thumbnail img{width:100%;height:100%;object-fit:cover}
|
||||
.post-card{position:relative;transition:transform .2s ease,box-shadow .2s ease}
|
||||
.post-card:hover{transform:translateY(-3px);box-shadow:0 12px 28px rgba(15,23,42,.1)}
|
||||
.post-thumbnail>.post-category{z-index:3}
|
||||
.post-placeholder{--placeholder-accent:#2563eb;position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:9px;overflow:hidden;background:linear-gradient(145deg,#eff6ff,#dbeafe);color:var(--placeholder-accent)}
|
||||
.post-placeholder::before{content:"";position:absolute;inset:0;background-image:linear-gradient(rgba(37,99,235,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(37,99,235,.08) 1px,transparent 1px);background-size:34px 34px;mask-image:linear-gradient(to bottom right,#000,transparent 78%)}
|
||||
.post-placeholder::after{content:"";position:absolute;right:-13%;bottom:-45%;width:62%;aspect-ratio:1;border:34px solid color-mix(in srgb,var(--placeholder-accent) 14%,transparent);border-radius:50%}
|
||||
.post-placeholder[data-category="키폰시스템"]{--placeholder-accent:#7c3aed;background:linear-gradient(145deg,#f5f3ff,#ede9fe)}
|
||||
.post-placeholder[data-category="CCTV"]{--placeholder-accent:#0f766e;background:linear-gradient(145deg,#f0fdfa,#ccfbf1)}
|
||||
.post-placeholder[data-category="시공사례"]{--placeholder-accent:#c2410c;background:linear-gradient(145deg,#fff7ed,#ffedd5)}
|
||||
.post-thumbnail .post-placeholder img{position:relative;z-index:1;width:136px;height:auto;object-fit:contain;filter:drop-shadow(0 5px 10px rgba(15,23,42,.08))}
|
||||
.post-thumbnail .post-placeholder small{position:relative;z-index:1;padding:0;background:transparent;color:var(--placeholder-accent);font-size:10px;font-weight:800;letter-spacing:.2em}
|
||||
.post-body h2 a::after,.post-body h3 a::after{inset:0;z-index:2}
|
||||
.post-body time{position:relative;z-index:3}
|
||||
.article-image{margin-inline:0;overflow:hidden}
|
||||
.article-image img{width:100%;height:100%;object-fit:cover}
|
||||
.admin-upload-field{margin:0 0 20px;padding:18px;border:1px solid var(--border);border-radius:10px}
|
||||
.admin-upload-field legend{padding:0 7px;color:var(--secondary);font-size:13px;font-weight:800}
|
||||
.admin-upload-field img{width:min(100%,360px);aspect-ratio:16/9;object-fit:cover;border-radius:8px}
|
||||
.admin-upload-field>p{margin:8px 0 16px;color:var(--muted);font-size:13px}
|
||||
.admin-notice{margin:0 0 18px;padding:12px 14px;border:1px solid #bbf7d0;border-radius:8px;background:#f0fdf4;color:#166534;font-size:13px}
|
||||
.floating-call{display:inline-flex;align-items:center;gap:8px}
|
||||
.floating-call svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||
.brand-logo{width:64px;height:auto;object-fit:contain;flex:none}.footer-brand .brand-logo{width:56px}.admin-brand .brand-logo{width:60px}.login-logo{width:112px;height:auto;margin:auto;object-fit:contain}
|
||||
.hero-visual{overflow:hidden;background:#111827;display:block;text-align:initial;color:#fff}
|
||||
.hero-slider{position:relative;isolation:isolate}
|
||||
.hero-slides,.hero-slide{position:absolute;inset:0;margin:0}
|
||||
.hero-slide{opacity:0;visibility:hidden;transition:opacity .55s ease,visibility .55s ease}
|
||||
.hero-slide.is-active{opacity:1;visibility:visible}
|
||||
.hero-slide::after{content:"";position:absolute;inset:auto 0 0;height:42%;background:linear-gradient(transparent,rgba(9,9,11,.72));pointer-events:none}
|
||||
.hero-slide img{width:100%;height:100%;object-fit:cover;object-position:center}
|
||||
.hero-slide figcaption{position:absolute;left:18px;right:18px;bottom:54px;z-index:1;color:#fff;font-size:14px;font-weight:700;text-shadow:0 1px 4px rgba(0,0,0,.5)}
|
||||
.hero-slider .hero-slider-controls{position:absolute;left:14px;right:14px;bottom:12px;z-index:2;display:flex;align-items:center;gap:8px}
|
||||
.hero-slider-controls button{border:0;color:#fff;cursor:pointer}
|
||||
.hero-slider-arrow{width:32px;height:32px;border-radius:50%;background:rgba(9,9,11,.64);font-size:25px;line-height:1}
|
||||
.hero-slider .hero-slider-dots{display:flex;align-items:center;gap:7px}
|
||||
.hero-slider-dots button{width:8px;height:8px;padding:0;border-radius:50%;background:rgba(255,255,255,.5)}
|
||||
.hero-slider-dots button.is-active{width:22px;border-radius:999px;background:#fff}
|
||||
.hero-slider-pause{min-height:32px;margin-left:auto;padding:0 11px;border-radius:999px;background:rgba(9,9,11,.64);font-size:11px;font-weight:700}
|
||||
@media(prefers-reduced-motion:reduce){.hero-slide{transition:none}}
|
||||
@media(prefers-reduced-motion:reduce){.post-card{transition:none}.post-card:hover{transform:none}}
|
||||
.contact-map-card>p{margin-bottom:18px}
|
||||
.contact-form input::placeholder,.contact-form textarea::placeholder{color:#a1a1aa;font-weight:400;opacity:1}
|
||||
.contact-form input::placeholder{font-size:14px}
|
||||
.contact-form select{appearance:none;padding-right:42px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2352565f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;background-size:16px 16px}
|
||||
.contact-form .form-field{min-width:0;margin:0 0 17px;padding:0;border:0}
|
||||
.contact-form .form-field legend{padding:0;color:var(--secondary);font-size:13px;font-weight:700}
|
||||
.email-template{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(128px,.8fr);align-items:center;gap:8px}
|
||||
.email-template[hidden],.email-direct-row[hidden]{display:none}
|
||||
.email-template>span{margin-top:7px;color:var(--secondary);font-size:15px;font-weight:700}
|
||||
.contact-form .email-template input,.contact-form .email-template select,.contact-form .email-direct-row input{margin-top:7px}
|
||||
.contact-form .email-template input::placeholder{font-size:13px;color:#b0b3ba}
|
||||
.email-direct-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:8px}
|
||||
.email-template-return{height:44px;padding:0 12px;border:1px solid #d4d4d8;border-radius:9px;background:#fff;color:var(--secondary);font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap}
|
||||
.naver-map-frame{position:relative;height:300px;margin-top:18px;overflow:hidden;border:1px solid var(--border);border-radius:10px;background:#f1f5f9}
|
||||
.naver-map{width:100%;height:100%!important}
|
||||
.naver-map-status{position:absolute;inset:0;z-index:1;display:grid;place-items:center;margin:0!important;padding:24px;text-align:center;background:#f8fafc;color:var(--secondary)!important;font-size:14px}
|
||||
.naver-map-frame[data-map-state=ready] .naver-map-status{display:none}
|
||||
.naver-map-frame[data-map-state=error] .naver-map-status{background:#fff7ed;color:#9a3412!important}
|
||||
.naver-map-fullscreen{position:absolute;right:10px;top:10px;z-index:5;width:38px;height:38px;display:grid;place-items:center;padding:0;border:1px solid rgba(15,23,42,.14);border-radius:8px;background:#fff;color:#27272a;box-shadow:0 2px 8px rgba(15,23,42,.14);cursor:pointer}
|
||||
.naver-map-fullscreen[hidden]{display:none}
|
||||
.naver-map-fullscreen svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||
.naver-map-frame:fullscreen{width:100vw;height:100vh;margin:0;border:0;border-radius:0;background:#fff}
|
||||
.naver-map-frame.is-fullscreen .naver-map-fullscreen{right:16px;top:16px}
|
||||
.naver-map-external{display:inline-flex;align-items:center;min-height:44px;margin-top:14px;color:var(--primary);font-weight:700}
|
||||
.naver-map-frame[data-map-state=loading]+.naver-map-external,.naver-map-frame[data-map-state=ready]+.naver-map-external{display:none}
|
||||
@media(max-width:640px){.post-card:hover{transform:none}.email-template{grid-template-columns:minmax(0,1fr) auto minmax(116px,.85fr);gap:6px}.email-direct-row{grid-template-columns:1fr}.email-template-return{width:100%;margin-top:0}.floating-call{display:none}}
|
||||
10
public/assets/css/site.css
Normal file
BIN
public/assets/images/company-logo-v2.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
public/assets/images/company-logo-v3.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
public/assets/images/field/cctv-control-rack.jpg
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
public/assets/images/field/keyphone-network-rack.jpg
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
public/assets/images/field/lan-cabling-rack.jpg
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
public/assets/images/field/outdoor-wireless-link.jpg
Normal file
|
After Width: | Height: | Size: 357 KiB |
BIN
public/assets/images/main-construction-hero.jpg
Normal file
|
After Width: | Height: | Size: 151 KiB |
315
public/assets/js/site.js
Normal file
@@ -0,0 +1,315 @@
|
||||
(() => {
|
||||
const toggle = document.querySelector('.menu-toggle');
|
||||
const navigation = document.querySelector('.site-navigation');
|
||||
if (!toggle || !navigation) return;
|
||||
|
||||
const close = () => {
|
||||
navigation.classList.remove('is-open');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
toggle.querySelector('.sr-only').textContent = '메뉴 열기';
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const open = !navigation.classList.contains('is-open');
|
||||
navigation.classList.toggle('is-open', open);
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
toggle.querySelector('.sr-only').textContent = open ? '메뉴 닫기' : '메뉴 열기';
|
||||
});
|
||||
|
||||
navigation.addEventListener('click', (event) => {
|
||||
if (event.target.closest('a')) close();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth > 960) close();
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const slider = document.querySelector('[data-hero-slider]');
|
||||
if (!slider) return;
|
||||
|
||||
const slides = [...slider.querySelectorAll('.hero-slide')];
|
||||
const dots = [...slider.querySelectorAll('[data-slider-dot]')];
|
||||
const previous = slider.querySelector('[data-slider-prev]');
|
||||
const next = slider.querySelector('[data-slider-next]');
|
||||
const pause = slider.querySelector('[data-slider-pause]');
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
let current = 0;
|
||||
let timer = null;
|
||||
let isPaused = false;
|
||||
let isInteracting = false;
|
||||
|
||||
const show = (index) => {
|
||||
current = (index + slides.length) % slides.length;
|
||||
slides.forEach((slide, slideIndex) => {
|
||||
const isActive = slideIndex === current;
|
||||
slide.classList.toggle('is-active', isActive);
|
||||
slide.setAttribute('aria-hidden', String(!isActive));
|
||||
});
|
||||
dots.forEach((dot, dotIndex) => {
|
||||
const isActive = dotIndex === current;
|
||||
dot.classList.toggle('is-active', isActive);
|
||||
if (isActive) dot.setAttribute('aria-current', 'true');
|
||||
else dot.removeAttribute('aria-current');
|
||||
});
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (timer !== null) window.clearInterval(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
stop();
|
||||
if (isPaused || isInteracting || reducedMotion.matches || document.hidden) return;
|
||||
timer = window.setInterval(() => show(current + 1), 5000);
|
||||
};
|
||||
|
||||
const move = (direction) => {
|
||||
show(current + direction);
|
||||
start();
|
||||
};
|
||||
|
||||
previous?.addEventListener('click', () => move(-1));
|
||||
next?.addEventListener('click', () => move(1));
|
||||
dots.forEach((dot) => dot.addEventListener('click', () => {
|
||||
show(Number(dot.dataset.sliderDot));
|
||||
start();
|
||||
}));
|
||||
pause?.addEventListener('click', () => {
|
||||
isPaused = !isPaused;
|
||||
pause.setAttribute('aria-pressed', String(isPaused));
|
||||
pause.textContent = isPaused ? '자동재생 시작' : '자동재생 일시정지';
|
||||
start();
|
||||
});
|
||||
slider.addEventListener('mouseenter', () => {
|
||||
isInteracting = true;
|
||||
stop();
|
||||
});
|
||||
slider.addEventListener('mouseleave', () => {
|
||||
isInteracting = false;
|
||||
start();
|
||||
});
|
||||
slider.addEventListener('focusin', () => {
|
||||
isInteracting = true;
|
||||
stop();
|
||||
});
|
||||
slider.addEventListener('focusout', () => {
|
||||
isInteracting = false;
|
||||
start();
|
||||
});
|
||||
document.addEventListener('visibilitychange', start);
|
||||
reducedMotion.addEventListener('change', start);
|
||||
start();
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const phoneInput = document.querySelector('[data-phone-input]');
|
||||
const emailField = document.querySelector('[data-email-field]');
|
||||
|
||||
if (phoneInput) {
|
||||
const formatPhone = (value) => {
|
||||
let numbers = value.replace(/\D/g, '');
|
||||
|
||||
if (numbers.startsWith('02')) {
|
||||
numbers = numbers.slice(0, 10);
|
||||
if (numbers.length <= 2) return numbers;
|
||||
if (numbers.length <= 5) return `${numbers.slice(0, 2)}-${numbers.slice(2)}`;
|
||||
if (numbers.length <= 9) return `${numbers.slice(0, 2)}-${numbers.slice(2, 5)}-${numbers.slice(5)}`;
|
||||
return `${numbers.slice(0, 2)}-${numbers.slice(2, 6)}-${numbers.slice(6)}`;
|
||||
}
|
||||
|
||||
if (/^1\d{3}/.test(numbers)) {
|
||||
numbers = numbers.slice(0, 8);
|
||||
return numbers.length <= 4 ? numbers : `${numbers.slice(0, 4)}-${numbers.slice(4)}`;
|
||||
}
|
||||
|
||||
if (/^050\d/.test(numbers)) {
|
||||
numbers = numbers.slice(0, 12);
|
||||
if (numbers.length <= 4) return numbers;
|
||||
if (numbers.length <= 8) return `${numbers.slice(0, 4)}-${numbers.slice(4)}`;
|
||||
return `${numbers.slice(0, 4)}-${numbers.slice(4, 8)}-${numbers.slice(8)}`;
|
||||
}
|
||||
|
||||
numbers = numbers.slice(0, 11);
|
||||
if (numbers.length <= 3) return numbers;
|
||||
if (numbers.length <= 6) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
|
||||
if (numbers.length <= 10) return `${numbers.slice(0, 3)}-${numbers.slice(3, 6)}-${numbers.slice(6)}`;
|
||||
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7)}`;
|
||||
};
|
||||
|
||||
const applyPhoneFormat = () => {
|
||||
phoneInput.value = formatPhone(phoneInput.value);
|
||||
};
|
||||
|
||||
phoneInput.addEventListener('input', applyPhoneFormat);
|
||||
applyPhoneFormat();
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-pin-input]').forEach((pinInput) => {
|
||||
const applyPinFormat = () => {
|
||||
pinInput.value = pinInput.value.replace(/\D/g, '').slice(0, 4);
|
||||
};
|
||||
pinInput.addEventListener('input', applyPinFormat);
|
||||
applyPinFormat();
|
||||
});
|
||||
|
||||
if (!emailField) return;
|
||||
|
||||
const template = emailField.querySelector('[data-email-template]');
|
||||
const localInput = emailField.querySelector('[data-email-local]');
|
||||
const domainSelect = emailField.querySelector('[data-email-domain]');
|
||||
const directRow = emailField.querySelector('[data-email-direct]');
|
||||
const fullInput = emailField.querySelector('[data-email-full]');
|
||||
const returnButton = emailField.querySelector('[data-email-return]');
|
||||
if (!template || !localInput || !domainSelect || !directRow || !fullInput || !returnButton) return;
|
||||
|
||||
const domains = [...domainSelect.options].map((option) => option.value).filter((value) => value !== 'direct');
|
||||
const splitEmail = (email) => {
|
||||
const separator = email.lastIndexOf('@');
|
||||
if (separator <= 0) return null;
|
||||
return { local: email.slice(0, separator), domain: email.slice(separator + 1).toLowerCase() };
|
||||
};
|
||||
const syncEmail = () => {
|
||||
fullInput.value = localInput.value && domainSelect.value !== 'direct'
|
||||
? `${localInput.value}@${domainSelect.value}`
|
||||
: '';
|
||||
};
|
||||
const showTemplate = () => {
|
||||
const parsed = splitEmail(fullInput.value);
|
||||
localInput.value = parsed?.local || fullInput.value;
|
||||
domainSelect.value = parsed && domains.includes(parsed.domain) ? parsed.domain : 'naver.com';
|
||||
template.hidden = false;
|
||||
directRow.hidden = true;
|
||||
returnButton.hidden = true;
|
||||
syncEmail();
|
||||
};
|
||||
const showDirect = () => {
|
||||
const local = localInput.value.trim();
|
||||
fullInput.value = local ? `${local}@` : '';
|
||||
template.hidden = true;
|
||||
directRow.hidden = false;
|
||||
returnButton.hidden = false;
|
||||
fullInput.focus();
|
||||
};
|
||||
|
||||
localInput.addEventListener('input', syncEmail);
|
||||
domainSelect.addEventListener('change', () => {
|
||||
if (domainSelect.value === 'direct') showDirect();
|
||||
else syncEmail();
|
||||
});
|
||||
returnButton.addEventListener('click', () => {
|
||||
showTemplate();
|
||||
localInput.focus();
|
||||
});
|
||||
emailField.closest('form')?.addEventListener('submit', () => {
|
||||
if (!template.hidden) syncEmail();
|
||||
});
|
||||
|
||||
const initialEmail = splitEmail(fullInput.value);
|
||||
if (!fullInput.value || (initialEmail && domains.includes(initialEmail.domain))) showTemplate();
|
||||
else returnButton.hidden = false;
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const mapFrame = document.querySelector('[data-naver-map]');
|
||||
if (!mapFrame) return;
|
||||
|
||||
const mapElement = mapFrame.querySelector('.naver-map');
|
||||
const statusElement = mapFrame.querySelector('[data-naver-map-status]');
|
||||
const fullscreenButton = mapFrame.querySelector('[data-naver-map-fullscreen]');
|
||||
const externalLink = mapFrame.parentElement?.querySelector('[data-naver-map-external]');
|
||||
const clientId = mapFrame.dataset.clientId;
|
||||
const latitude = Number(mapFrame.dataset.latitude);
|
||||
const longitude = Number(mapFrame.dataset.longitude);
|
||||
const label = mapFrame.dataset.label;
|
||||
let settled = false;
|
||||
|
||||
const setState = (state, message = '') => {
|
||||
settled = state !== 'loading';
|
||||
mapFrame.dataset.mapState = state;
|
||||
mapFrame.setAttribute('aria-busy', String(state === 'loading'));
|
||||
if (fullscreenButton) fullscreenButton.hidden = state !== 'ready';
|
||||
if (statusElement && message) statusElement.textContent = message;
|
||||
};
|
||||
|
||||
const fail = () => {
|
||||
setState('error', '지도를 불러오지 못했습니다. 아래 네이버 지도 링크를 이용해 주세요.');
|
||||
};
|
||||
|
||||
const renderMap = () => {
|
||||
if (!mapElement || !window.naver?.maps || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
|
||||
const position = new window.naver.maps.LatLng(latitude, longitude);
|
||||
const map = new window.naver.maps.Map(mapElement, {
|
||||
center: position,
|
||||
zoom: 17,
|
||||
minZoom: 10,
|
||||
zoomControl: true,
|
||||
zoomControlOptions: {
|
||||
position: window.naver.maps.Position.RIGHT_CENTER,
|
||||
},
|
||||
});
|
||||
|
||||
new window.naver.maps.Marker({
|
||||
map,
|
||||
position,
|
||||
title: label,
|
||||
});
|
||||
|
||||
const refreshMapSize = () => {
|
||||
window.setTimeout(() => {
|
||||
window.naver.maps.Event.trigger(map, 'resize');
|
||||
map.setCenter(position);
|
||||
}, 0);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
const isFullscreen = document.fullscreenElement === mapFrame;
|
||||
mapFrame.classList.toggle('is-fullscreen', isFullscreen);
|
||||
if (fullscreenButton) {
|
||||
fullscreenButton.setAttribute('aria-label', isFullscreen ? '전체 화면 종료' : '지도를 전체 화면으로 보기');
|
||||
fullscreenButton.title = isFullscreen ? '전체 화면 종료' : '전체 화면';
|
||||
}
|
||||
refreshMapSize();
|
||||
});
|
||||
if (fullscreenButton && !mapFrame.requestFullscreen) {
|
||||
fullscreenButton.setAttribute('aria-label', '네이버 지도에서 크게 보기');
|
||||
fullscreenButton.title = '네이버 지도에서 열기';
|
||||
}
|
||||
fullscreenButton?.addEventListener('click', async () => {
|
||||
if (document.fullscreenElement === mapFrame) {
|
||||
await document.exitFullscreen();
|
||||
} else if (mapFrame.requestFullscreen) {
|
||||
await mapFrame.requestFullscreen();
|
||||
} else if (externalLink) {
|
||||
window.open(externalLink.href, '_blank', 'noopener');
|
||||
}
|
||||
});
|
||||
setState('ready', '지도를 불러왔습니다.');
|
||||
};
|
||||
|
||||
if (!clientId) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
|
||||
setState('loading');
|
||||
window.navermap_authFailure = fail;
|
||||
const script = document.createElement('script');
|
||||
const parameters = new URLSearchParams({
|
||||
ncpKeyId: clientId,
|
||||
});
|
||||
script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?${parameters}`;
|
||||
script.async = true;
|
||||
script.onload = renderMap;
|
||||
script.onerror = fail;
|
||||
document.head.append(script);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!settled) fail();
|
||||
}, 12000);
|
||||
})();
|
||||
293
public/index.html
Normal file
@@ -0,0 +1,293 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232563eb'/%3E%3Cpath d='M16 38h32v14H16zM24 30v8m3-15a12 12 0 0 1 10 0m-16-7a21 21 0 0 1 22 0' fill='none' stroke='white' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
|
||||
<title>(주)지티에스정보통신 | 사이트 준비 중</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--primary-soft: #eff6ff;
|
||||
--text: #18181b;
|
||||
--secondary: #52525b;
|
||||
--muted: #71717a;
|
||||
--border: #e4e4e7;
|
||||
--surface: #fff;
|
||||
--background: #f8fafc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100svh;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at 12% 12%, rgba(37, 99, 235, 0.12), transparent 28rem),
|
||||
radial-gradient(circle at 88% 84%, rgba(14, 165, 233, 0.1), transparent 24rem),
|
||||
var(--background);
|
||||
font-family: Pretendard, -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.header,
|
||||
.footer {
|
||||
width: min(100% - 40px, 1120px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid rgba(228, 228, 231, 0.8);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 64px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-name em {
|
||||
color: var(--primary);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.brand-caption {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.header-call {
|
||||
color: var(--primary);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: min(100% - 40px, 1120px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto;
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: min(100%, 720px);
|
||||
padding: clamp(32px, 6vw, 64px);
|
||||
text-align: center;
|
||||
border: 1px solid rgba(228, 228, 231, 0.9);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 24px 70px rgba(30, 41, 59, 0.1);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 0 13px;
|
||||
color: var(--primary-dark);
|
||||
border-radius: 999px;
|
||||
background: var(--primary-soft);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
content: "";
|
||||
box-shadow: 0 0 0 5px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 24px 0 0;
|
||||
font-size: clamp(34px, 6vw, 58px);
|
||||
line-height: 1.14;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 22px auto 0;
|
||||
color: var(--secondary);
|
||||
font-size: clamp(16px, 2vw, 19px);
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.24);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
border-color: var(--primary-dark);
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.hours {
|
||||
margin: 20px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 24px 0 30px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.header,
|
||||
.main,
|
||||
.footer {
|
||||
width: min(100% - 32px, 1120px);
|
||||
}
|
||||
|
||||
.header {
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.brand-caption,
|
||||
.header-call {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 38px 22px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<a class="brand" href="/" aria-label="(주)지티에스정보통신 홈">
|
||||
<img class="brand-logo" src="/assets/images/company-logo-v3.png" alt="" width="64" height="30">
|
||||
<span>
|
||||
<span class="brand-name">(주)지티에스<em>정보통신</em></span>
|
||||
</span>
|
||||
</a>
|
||||
<a class="header-call" href="tel:18334917">1833-4917</a>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<section class="content" aria-labelledby="page-title">
|
||||
<span class="status">사이트 준비 중</span>
|
||||
<h1 id="page-title">더 나은 통신 환경을<br>준비하고 있습니다</h1>
|
||||
<p class="description">
|
||||
네트워크·키폰·CCTV 설계부터 시공과 유지보수까지,<br>
|
||||
(주)지티에스정보통신의 새로운 사이트로 곧 찾아뵙겠습니다.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="button button-primary" href="tel:18334917">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"></path>
|
||||
</svg>
|
||||
대표번호 1833-4917
|
||||
</a>
|
||||
<a class="button" href="tel:18334918">경기지점 1833-4918</a>
|
||||
</div>
|
||||
<p class="hours">상담시간 평일 09:00–18:00 · 주말 및 공휴일 휴무</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">© 2026 (주)지티에스정보통신. All rights reserved.</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
573
public/index.php
Normal file
@@ -0,0 +1,573 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$appRoot = (string) (getenv('GTSIT_APP_ROOT') ?: dirname(__DIR__));
|
||||
require $appRoot . '/app/bootstrap.php';
|
||||
|
||||
use App\Repositories\PostRepository;
|
||||
use App\Repositories\InquiryRepository;
|
||||
use App\Repositories\AdminRepository;
|
||||
use App\Auth\AdminAuth;
|
||||
use App\Repositories\SiteSettingRepository;
|
||||
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$basePath = (string) config('app.base_path', '');
|
||||
if ($basePath !== '') {
|
||||
if ($path !== $basePath && !str_starts_with($path, $basePath . '/')) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$path = substr($path, strlen($basePath)) ?: '/';
|
||||
}
|
||||
$path = rtrim($path, '/') ?: '/';
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
|
||||
if (PHP_SAPI === 'cli-server' && $path !== '/' && is_file(__DIR__ . $path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$posts = [];
|
||||
$databaseReady = true;
|
||||
try {
|
||||
$postRepository = new PostRepository();
|
||||
} catch (Throwable) {
|
||||
$postRepository = null;
|
||||
$databaseReady = false;
|
||||
}
|
||||
|
||||
if ($path === '/' && $method === 'GET') {
|
||||
if ($postRepository !== null) {
|
||||
$posts = $postRepository->latest(3);
|
||||
}
|
||||
render('home', [
|
||||
'title' => '사무실 통신 인프라 전문',
|
||||
'description' => '네트워크, 키폰, CCTV 설계부터 시공과 유지보수까지 책임지는 (주)지티에스정보통신입니다.',
|
||||
'posts' => $posts,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/blog' && $method === 'GET') {
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$category = trim((string) ($_GET['category'] ?? ''));
|
||||
$result = ['items' => [], 'total' => 0];
|
||||
if ($postRepository !== null) {
|
||||
$result = $postRepository->paginate($page, 6, $category ?: null);
|
||||
}
|
||||
render('blog/index', [
|
||||
'title' => '시공 이야기와 정보',
|
||||
'description' => '네트워크, 키폰, CCTV 시공 사례와 장비 선택 정보를 확인하세요.',
|
||||
'posts' => $result['items'],
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'perPage' => 6,
|
||||
'category' => $category,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (preg_match('#^/blog/([a-z0-9-]+)$#', $path, $matches) === 1 && $method === 'GET') {
|
||||
$post = $postRepository?->findPublishedBySlug($matches[1]);
|
||||
if ($post === null) {
|
||||
http_response_code(404);
|
||||
render('errors/404', ['title' => '게시글을 찾을 수 없습니다']);
|
||||
exit;
|
||||
}
|
||||
render('blog/show', [
|
||||
'title' => $post['title'],
|
||||
'description' => $post['excerpt'],
|
||||
'post' => $post,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/contact' && $method === 'GET') {
|
||||
render('contact', [
|
||||
'title' => '1:1 비공개 상담 신청',
|
||||
'description' => '네트워크, 키폰, CCTV 구축과 유지보수 상담을 신청하세요.',
|
||||
'errors' => [],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/contact' && $method === 'POST') {
|
||||
verify_csrf();
|
||||
$allowedTypes = ['네트워크 구축', '키폰시스템', 'CCTV 설치', '시공·유지보수', '기타 문의'];
|
||||
$data = [
|
||||
'name' => trim((string) ($_POST['name'] ?? '')),
|
||||
'phone' => trim((string) ($_POST['phone'] ?? '')),
|
||||
'email' => trim((string) ($_POST['email'] ?? '')),
|
||||
'type' => trim((string) ($_POST['type'] ?? '')),
|
||||
'title' => trim((string) ($_POST['title'] ?? '')),
|
||||
'body' => trim((string) ($_POST['body'] ?? '')),
|
||||
'password' => (string) ($_POST['password'] ?? ''),
|
||||
];
|
||||
$errors = [];
|
||||
if ($data['name'] === '' || mb_strlen($data['name']) > 120) $errors[] = '이름 또는 회사명을 확인해 주세요.';
|
||||
if (!preg_match('/^(?:02-\d{3,4}-\d{4}|0\d{2}-\d{3,4}-\d{4}|050\d-\d{4}-\d{4}|1\d{3}-\d{4})$/D', $data['phone'])) $errors[] = '연락처 형식을 확인해 주세요.';
|
||||
if ($data['email'] !== '' && filter_var($data['email'], FILTER_VALIDATE_EMAIL) === false) $errors[] = '이메일 형식을 확인해 주세요.';
|
||||
if (!in_array($data['type'], $allowedTypes, true)) $errors[] = '문의 유형을 선택해 주세요.';
|
||||
if ($data['title'] === '' || mb_strlen($data['title']) > 200) $errors[] = '제목을 200자 이내로 입력해 주세요.';
|
||||
if ($data['body'] === '' || mb_strlen($data['body']) > 5000) $errors[] = '문의 내용을 5,000자 이내로 입력해 주세요.';
|
||||
if (!preg_match('/^[0-9]{4}$/D', $data['password'])) $errors[] = '조회 PIN은 숫자 4자리로 입력해 주세요.';
|
||||
if (($_POST['consent'] ?? '') !== '1') $errors[] = '개인정보 수집·이용 동의가 필요합니다.';
|
||||
|
||||
$lastSubmit = (int) ($_SESSION['last_inquiry_submit'] ?? 0);
|
||||
if (time() - $lastSubmit < 20) $errors[] = '중복 접수를 방지하기 위해 잠시 후 다시 시도해 주세요.';
|
||||
|
||||
if ($errors) {
|
||||
http_response_code(422);
|
||||
render('contact', [
|
||||
'title' => '1:1 비공개 상담 신청',
|
||||
'description' => '네트워크, 키폰, CCTV 구축과 유지보수 상담을 신청하세요.',
|
||||
'errors' => $errors,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$ticket = (new InquiryRepository())->create($data);
|
||||
$_SESSION['last_inquiry_submit'] = time();
|
||||
$_SESSION['submitted_ticket'] = $ticket;
|
||||
redirect('/contact/success');
|
||||
} catch (Throwable) {
|
||||
http_response_code(503);
|
||||
render('contact', [
|
||||
'title' => '1:1 비공개 상담 신청',
|
||||
'description' => '네트워크, 키폰, CCTV 구축과 유지보수 상담을 신청하세요.',
|
||||
'errors' => ['현재 온라인 접수가 원활하지 않습니다. 대표번호로 문의해 주세요.'],
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($path === '/contact/success' && $method === 'GET') {
|
||||
$ticket = $_SESSION['submitted_ticket'] ?? null;
|
||||
if (!is_string($ticket) || $ticket === '') redirect('/contact');
|
||||
unset($_SESSION['submitted_ticket']);
|
||||
render('inquiries/success', ['title' => '문의 접수 완료', 'ticket' => $ticket]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/inquiry/lookup' && $method === 'GET') {
|
||||
render('inquiries/lookup', [
|
||||
'title' => '문의 접수 확인',
|
||||
'description' => '접수번호와 숫자 4자리 조회 PIN으로 상담 처리 상태를 확인하세요.',
|
||||
'ticket' => trim((string) ($_GET['ticket'] ?? '')),
|
||||
'error' => '',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/inquiry/lookup' && $method === 'POST') {
|
||||
verify_csrf();
|
||||
$window = $_SESSION['lookup_rate'] ?? ['started' => time(), 'attempts' => 0];
|
||||
if (time() - (int) $window['started'] > 900) $window = ['started' => time(), 'attempts' => 0];
|
||||
if ((int) $window['attempts'] >= 5) {
|
||||
http_response_code(429);
|
||||
render('inquiries/lookup', ['title'=>'문의 접수 확인','ticket'=>'','error'=>'잠시 후 다시 시도해 주세요.']);
|
||||
exit;
|
||||
}
|
||||
$ticket = strtoupper(trim((string) ($_POST['ticket'] ?? '')));
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
$inquiry = null;
|
||||
$inquiryRepository = null;
|
||||
try {
|
||||
$inquiryRepository = new InquiryRepository();
|
||||
$inquiry = $inquiryRepository->findForLookup($ticket);
|
||||
} catch (Throwable) {}
|
||||
|
||||
if ($inquiry !== null && !empty($inquiry['lookup_locked_until'])) {
|
||||
if (strtotime((string) $inquiry['lookup_locked_until']) > time()) {
|
||||
http_response_code(429);
|
||||
render('inquiries/lookup', ['title'=>'문의 접수 확인','ticket'=>$ticket,'error'=>'조회 시도가 제한되었습니다. 15분 후 다시 시도해 주세요.']);
|
||||
exit;
|
||||
}
|
||||
try { $inquiryRepository?->resetLookupLimit((int) $inquiry['id']); } catch (Throwable) {}
|
||||
}
|
||||
|
||||
$validPin = preg_match('/^[0-9]{4}$/D', $password) === 1;
|
||||
if ($inquiry === null || !$validPin || !password_verify($password, $inquiry['password_hash'])) {
|
||||
$window['attempts']++;
|
||||
$_SESSION['lookup_rate'] = $window;
|
||||
$locked = false;
|
||||
if ($inquiry !== null && $inquiryRepository instanceof InquiryRepository) {
|
||||
try { $locked = $inquiryRepository->recordLookupFailure((int) $inquiry['id']); } catch (Throwable) {}
|
||||
}
|
||||
http_response_code($locked ? 429 : 422);
|
||||
render('inquiries/lookup', [
|
||||
'title'=>'문의 접수 확인',
|
||||
'ticket'=>$ticket,
|
||||
'error'=>$locked ? '조회 시도가 제한되었습니다. 15분 후 다시 시도해 주세요.' : '접수번호 또는 조회 PIN이 올바르지 않습니다.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
try { $inquiryRepository?->resetLookupLimit((int) $inquiry['id']); } catch (Throwable) {}
|
||||
unset($_SESSION['lookup_rate'], $inquiry['id'], $inquiry['password_hash'], $inquiry['lookup_failed_attempts'], $inquiry['lookup_locked_until']);
|
||||
render('inquiries/show', ['title'=>'문의 처리 상태','description'=>'비공개 상담 처리 상태입니다.','inquiry'=>$inquiry]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/privacy' && $method === 'GET') {
|
||||
render('privacy', [
|
||||
'title' => '개인정보처리방침',
|
||||
'description' => '(주)지티에스정보통신 개인정보처리방침입니다.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/admin/setup' && $method === 'GET') {
|
||||
try {
|
||||
$adminRepository = new AdminRepository();
|
||||
if ($adminRepository->adminCount() > 0) redirect('/admin/login');
|
||||
} catch (Throwable) {
|
||||
http_response_code(503);
|
||||
render('errors/404', ['title'=>'관리자 등록을 준비할 수 없습니다'], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = (string) ($_GET['token'] ?? '');
|
||||
$tokenFile = (string) (getenv('GTSIT_ADMIN_SETUP_TOKEN_FILE') ?: '');
|
||||
if ($token !== '' && $tokenFile !== '' && is_readable($tokenFile) && time() - (int) filemtime($tokenFile) <= 86400) {
|
||||
$storedHash = trim((string) file_get_contents($tokenFile));
|
||||
if ($storedHash !== '' && hash_equals($storedHash, hash('sha256', $token))) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['admin_setup_verified'] = time();
|
||||
redirect('/admin/setup');
|
||||
}
|
||||
}
|
||||
|
||||
if (time() - (int) ($_SESSION['admin_setup_verified'] ?? 0) > 900) {
|
||||
unset($_SESSION['admin_setup_verified']);
|
||||
http_response_code(404);
|
||||
render('errors/404', ['title'=>'페이지를 찾을 수 없습니다'], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
|
||||
render('admin/setup', [
|
||||
'title'=>'관리자 계정 등록',
|
||||
'username'=>(string) (getenv('GTSIT_INITIAL_ADMIN_USERNAME') ?: 'gts0201'),
|
||||
'displayName'=>'관리자',
|
||||
'errors'=>[],
|
||||
], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/admin/setup' && $method === 'POST') {
|
||||
verify_csrf();
|
||||
if (time() - (int) ($_SESSION['admin_setup_verified'] ?? 0) > 900) {
|
||||
unset($_SESSION['admin_setup_verified']);
|
||||
http_response_code(404);
|
||||
render('errors/404', ['title'=>'페이지를 찾을 수 없습니다'], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = (string) (getenv('GTSIT_INITIAL_ADMIN_USERNAME') ?: 'gts0201');
|
||||
$displayName = trim((string) ($_POST['display_name'] ?? ''));
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
$passwordConfirmation = (string) ($_POST['password_confirmation'] ?? '');
|
||||
$errors = [];
|
||||
if ($displayName === '' || mb_strlen($displayName) > 100) $errors[] = '표시 이름을 100자 이내로 입력해 주세요.';
|
||||
if (strlen($password) < 12 || strlen($password) > 72 || !preg_match('/[A-Za-z]/', $password) || !preg_match('/[0-9]/', $password)) {
|
||||
$errors[] = '비밀번호는 영문과 숫자를 포함해 12자 이상 입력해 주세요.';
|
||||
}
|
||||
if (!hash_equals($password, $passwordConfirmation)) $errors[] = '비밀번호 확인이 일치하지 않습니다.';
|
||||
|
||||
if ($errors) {
|
||||
http_response_code(422);
|
||||
render('admin/setup', compact('username', 'displayName', 'errors') + ['title'=>'관리자 계정 등록'], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$adminRepository = new AdminRepository();
|
||||
if ($adminRepository->adminCount() > 0) redirect('/admin/login');
|
||||
$adminRepository->createAdmin($username, $displayName, $password);
|
||||
$tokenFile = (string) (getenv('GTSIT_ADMIN_SETUP_TOKEN_FILE') ?: '');
|
||||
if ($tokenFile !== '' && is_file($tokenFile)) unlink($tokenFile);
|
||||
unset($_SESSION['admin_setup_verified']);
|
||||
$_SESSION['admin_created'] = true;
|
||||
redirect('/admin/login');
|
||||
} catch (Throwable) {
|
||||
http_response_code(503);
|
||||
render('admin/setup', [
|
||||
'title'=>'관리자 계정 등록','username'=>$username,'displayName'=>$displayName,
|
||||
'errors'=>['계정을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.'],
|
||||
], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if ($path === '/admin/login' && $method === 'GET') {
|
||||
if (AdminAuth::check()) redirect('/admin');
|
||||
$notice = !empty($_SESSION['admin_created']) ? '관리자 계정이 생성되었습니다. 설정한 비밀번호로 로그인해 주세요.' : '';
|
||||
unset($_SESSION['admin_created']);
|
||||
render('admin/login', ['title'=>'관리자 로그인','error'=>'','notice'=>$notice,'username'=>'gts0201'], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/admin/login' && $method === 'POST') {
|
||||
verify_csrf();
|
||||
$username = trim((string) ($_POST['username'] ?? ''));
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
$loginRate = $_SESSION['admin_login_rate'] ?? ['started'=>time(),'attempts'=>0];
|
||||
if (time() - (int)$loginRate['started'] > 900) $loginRate = ['started'=>time(),'attempts'=>0];
|
||||
if ((int)$loginRate['attempts'] >= 10) {
|
||||
http_response_code(429);
|
||||
render('admin/login', ['title'=>'관리자 로그인','error'=>'로그인 시도가 많습니다. 잠시 후 다시 시도해 주세요.','notice'=>'','username'=>$username], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
$authenticated = false;
|
||||
try { $authenticated = (new AdminAuth())->attempt($username, $password); } catch (Throwable) {}
|
||||
if ($authenticated) {
|
||||
unset($_SESSION['admin_login_rate']);
|
||||
redirect('/admin');
|
||||
}
|
||||
$loginRate['attempts']++;
|
||||
$_SESSION['admin_login_rate'] = $loginRate;
|
||||
http_response_code(422);
|
||||
render('admin/login', ['title'=>'관리자 로그인','error'=>'아이디 또는 비밀번호를 확인해 주세요.','notice'=>'','username'=>$username], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/admin/logout' && $method === 'POST') {
|
||||
verify_csrf();
|
||||
AdminAuth::logout();
|
||||
redirect('/admin/login');
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/admin')) {
|
||||
AdminAuth::requireLogin();
|
||||
try { $adminRepository = new AdminRepository(); } catch (Throwable) {
|
||||
http_response_code(503);
|
||||
render('errors/404', ['title'=>'관리 데이터를 불러올 수 없습니다'], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/admin' && $method === 'GET') {
|
||||
render('admin/dashboard', ['title'=>'대시보드','counts'=>$adminRepository->dashboardCounts()], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if ($path === '/admin/posts' && $method === 'GET') {
|
||||
render('admin/posts/index', ['title'=>'글 관리','posts'=>$adminRepository->posts()], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if ($path === '/admin/posts/new' && $method === 'GET') {
|
||||
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>[]], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if ($path === '/admin/posts/new' && $method === 'POST') {
|
||||
verify_csrf();
|
||||
$postData = collect_post_input();
|
||||
$errors = validate_post_input($postData, $adminRepository);
|
||||
[$image, $imageError] = prepare_post_image($_FILES['image'] ?? null);
|
||||
if ($imageError !== null) $errors[] = $imageError;
|
||||
if ($image !== null && $postData['image_alt'] === '') $errors[] = '대표 이미지의 대체 텍스트를 입력해 주세요.';
|
||||
if ($errors) {
|
||||
http_response_code(422);
|
||||
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>$errors], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
$newImagePath = null;
|
||||
try {
|
||||
if ($image !== null) $newImagePath = store_post_image($image);
|
||||
$adminRepository->savePostWithImage($postData, null, $newImagePath, $postData['image_alt']);
|
||||
} catch (Throwable) {
|
||||
if ($newImagePath !== null) remove_stored_image($newImagePath);
|
||||
http_response_code(503);
|
||||
render('admin/posts/form', ['title'=>'새 글 작성','post'=>null,'errors'=>['저장하지 못했습니다. 잠시 후 다시 시도해 주세요.']], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
redirect('/admin/posts');
|
||||
}
|
||||
if (preg_match('#^/admin/posts/(\d+)/edit$#', $path, $adminPostMatch) === 1) {
|
||||
$postId = (int) $adminPostMatch[1];
|
||||
$post = $adminRepository->findPost($postId);
|
||||
if (!$post) {
|
||||
http_response_code(404);
|
||||
render('errors/404', ['title'=>'게시글을 찾을 수 없습니다'], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if ($method === 'GET') {
|
||||
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>[]], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
verify_csrf();
|
||||
$postData = collect_post_input();
|
||||
$errors = validate_post_input($postData, $adminRepository, $postId);
|
||||
[$image, $imageError] = prepare_post_image($_FILES['image'] ?? null);
|
||||
if ($imageError !== null) $errors[] = $imageError;
|
||||
if (($image !== null || !empty($post['image_path'])) && $postData['image_alt'] === '') $errors[] = '대표 이미지의 대체 텍스트를 입력해 주세요.';
|
||||
if ($errors) {
|
||||
http_response_code(422);
|
||||
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>$errors], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
$newImagePath = null;
|
||||
try {
|
||||
if ($image !== null) $newImagePath = store_post_image($image);
|
||||
$result = $adminRepository->savePostWithImage($postData, $postId, $newImagePath, $postData['image_alt']);
|
||||
foreach ($result['old_paths'] as $oldPath) remove_stored_image((string) $oldPath);
|
||||
} catch (Throwable) {
|
||||
if ($newImagePath !== null) remove_stored_image($newImagePath);
|
||||
http_response_code(503);
|
||||
render('admin/posts/form', ['title'=>'글 수정','post'=>$post,'errors'=>['저장하지 못했습니다. 잠시 후 다시 시도해 주세요.']], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
redirect('/admin/posts');
|
||||
}
|
||||
}
|
||||
if (preg_match('#^/admin/posts/(\d+)/delete$#', $path, $adminDeleteMatch) === 1 && $method === 'POST') {
|
||||
verify_csrf();
|
||||
$adminRepository->deletePost((int) $adminDeleteMatch[1]);
|
||||
redirect('/admin/posts');
|
||||
}
|
||||
if ($path === '/admin/inquiries' && $method === 'GET') {
|
||||
render('admin/inquiries/index', ['title'=>'문의 관리','inquiries'=>$adminRepository->inquiries()], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if (preg_match('#^/admin/inquiries/(\d+)$#', $path, $adminInquiryMatch) === 1) {
|
||||
$inquiryId = (int) $adminInquiryMatch[1];
|
||||
$inquiry = $adminRepository->findInquiry($inquiryId);
|
||||
if (!$inquiry) {
|
||||
http_response_code(404);
|
||||
render('errors/404', ['title'=>'문의를 찾을 수 없습니다'], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if ($method === 'GET') {
|
||||
render('admin/inquiries/show', ['title'=>'문의 상세','inquiry'=>$inquiry], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
verify_csrf();
|
||||
$allowedStatuses = ['pending','in_progress','replied','closed'];
|
||||
$status = (string) ($_POST['status'] ?? 'pending');
|
||||
$reply = trim((string) ($_POST['reply'] ?? ''));
|
||||
if (!in_array($status, $allowedStatuses, true) || mb_strlen($reply) > 5000) {
|
||||
http_response_code(422);
|
||||
render('admin/inquiries/show', ['title'=>'문의 상세','inquiry'=>$inquiry], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if ($reply !== '' && $status === 'pending') $status = 'replied';
|
||||
$adminRepository->updateInquiry($inquiryId, $status, $reply);
|
||||
redirect('/admin/inquiries/' . $inquiryId);
|
||||
}
|
||||
}
|
||||
if ($path === '/admin/company') {
|
||||
$defaults = [
|
||||
'legal_name'=>(string)config('app.legal_name'),'ceo'=>'서선배',
|
||||
'phone'=>(string)config('app.contact.phone'),'phone_sub'=>(string)config('app.contact.phone_sub'),
|
||||
'fax'=>(string)config('app.contact.fax'),'email'=>(string)config('app.contact.email'),
|
||||
'address'=>(string)config('app.contact.address'),'bizno'=>'206-86-70582',
|
||||
'business_hours'=>(string)config('app.contact.business_hours'),
|
||||
];
|
||||
$settingRepository = new SiteSettingRepository();
|
||||
$settings = array_merge($defaults, $settingRepository->all());
|
||||
if ($method === 'GET') {
|
||||
render('admin/company', ['title'=>'회사 정보','settings'=>$settings,'errors'=>[]], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
verify_csrf();
|
||||
$submitted = [];
|
||||
foreach (array_keys($defaults) as $key) $submitted[$key] = trim((string)($_POST[$key]??''));
|
||||
$errors = [];
|
||||
if ($submitted['legal_name']==='' || mb_strlen($submitted['legal_name'])>120) $errors[]='법인명을 확인해 주세요.';
|
||||
if (!preg_match('/^[0-9+() -]{8,30}$/',$submitted['phone'])) $errors[]='대표번호를 확인해 주세요.';
|
||||
if ($submitted['email']!=='' && filter_var($submitted['email'],FILTER_VALIDATE_EMAIL)===false) $errors[]='이메일 형식을 확인해 주세요.';
|
||||
if (mb_strlen($submitted['address'])>255) $errors[]='주소를 255자 이내로 입력해 주세요.';
|
||||
if ($errors) {
|
||||
http_response_code(422);
|
||||
render('admin/company', ['title'=>'회사 정보','settings'=>$settings,'errors'=>$errors], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
$settingRepository->save($submitted);
|
||||
redirect('/admin/company');
|
||||
}
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
render('errors/404', ['title'=>'관리자 페이지를 찾을 수 없습니다'], 'layouts/admin');
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
render('errors/404', ['title' => '페이지를 찾을 수 없습니다']);
|
||||
|
||||
function collect_post_input(): array
|
||||
{
|
||||
return [
|
||||
'title'=>trim((string)($_POST['title']??'')),
|
||||
'slug'=>trim((string)($_POST['slug']??'')),
|
||||
'category'=>trim((string)($_POST['category']??'')),
|
||||
'excerpt'=>trim((string)($_POST['excerpt']??'')),
|
||||
'body'=>trim((string)($_POST['body']??'')),
|
||||
'author'=>trim((string)($_POST['author']??'')),
|
||||
'status'=>(string)($_POST['status']??'draft'),
|
||||
'published_at'=>'',
|
||||
'image_alt'=>trim((string)($_POST['image_alt']??'')),
|
||||
];
|
||||
}
|
||||
|
||||
function validate_post_input(array $data, AdminRepository $repository, ?int $exceptId = null): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($data['title']==='' || mb_strlen($data['title'])>200) $errors[]='제목을 200자 이내로 입력해 주세요.';
|
||||
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $data['slug'])) $errors[]='slug는 영문 소문자, 숫자, 하이픈으로 입력해 주세요.';
|
||||
elseif ($repository->slugExists($data['slug'], $exceptId)) $errors[]='이미 사용 중인 slug입니다.';
|
||||
if (!in_array($data['category'], ['네트워크','키폰시스템','CCTV','시공사례'], true)) $errors[]='카테고리를 확인해 주세요.';
|
||||
if (mb_strlen($data['excerpt'])>500) $errors[]='목록 요약을 500자 이내로 입력해 주세요.';
|
||||
if ($data['body']==='') $errors[]='본문을 입력해 주세요.';
|
||||
if ($data['author']==='' || mb_strlen($data['author'])>100) $errors[]='작성자를 확인해 주세요.';
|
||||
if (!in_array($data['status'], ['draft','published'], true)) $errors[]='공개 상태를 확인해 주세요.';
|
||||
if (mb_strlen($data['image_alt'])>255) $errors[]='이미지 대체 텍스트를 255자 이내로 입력해 주세요.';
|
||||
return $errors;
|
||||
}
|
||||
|
||||
function prepare_post_image(?array $file): array
|
||||
{
|
||||
if ($file === null || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return [null, null];
|
||||
if (!isset($file['error'], $file['size'], $file['tmp_name']) || is_array($file['error']) || is_array($file['size']) || is_array($file['tmp_name'])) {
|
||||
return [null, '이미지 업로드 요청이 올바르지 않습니다.'];
|
||||
}
|
||||
if ((int) $file['error'] !== UPLOAD_ERR_OK) return [null, '이미지를 업로드하지 못했습니다. 파일 크기를 확인해 주세요.'];
|
||||
if ((int) $file['size'] <= 0 || (int) $file['size'] > 8 * 1024 * 1024) return [null, '대표 이미지는 8MB 이하만 등록할 수 있습니다.'];
|
||||
|
||||
$tmpName = (string) $file['tmp_name'];
|
||||
if (!is_uploaded_file($tmpName)) return [null, '업로드된 이미지 파일을 확인할 수 없습니다.'];
|
||||
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmpName);
|
||||
$extensions = ['image/jpeg'=>'jpg', 'image/png'=>'png', 'image/webp'=>'webp'];
|
||||
if (!is_string($mime) || !isset($extensions[$mime])) return [null, 'JPG, PNG, WebP 이미지만 등록할 수 있습니다.'];
|
||||
$dimensions = getimagesize($tmpName);
|
||||
if ($dimensions === false || $dimensions[0] < 1 || $dimensions[1] < 1 || $dimensions[0] > 6000 || $dimensions[1] > 6000) {
|
||||
return [null, '이미지 크기는 최대 6000×6000px까지 등록할 수 있습니다.'];
|
||||
}
|
||||
if (($dimensions['mime'] ?? '') !== $mime) return [null, '이미지 파일의 형식이 올바르지 않습니다.'];
|
||||
|
||||
return [['tmp_name'=>$tmpName, 'extension'=>$extensions[$mime]], null];
|
||||
}
|
||||
|
||||
function store_post_image(array $image): string
|
||||
{
|
||||
$relativeDirectory = '/uploads/posts/' . date('Y/m');
|
||||
$publicRoot = rtrim((string) config('app.public_root', __DIR__), '/');
|
||||
$absoluteDirectory = $publicRoot . $relativeDirectory;
|
||||
if (!is_dir($absoluteDirectory) && !mkdir($absoluteDirectory, 0750, true) && !is_dir($absoluteDirectory)) {
|
||||
throw new RuntimeException('Upload directory could not be created.');
|
||||
}
|
||||
$relativePath = $relativeDirectory . '/' . bin2hex(random_bytes(20)) . '.' . $image['extension'];
|
||||
if (!move_uploaded_file($image['tmp_name'], $publicRoot . $relativePath)) {
|
||||
throw new RuntimeException('Uploaded file could not be moved.');
|
||||
}
|
||||
chmod($publicRoot . $relativePath, 0640);
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
function remove_stored_image(string $path): void
|
||||
{
|
||||
if (!str_starts_with($path, '/uploads/posts/')) return;
|
||||
$absolutePath = rtrim((string) config('app.public_root', __DIR__), '/') . $path;
|
||||
if (is_file($absolutePath)) unlink($absolutePath);
|
||||
}
|
||||
11
public/uploads/.htaccess
Normal file
@@ -0,0 +1,11 @@
|
||||
Options -Indexes
|
||||
|
||||
<FilesMatch "\.(php[0-9]?|phtml|phar|cgi|pl|py|sh)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||