Files
myProfile/profile.html

1111 lines
36 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="이종재 프로필 — Game & XR Developer">
<meta property="og:title" content="Profile | 이종재">
<meta property="og:description" content="Game & XR Developer 이종재 소개 및 기술 스택">
<meta property="og:type" content="website">
<meta property="og:url" content="https://portfolio.whdwo798.synology.me/profile.html">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Profile | 이종재">
<meta name="twitter:description" content="Game & XR Developer 이종재 소개 및 기술 스택">
<title>Profile | 이종재</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Noto+Sans+KR:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="style.css">
<style>
/* ===== 라이트 모드 (기본) ===== */
.profile-hero {
min-height: 70vh;
display: grid;
grid-template-columns: auto 1fr;
gap: 4rem;
align-items: center;
padding: 80px 8%;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.avatar-wrap {
position: relative; width: 240px; height: 240px;
}
.avatar {
width: 100%; height: 100%; border-radius: 50%;
object-fit: cover;
border: 3px solid var(--primary);
background: var(--bg-deep);
display: flex; align-items: center; justify-content: center;
font-size: 6rem; color: var(--border-card);
box-shadow: 0 0 30px var(--border-card);
}
.avatar-ring {
position: absolute; inset: -12px;
border: 1px dashed var(--border-card);
border-radius: 50%;
animation: rotate 30s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.profile-info .label-mono {
color: var(--primary); font-size: 0.8rem; margin-bottom: 1rem; display: block;
}
.profile-info h1 {
font-size: 3.5rem; margin-bottom: 0.5rem;
text-shadow: none;
}
.profile-info .role {
font-size: 1.3rem; color: var(--primary);
font-family: 'Orbitron', sans-serif;
margin-bottom: 1.5rem; font-weight: 400; letter-spacing: 2px;
}
.profile-info .tagline {
font-size: 1.1rem; color: var(--text-dim);
max-width: 600px; margin-bottom: 2rem;
}
.profile-meta {
display: flex; gap: 1.5rem; flex-wrap: wrap;
color: var(--text-dim); font-size: 0.9rem;
}
.profile-meta span { display: flex; align-items: center; gap: 8px; }
.profile-meta i { color: var(--primary); }
section {
padding: 80px 8%;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.section-header { display: flex; align-items: center; gap: 20px; margin-bottom: 3rem; }
.section-header h2 { font-size: 2rem; }
.line { flex-grow: 1; height: 1px; background: var(--border-card); }
.admin-controls { display: flex; gap: 10px; }
.admin-controls.hidden { display: none !important; }
.about-text {
font-size: 1.05rem; color: var(--text-dim);
max-width: 850px; line-height: 2;
}
/* ===== 기술 스택 (카테고리화) ===== */
.skills-categories {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.skill-category {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.6rem;
transition: 0.3s;
}
.skill-category:hover {
border-color: var(--border-card);
transform: translateY(-3px);
}
.skill-category-title {
color: var(--primary);
font-family: 'Orbitron', sans-serif;
font-size: 0.85rem;
letter-spacing: 2px;
margin-bottom: 1rem;
padding-bottom: 0.7rem;
border-bottom: 1px dashed var(--border-card);
display: flex;
align-items: center;
gap: 8px;
}
.skill-category-title::before {
content: '';
display: inline-block;
width: 6px; height: 6px;
background: var(--primary);
border-radius: 50%;
box-shadow: 0 0 8px var(--primary);
}
.skill-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.skill-tag {
background: var(--bg-deep);
color: var(--text);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.85rem;
border: 1px solid var(--border);
transition: 0.2s;
}
.skill-tag:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-dim);
transform: translateY(-2px);
}
/* ===== 타임라인 ===== */
.timeline {
position: relative;
max-width: 800px;
margin: 0 auto;
padding-left: 30px;
}
.timeline::before {
content: '';
position: absolute;
left: 8px; top: 8px; bottom: 8px;
width: 1px;
background: var(--primary);
}
.timeline-item {
position: relative;
padding: 0 0 2.5rem 30px;
}
.timeline-item::before {
content: '';
position: absolute;
left: -27px; top: 8px;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--primary);
border: 2px solid var(--primary);
box-shadow: none;
}
.timeline-item .year {
color: var(--primary);
font-family: 'Orbitron', sans-serif;
font-size: 0.9rem; letter-spacing: 2px;
margin-bottom: 6px;
}
.timeline-item h4 { font-size: 1.2rem; margin-bottom: 6px; }
.timeline-item p { color: var(--text-dim); font-size: 0.95rem; }
/* ===== 연락처 ===== */
.contact-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
max-width: 900px;
}
.contact-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.8rem;
text-align: center;
text-decoration: none;
color: var(--text);
transition: 0.3s;
display: block;
}
.contact-card:hover {
transform: translateY(-5px);
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 5%, transparent);
}
.contact-card i { font-size: 2rem; color: var(--primary); margin-bottom: 1rem; }
.contact-card .label {
font-family: 'Orbitron', sans-serif; font-size: 0.75rem;
color: var(--text-dim); letter-spacing: 2px;
margin-bottom: 6px; display: block;
}
.contact-card .value { font-size: 0.95rem; word-break: break-all; }
/* ===== 이메일 액션 모달 ===== */
.email-action-list {
display: flex;
flex-direction: column;
gap: 10px;
margin: 1rem 0;
}
.email-action-btn {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
cursor: pointer;
text-align: left;
transition: 0.2s;
text-decoration: none;
font-family: 'Noto Sans KR', sans-serif;
font-size: 0.95rem;
width: 100%;
}
.email-action-btn:hover {
background: var(--primary-dim);
border-color: var(--primary);
transform: translateX(4px);
}
.email-action-btn i {
font-size: 1.2rem;
color: var(--primary);
width: 28px;
text-align: center;
}
.email-action-btn .label {
flex: 1;
}
.email-action-btn .label-mono-sm {
color: var(--text-dim);
font-size: 0.75rem;
font-family: 'Orbitron', sans-serif;
letter-spacing: 1px;
margin-top: 2px;
}
.email-display {
background: var(--bg-deep);
border: 1px solid var(--border-card);
border-radius: 8px;
padding: 14px 16px;
margin: 1rem 0;
text-align: center;
color: var(--primary);
font-family: 'JetBrains Mono', monospace;
font-size: 1rem;
word-break: break-all;
}
.copy-toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--primary);
color: var(--bg);
padding: 12px 24px;
border-radius: 8px;
font-family: 'Orbitron', sans-serif;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 1px;
box-shadow: 0 4px 20px var(--shadow);
z-index: 3000;
transition: transform 0.3s ease;
pointer-events: none;
}
.copy-toast.show {
transform: translateX(-50%) translateY(0);
}
/* ===== 모달 ===== */
/* 동적 행 (스킬 카테고리 / 타임라인) */
.skill-cat-editor {
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
}
.skill-cat-header {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
margin-bottom: 10px;
}
.skill-cat-header input {
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid var(--border-card);
border-radius: 6px;
color: var(--text);
font-family: 'Orbitron', sans-serif;
font-size: 0.9rem;
letter-spacing: 1px;
}
.skill-cat-items {
display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 0;
min-height: 36px;
}
.skill-item-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-card);
border: 1px solid var(--border-card);
color: var(--text);
padding: 5px 10px;
border-radius: 14px;
font-size: 0.85rem;
}
.skill-item-chip button {
background: none; border: none;
color: var(--text-dim); cursor: pointer;
padding: 0; font-size: 0.7rem;
display: flex; align-items: center;
}
.skill-item-chip button:hover { color: var(--danger); }
.skill-cat-add-input {
flex: 1;
min-width: 100px;
background: transparent;
border: 1px dashed var(--border);
border-radius: 14px;
color: var(--text);
padding: 5px 10px;
font-size: 0.85rem;
font-family: 'Noto Sans KR', sans-serif;
}
.skill-cat-add-input:focus {
outline: none;
border-color: var(--primary);
border-style: solid;
}
.dynamic-row {
display: grid;
grid-template-columns: 100px 1fr auto;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.dynamic-row input { padding: 8px; font-size: 0.9rem; }
.remove-btn {
background: transparent;
border: 1px solid var(--danger);
color: var(--danger);
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: 0.3s;
}
.remove-btn:hover { background: var(--danger); color: #fff; }
.add-row-btn {
background: transparent;
border: 1px dashed var(--primary);
color: var(--primary);
padding: 10px;
border-radius: 6px;
cursor: pointer;
width: 100%;
font-family: 'Orbitron', sans-serif;
font-size: 0.8rem;
letter-spacing: 1px;
margin-top: 8px;
}
.add-row-btn:hover { background: var(--primary-dim); }
.timeline-detail {
margin-top: 6px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--border);
margin-bottom: 8px;
}
.timeline-detail:last-child { border-bottom: none; }
.timeline-detail input.timeline-desc {
width: 100%;
padding: 8px;
margin-top: 6px;
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-family: 'Noto Sans KR', sans-serif;
font-size: 0.85rem;
}
footer {
padding: 60px 8% 30px;
text-align: center;
color: var(--text-dim);
font-size: 0.9rem;
}
@media (max-width: 768px) {
.profile-hero {
grid-template-columns: 1fr; text-align: center;
gap: 1.8rem; padding: 50px 5%; min-height: auto;
}
.avatar-wrap { margin: 0 auto; width: 160px; height: 160px; }
.avatar { font-size: 4rem; }
.profile-info .label-mono { font-size: 0.7rem; }
.profile-info h1 { font-size: 1.9rem; letter-spacing: 1px; }
.profile-info .role { font-size: 1rem; letter-spacing: 1.5px; }
.profile-info .tagline { font-size: 0.95rem; margin-bottom: 1.2rem; }
.profile-meta { justify-content: center; gap: 1rem; font-size: 0.8rem; }
section { padding: 50px 5%; }
.section-header { flex-wrap: wrap; gap: 12px; margin-bottom: 2rem; }
.section-header h2 { font-size: 1.5rem; }
.admin-controls { width: 100%; }
.admin-controls .btn { flex: 1; font-size: 0.7rem; justify-content: center; }
.about-text { font-size: 0.95rem; line-height: 1.8; }
.skills-categories { grid-template-columns: 1fr; gap: 1rem; }
.skill-category { padding: 1.3rem; }
.timeline { padding-left: 20px; }
.timeline-item { padding: 0 0 2rem 24px; }
.timeline-item h4 { font-size: 1.05rem; }
.timeline-item p { font-size: 0.9rem; }
.contact-grid { grid-template-columns: 1fr 1fr; gap: 1rem; }
.contact-card { padding: 1.4rem 1rem; }
.contact-card i { font-size: 1.6rem; }
.contact-card .label { font-size: 0.65rem; }
.contact-card .value { font-size: 0.8rem; }
.modal { padding: 1.4rem; max-height: 92vh; border-radius: 0.8rem; }
.modal h2 { font-size: 1.15rem; margin-bottom: 1.2rem; }
.form-row { grid-template-columns: 1fr; gap: 0; }
.form-group label { font-size: 0.75rem; }
.form-group input, .form-group textarea { padding: 10px; font-size: 0.9rem; }
.modal-actions { flex-direction: column-reverse; gap: 8px; }
.modal-actions .btn { width: 100%; justify-content: center; padding: 0.8rem; }
.dynamic-row { grid-template-columns: 70px 1fr auto; gap: 6px; }
.dynamic-row input { font-size: 0.85rem; padding: 8px; }
.remove-btn { padding: 8px 10px; font-size: 0.85rem; }
footer { padding: 40px 5% 20px; font-size: 0.8rem; }
}
@media (max-width: 380px) {
.avatar-wrap { width: 140px; height: 140px; }
.avatar { font-size: 3.5rem; }
.profile-info h1 { font-size: 1.6rem; }
.profile-meta { flex-direction: column; align-items: center; gap: 6px; }
.contact-grid { grid-template-columns: 1fr; }
.section-header h2 { font-size: 1.3rem; }
}
</style>
</head>
<body>
<nav>
<a href="index.html" class="logo">JONGJAE.XR</a>
<div class="links">
<a href="index.html">PROJECTS</a>
<a href="learning.html">LEARNING</a>
<a href="profile.html" class="nav-active">PROFILE</a>
<button class="theme-toggle" onclick="toggleTheme()" title="테마 전환" aria-label="테마 전환" id="themeToggleBtn"><i class="fa-solid fa-moon"></i></button>
</div>
</nav>
<section class="profile-hero">
<div class="avatar-wrap">
<div class="avatar-ring"></div>
<div class="avatar" id="avatarEl">
<i class="fa-solid fa-user-astronaut"></i>
</div>
</div>
<div class="profile-info">
<span class="label-mono">// PROFILE</span>
<h1 id="profileName" style="opacity:0;transition:opacity 0.3s"></h1>
<div class="role" id="profileTitle"></div>
<p class="tagline" id="profileTagline"></p>
<div class="profile-meta">
<span id="metaLocation"><i class="fa-solid fa-location-dot"></i> <span></span></span>
<span id="metaEmail"><i class="fa-solid fa-envelope"></i> <span></span></span>
</div>
</div>
</section>
<section>
<div class="section-header">
<h2>ABOUT</h2>
<div class="line"></div>
<div class="admin-controls hidden" id="adminControls1">
<button class="btn btn-outline" onclick="openEditModal()">
<i class="fa-solid fa-pen"></i> 프로필 편집
</button>
</div>
</div>
<p class="about-text" id="bioText"></p>
</section>
<section>
<div class="section-header">
<h2>TECH STACK</h2>
<div class="line"></div>
</div>
<div class="skills-categories" id="skillsContainer"></div>
</section>
<section>
<div class="section-header">
<h2>JOURNEY</h2>
<div class="line"></div>
</div>
<div class="timeline" id="timelineEl"></div>
</section>
<section>
<div class="section-header">
<h2>CONTACT</h2>
<div class="line"></div>
</div>
<div class="contact-grid" id="contactGrid"></div>
</section>
<footer>
<p>&copy; 2026 Lee Jong-jae. Hosted on Private Synology NAS.</p>
</footer>
<!-- 이메일 액션 모달 -->
<div class="modal-overlay" id="emailActionModal" role="dialog" aria-modal="true" aria-label="이메일 전송 방법 선택">
<div class="modal" style="max-width: 460px;">
<button class="modal-close-x" onclick="closeEmailActionModal()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2><i class="fa-solid fa-envelope"></i> CONTACT</h2>
<div class="email-display" id="emailDisplay"></div>
<div class="email-action-list">
<a class="email-action-btn" id="emailMailtoBtn" href="#">
<i class="fa-solid fa-paper-plane"></i>
<div class="label">
메일 보내기
<div class="label-mono-sm">기본 메일 앱 열기</div>
</div>
</a>
<a class="email-action-btn" id="emailGmailBtn" href="#" target="_blank">
<i class="fa-brands fa-google"></i>
<div class="label">
Gmail로 보내기
<div class="label-mono-sm">웹 브라우저로 작성</div>
</div>
</a>
<button class="email-action-btn" onclick="copyEmail()">
<i class="fa-solid fa-copy"></i>
<div class="label">
이메일 주소 복사
<div class="label-mono-sm">클립보드에 복사</div>
</div>
</button>
</div>
</div>
</div>
<!-- 복사 알림 토스트 -->
<div class="copy-toast" id="copyToast">
<i class="fa-solid fa-check"></i> 복사되었습니다
</div>
<!-- 편집 모달 -->
<div class="modal-overlay" id="editModal" role="dialog" aria-modal="true" aria-label="프로필 편집">
<div class="modal">
<button class="modal-close-x" onclick="closeEditModal()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2><i class="fa-solid fa-pen-to-square"></i> 프로필 편집</h2>
<div id="editAlert"></div>
<h3 style="font-size: 0.9rem; color: var(--primary); margin-bottom: 1rem;">// 기본 정보</h3>
<div class="form-row">
<div class="form-group">
<label>이름</label>
<input type="text" id="editName">
</div>
<div class="form-group">
<label>직함</label>
<input type="text" id="editTitle">
</div>
</div>
<div class="form-group">
<label>한 줄 소개</label>
<input type="text" id="editTagline">
</div>
<div class="form-group">
<label>아바타 이미지 URL (비우면 기본 아이콘)</label>
<input type="text" id="editAvatar" placeholder="uploads/your-photo.jpg 또는 https://...">
</div>
<div class="form-group">
<label>자기소개</label>
<textarea id="editBio"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>지역</label>
<input type="text" id="editLocation">
</div>
<div class="form-group">
<label>이메일</label>
<input type="email" id="editEmail">
</div>
</div>
<h3 style="font-size: 0.9rem; color: var(--primary); margin: 1.5rem 0 1rem;">// 기술 스택</h3>
<p style="color: var(--text-dim); font-size: 0.8rem; margin-bottom: 1rem;">
<i class="fa-solid fa-info-circle"></i> 카테고리별로 기술을 묶어서 관리하세요. 각 칸에 입력 후 Enter로 추가됩니다.
</p>
<div id="skillsEditor"></div>
<button class="add-row-btn" onclick="addSkillCategory()">
<i class="fa-solid fa-plus"></i> 카테고리 추가
</button>
<h3 style="font-size: 0.9rem; color: var(--primary); margin: 1.5rem 0 1rem;">// 타임라인</h3>
<div id="timelineEditor"></div>
<button class="add-row-btn" onclick="addTimelineRow()">
<i class="fa-solid fa-plus"></i> 타임라인 추가
</button>
<h3 style="font-size: 0.9rem; color: var(--primary); margin: 1.5rem 0 1rem;">// SNS / 링크</h3>
<div class="form-group">
<label>GitHub / Gitea URL</label>
<input type="text" id="editGithub">
</div>
<div class="form-group">
<label>LinkedIn URL</label>
<input type="text" id="editLinkedin">
</div>
<div class="form-group">
<label>Blog URL</label>
<input type="text" id="editBlog">
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeEditModal()">취소</button>
<button class="btn btn-primary" onclick="saveProfile()">
<i class="fa-solid fa-floppy-disk"></i> 저장
</button>
</div>
</div>
</div>
<script>
// ===== 테마 =====
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('portfolio-theme', theme);
const btn = document.getElementById('themeToggleBtn');
if (btn) {
btn.innerHTML = theme === 'dark'
? '<i class="fa-solid fa-sun"></i>'
: '<i class="fa-solid fa-moon"></i>';
btn.title = theme === 'dark' ? '라이트 모드로' : '다크 모드로';
}
}
function toggleTheme() {
const curr = document.documentElement.getAttribute('data-theme');
applyTheme(curr === 'dark' ? 'light' : 'dark');
}
(function() {
const saved = localStorage.getItem('portfolio-theme');
const prefer = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
applyTheme(saved || prefer);
})();
let isAdmin = false;
let csrfToken = '';
let profile = null;
async function init() {
await checkAuth();
await loadProfile();
}
async function checkAuth() {
try {
const res = await fetch('api/auth.php?action=check');
const data = await res.json();
isAdmin = data.authenticated === true;
csrfToken = data.csrf_token || '';
document.querySelectorAll('.admin-controls').forEach(el => {
el.classList.toggle('hidden', !isAdmin);
});
} catch (e) { isAdmin = false; }
}
function csrfHeaders(headers = {}) {
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
}
function csrfFetch(url, options = {}) {
return fetch(url, { ...options, headers: csrfHeaders(options.headers || {}) });
}
async function loadProfile() {
try {
const res = await fetch('api/profile.php');
profile = await res.json();
renderProfile();
} catch (e) {
console.error('Failed to load profile', e);
}
}
function renderProfile() {
if (!profile) return;
const nameEl = document.getElementById('profileName');
nameEl.textContent = profile.name || '';
nameEl.style.opacity = '1';
document.getElementById('profileTitle').textContent = profile.title || '';
document.getElementById('profileTagline').textContent = profile.tagline || '';
const avatarEl = document.getElementById('avatarEl');
if (profile.avatar) {
avatarEl.style.backgroundImage = `url('${escapeAttr(sanitizeUrl(profile.avatar))}')`;
avatarEl.style.backgroundSize = 'cover';
avatarEl.style.backgroundPosition = 'center';
avatarEl.innerHTML = '';
} else {
avatarEl.style.backgroundImage = '';
avatarEl.innerHTML = '<i class="fa-solid fa-user-astronaut"></i>';
}
const metaLoc = document.getElementById('metaLocation');
const metaEmail = document.getElementById('metaEmail');
if (profile.location) {
metaLoc.querySelector('span').textContent = profile.location;
metaLoc.style.display = 'flex';
} else {
metaLoc.style.display = 'none';
}
if (profile.email) {
metaEmail.querySelector('span').textContent = profile.email;
metaEmail.style.display = 'flex';
} else {
metaEmail.style.display = 'none';
}
document.getElementById('bioText').textContent = profile.bio || '';
// 스킬 (카테고리화)
const skillsContainer = document.getElementById('skillsContainer');
const skills = profile.skills || [];
if (skills.length === 0) {
skillsContainer.innerHTML = '<p style="color: var(--text-dim); font-size: 0.9rem;">아직 등록된 기술 스택이 없습니다.</p>';
} else {
skillsContainer.innerHTML = skills.map(cat => `
<div class="skill-category">
<div class="skill-category-title">${escapeHtml(cat.category || '기타')}</div>
<div class="skill-tags">
${(cat.items || []).map(item => `
<span class="skill-tag">${escapeHtml(item)}</span>
`).join('')}
</div>
</div>
`).join('');
}
// 타임라인
const timelineEl = document.getElementById('timelineEl');
timelineEl.innerHTML = (profile.timeline || []).map(t => `
<div class="timeline-item">
<div class="year">${escapeHtml(t.year || '')}</div>
<h4>${escapeHtml(t.title || '')}</h4>
<p>${escapeHtml(t.description || '')}</p>
</div>
`).join('');
// 연락처
const contactGrid = document.getElementById('contactGrid');
const contacts = [];
if (profile.email) {
contacts.push({
icon: 'fa-solid fa-envelope',
label: 'EMAIL',
value: profile.email,
isEmail: true
});
}
if (profile.social?.github) {
contacts.push({ icon: 'fa-brands fa-git-alt', label: 'GIT REPOSITORY', value: profile.social.github.replace(/^https?:\/\//, ''), href: profile.social.github });
}
if (profile.social?.linkedin) {
contacts.push({ icon: 'fa-brands fa-linkedin', label: 'LINKEDIN', value: profile.social.linkedin.replace(/^https?:\/\//, ''), href: profile.social.linkedin });
}
if (profile.social?.blog) {
contacts.push({ icon: 'fa-solid fa-blog', label: 'BLOG', value: profile.social.blog.replace(/^https?:\/\//, ''), href: profile.social.blog });
}
contactGrid.innerHTML = contacts.map(c => {
if (c.isEmail) {
return `
<a class="contact-card" onclick="openEmailActionModal(); return false;" href="javascript:void(0)" style="cursor: pointer;">
<i class="${c.icon}"></i>
<span class="label">${c.label}</span>
<span class="value">${escapeHtml(c.value)}</span>
</a>
`;
}
const safeHref = sanitizeUrl(c.href);
if (!safeHref) return '';
return `
<a href="${escapeAttr(safeHref)}" class="contact-card" target="_blank" rel="noopener noreferrer">
<i class="${c.icon}"></i>
<span class="label">${c.label}</span>
<span class="value">${escapeHtml(c.value)}</span>
</a>
`;
}).join('');
}
// ===== 이메일 액션 모달 =====
function openEmailActionModal() {
if (!profile || !profile.email) return;
const email = profile.email;
document.getElementById('emailDisplay').textContent = email;
document.getElementById('emailMailtoBtn').href = `mailto:${email}`;
document.getElementById('emailGmailBtn').href =
`https://mail.google.com/mail/?view=cm&fs=1&to=${encodeURIComponent(email)}`;
document.getElementById('emailActionModal').classList.add('active');
}
function closeEmailActionModal() {
document.getElementById('emailActionModal').classList.remove('active');
}
async function copyEmail() {
if (!profile || !profile.email) return;
const email = profile.email;
try {
// 최신 브라우저
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(email);
} else {
// 구형 브라우저 fallback
const textArea = document.createElement('textarea');
textArea.value = email;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
showCopyToast();
// 모달은 살짝 후 닫기
setTimeout(() => closeEmailActionModal(), 800);
} catch (e) {
alert('복사 실패: ' + email);
}
}
function showCopyToast() {
const toast = document.getElementById('copyToast');
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
}
// ===== 편집 모달 =====
function openEditModal() {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
document.getElementById('editAlert').innerHTML = '';
document.getElementById('editName').value = profile.name || '';
document.getElementById('editTitle').value = profile.title || '';
document.getElementById('editTagline').value = profile.tagline || '';
document.getElementById('editAvatar').value = profile.avatar || '';
document.getElementById('editBio').value = profile.bio || '';
document.getElementById('editLocation').value = profile.location || '';
document.getElementById('editEmail').value = profile.email || '';
document.getElementById('editGithub').value = profile.social?.github || '';
document.getElementById('editLinkedin').value = profile.social?.linkedin || '';
document.getElementById('editBlog').value = profile.social?.blog || '';
// 스킬 카테고리 에디터
const skillsEditor = document.getElementById('skillsEditor');
skillsEditor.innerHTML = '';
(profile.skills || []).forEach(cat => addSkillCategory(cat));
// 타임라인 에디터
const timelineEditor = document.getElementById('timelineEditor');
timelineEditor.innerHTML = '';
(profile.timeline || []).forEach(t => addTimelineRow(t));
document.getElementById('editModal').classList.add('active');
}
function closeEditModal() {
document.getElementById('editModal').classList.remove('active');
}
// ===== 스킬 카테고리 에디터 =====
function addSkillCategory(data = null) {
const editor = document.getElementById('skillsEditor');
const div = document.createElement('div');
div.className = 'skill-cat-editor';
div.innerHTML = `
<div class="skill-cat-header">
<input type="text" placeholder="카테고리명 (예: 언어, AI, 엔진)"
value="${escapeAttr(data?.category || '')}" data-cat>
<button type="button" class="remove-btn" onclick="this.closest('.skill-cat-editor').remove()" title="카테고리 삭제">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="skill-cat-items"></div>
`;
editor.appendChild(div);
const itemsWrap = div.querySelector('.skill-cat-items');
// 기존 아이템들
(data?.items || []).forEach(item => addSkillChip(itemsWrap, item));
// 입력창
const input = document.createElement('input');
input.type = 'text';
input.className = 'skill-cat-add-input';
input.placeholder = '+ 추가 (Enter)';
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const v = input.value.trim();
if (v) {
addSkillChip(itemsWrap, v);
input.value = '';
// 입력창을 다시 끝으로
itemsWrap.appendChild(input);
input.focus();
}
} else if (e.key === 'Backspace' && input.value === '') {
// 빈 상태에서 백스페이스: 마지막 칩 삭제
const chips = itemsWrap.querySelectorAll('.skill-item-chip');
if (chips.length > 0) {
chips[chips.length - 1].remove();
}
}
});
itemsWrap.appendChild(input);
}
function addSkillChip(wrap, text) {
const chip = document.createElement('span');
chip.className = 'skill-item-chip';
chip.dataset.value = text;
chip.innerHTML = `
${escapeHtml(text)}
<button type="button" onclick="this.parentElement.remove()" title="삭제">
<i class="fa-solid fa-times"></i>
</button>
`;
// input 앞에 삽입
const input = wrap.querySelector('.skill-cat-add-input');
if (input) {
wrap.insertBefore(chip, input);
} else {
wrap.appendChild(chip);
}
}
function addTimelineRow(data = null) {
const editor = document.getElementById('timelineEditor');
const div = document.createElement('div');
div.className = 'timeline-detail';
div.innerHTML = `
<div class="dynamic-row">
<input type="text" placeholder="연도" value="${escapeAttr(data?.year || '')}" data-field="year">
<input type="text" placeholder="제목" value="${escapeAttr(data?.title || '')}" data-field="title">
<button type="button" class="remove-btn" onclick="this.closest('.timeline-detail').remove()">
<i class="fa-solid fa-times"></i>
</button>
</div>
<input type="text" placeholder="상세 설명"
value="${escapeAttr(data?.description || '')}"
data-field="description"
class="timeline-desc">
`;
editor.appendChild(div);
}
async function saveProfile() {
// 스킬 수집 (카테고리)
const skills = [];
document.querySelectorAll('#skillsEditor .skill-cat-editor').forEach(catEl => {
const catName = catEl.querySelector('[data-cat]').value.trim();
if (!catName) return;
const items = Array.from(catEl.querySelectorAll('.skill-item-chip'))
.map(c => c.dataset.value)
.filter(v => v);
if (items.length > 0) {
skills.push({ category: catName, items });
}
});
// 타임라인 수집
const timeline = [];
document.querySelectorAll('#timelineEditor .timeline-detail').forEach(item => {
const year = item.querySelector('[data-field="year"]').value.trim();
const title = item.querySelector('[data-field="title"]').value.trim();
const description = item.querySelector('[data-field="description"]').value.trim();
if (title) timeline.push({ year, title, description });
});
const data = {
name: document.getElementById('editName').value.trim(),
title: document.getElementById('editTitle').value.trim(),
tagline: document.getElementById('editTagline').value.trim(),
avatar: document.getElementById('editAvatar').value.trim(),
bio: document.getElementById('editBio').value.trim(),
location: document.getElementById('editLocation').value.trim(),
email: document.getElementById('editEmail').value.trim(),
skills,
timeline,
social: {
github: document.getElementById('editGithub').value.trim(),
linkedin: document.getElementById('editLinkedin').value.trim(),
blog: document.getElementById('editBlog').value.trim()
}
};
try {
const res = await csrfFetch('api/profile.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
profile = result.profile;
renderProfile();
closeEditModal();
} else {
showAlert('editAlert', result.error || '저장 실패', 'error');
}
} catch (e) {
showAlert('editAlert', '서버 오류: ' + e.message, 'error');
}
}
function showAlert(elemId, message, type) {
document.getElementById(elemId).innerHTML =
`<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
function escapeAttr(str) { return escapeHtml(str); }
function sanitizeUrl(url) {
if (!url) return '';
const trimmed = String(url).trim();
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
} catch (e) {}
return '';
}
// ESC 키로 모달 닫기 (바깥 클릭 닫기 제거)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(m => {
m.classList.remove('active');
});
}
});
init();
</script>
</body>
</html>