1111 lines
36 KiB
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>© 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
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>
|