feat(portfolio): 포트폴리오 페이지 전체 구현

- 3탭 구조: 프로필&경력, 프로젝트, 자기소개
- 비밀번호 인증 → 편집 모드
- 클립보드 복사, PDF 내보내기 (window.print)
- 사이버펑크 테마 CSS, 모바일 반응형

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 14:37:25 +09:00
parent bebd55874c
commit a6dd2ef747
10 changed files with 1673 additions and 0 deletions

View File

@@ -102,6 +102,16 @@ export const IconBlogMarketing = () =>
</> </>
); );
export const IconPortfolio = () =>
svg(
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>
);
export const IconBuilding = () => export const IconBuilding = () =>
svg( svg(
<> <>

View File

@@ -0,0 +1,94 @@
import { useState } from 'react';
const emptyIntro = { title: '', content: '', is_main: 0 };
export default function IntroTab({ introductions, editing, api, onRefresh }) {
const [form, setForm] = useState(null);
const [copiedId, setCopiedId] = useState(null);
const save = async () => {
if (form.id) {
await api.editIntro(form.id, { title: form.title, content: form.content });
} else {
await api.addIntro(form);
}
setForm(null);
onRefresh();
};
const remove = async (id) => {
await api.removeIntro(id);
onRefresh();
};
const setMain = async (id) => {
await api.setMainIntro(id);
onRefresh();
};
const copyToClipboard = async (intro) => {
try {
await navigator.clipboard.writeText(intro.content);
setCopiedId(intro.id);
setTimeout(() => setCopiedId(null), 1500);
} catch {
/* 무시 */
}
};
return (
<div className="pf-intro-tab">
{editing && (
<div className="pf-intro-tab__toolbar">
<button className="button primary" onClick={() => setForm({...emptyIntro})}>+ 작성</button>
</div>
)}
{/* 작성/수정 폼 */}
{form && (
<div className="pf-edit-form">
<label>버전명 <input value={form.title} placeholder="예: 이직용 짧은 버전" onChange={(e) => setForm(f => ({...f, title: e.target.value}))} /></label>
<label>본문 <textarea value={form.content} rows={8} onChange={(e) => setForm(f => ({...f, content: e.target.value}))} /></label>
<div className="pf-edit-form__actions">
<button className="button ghost" onClick={() => setForm(null)}>취소</button>
<button className="button primary" onClick={save}>저장</button>
</div>
</div>
)}
{/* 자기소개 목록 */}
<div className="pf-intro-list">
{introductions.length === 0 && <p className="pf-empty">자기소개 글이 없습니다.</p>}
{introductions.map(intro => (
<div key={intro.id} className={`pf-intro-card${intro.is_main ? ' is-main' : ''}`}>
<div className="pf-intro-card__header">
<span className="pf-intro-card__title">
{intro.is_main ? <span className="pf-intro-card__badge">MAIN</span> : null}
{intro.title || '제목 없음'}
</span>
<span className="pf-intro-card__date">
{intro.updated_at ? new Date(intro.updated_at).toLocaleDateString('ko-KR') : ''}
</span>
</div>
<p className="pf-intro-card__preview">{intro.content}</p>
<div className="pf-intro-card__actions">
<button
className="button ghost"
onClick={() => copyToClipboard(intro)}
>
{copiedId === intro.id ? '복사됨!' : '복사'}
</button>
{editing && (
<>
<button className="button ghost" onClick={() => setForm({...intro})}>수정</button>
{!intro.is_main && <button className="button ghost" onClick={() => setMain(intro.id)}>메인 지정</button>}
<button className="button ghost" onClick={() => remove(intro.id)}>삭제</button>
</>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { useState } from 'react';
export default function PasswordModal({ open, onAuth, onClose, error }) {
const [pw, setPw] = useState('');
const [loading, setLoading] = useState(false);
if (!open) return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!pw.trim()) return;
setLoading(true);
await onAuth(pw);
setLoading(false);
setPw('');
};
return (
<div className="pf-modal-backdrop" onClick={onClose}>
<div className="pf-modal" onClick={(e) => e.stopPropagation()}>
<h3 className="pf-modal__title">편집 모드</h3>
<p className="pf-modal__desc">편집하려면 비밀번호를 입력하세요.</p>
<form onSubmit={handleSubmit}>
<input
type="password"
className="pf-modal__input"
placeholder="비밀번호"
value={pw}
onChange={(e) => setPw(e.target.value)}
autoFocus
/>
{error && <p className="pf-modal__error">{error}</p>}
<div className="pf-modal__actions">
<button type="button" className="button ghost" onClick={onClose}>취소</button>
<button type="submit" className="button primary" disabled={loading || !pw.trim()}>
{loading ? '확인 중...' : '확인'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,873 @@
/* ═══════════════════════════════════════════════════════════════════════
Portfolio Page — Cyberpunk Resume
═══════════════════════════════════════════════════════════════════════ */
.pf-page {
display: grid;
gap: 20px;
}
.pf-loading,
.pf-error {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
.pf-error {
color: #f9b6b1;
border: 1px solid rgba(249, 182, 177, 0.4);
border-radius: 12px;
padding: 12px;
background: rgba(249, 182, 177, 0.08);
}
.pf-empty {
margin: 0;
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 32px 16px;
opacity: 0.6;
}
/* ── Toolbar ─────────────────────────────────────────────────────────── */
.pf-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* ── Password Modal ──────────────────────────────────────────────────── */
.pf-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
}
.pf-modal {
background: var(--bg-secondary);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 28px 24px;
width: min(400px, 90vw);
display: grid;
gap: 16px;
}
.pf-modal__title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-bright);
}
.pf-modal__desc {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
.pf-modal__input {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
background: rgba(0, 0, 0, 0.25);
color: var(--text-bright);
outline: none;
font-family: inherit;
font-size: 15px;
box-sizing: border-box;
}
.pf-modal__input:focus {
border-color: rgba(6, 182, 212, 0.5);
box-shadow: 0 0 0 2px rgba(6, 182, 212, 0.1);
}
.pf-modal__error {
margin: 8px 0 0;
font-size: 13px;
color: #f9b6b1;
}
.pf-modal__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 8px;
}
/* ── Edit Form (공통) ────────────────────────────────────────────────── */
.pf-edit-form {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 20px;
display: grid;
gap: 14px;
animation: fadeIn 0.2s ease both;
}
.pf-edit-form label {
display: grid;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.pf-edit-form input,
.pf-edit-form textarea,
.pf-edit-form select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
color: var(--text-bright);
outline: none;
font-family: inherit;
font-size: 14px;
resize: vertical;
}
.pf-edit-form input:focus,
.pf-edit-form textarea:focus,
.pf-edit-form select:focus {
border-color: rgba(6, 182, 212, 0.5);
box-shadow: 0 0 0 2px rgba(6, 182, 212, 0.1);
}
.pf-edit-form__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.pf-edit-form__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.pf-edit-btn {
margin-top: 8px;
}
/* ── Profile Card ────────────────────────────────────────────────────── */
.pf-profile-tab {
display: grid;
gap: 20px;
}
.pf-profile-card {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 24px;
display: grid;
gap: 16px;
box-shadow: var(--shadow-card);
}
.pf-profile-card__header {
display: flex;
align-items: center;
gap: 16px;
}
.pf-profile-card__photo {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(6, 182, 212, 0.3);
}
.pf-profile-card__name {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-bright);
}
.pf-profile-card__name-en {
margin: 2px 0 0;
font-size: 13px;
color: var(--text-muted);
}
.pf-profile-card__role {
margin: 4px 0 0;
font-size: 14px;
color: #06b6d4;
font-weight: 600;
}
.pf-profile-card__bio {
margin: 0;
font-size: 14px;
color: var(--text);
line-height: 1.7;
white-space: pre-line;
}
.pf-profile-card__links {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.pf-profile-card__links a {
font-size: 13px;
color: #06b6d4;
text-decoration: none;
}
.pf-profile-card__links a:hover {
text-decoration: underline;
}
/* ── Section ─────────────────────────────────────────────────────────── */
.pf-section {
display: grid;
gap: 12px;
}
.pf-section__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.pf-section__header h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--text-bright);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Career Group ────────────────────────────────────────────────────── */
.pf-career-group {
display: grid;
gap: 8px;
}
.pf-career-group__title {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #06b6d4;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.pf-career-item {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 14px;
display: grid;
gap: 4px;
}
.pf-career-item__period {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.pf-career-item__role {
font-size: 14px;
color: var(--text-bright);
}
.pf-career-item__org {
font-size: 13px;
color: var(--text-dim);
}
.pf-career-item__desc {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-dim);
line-height: 1.6;
}
.pf-career-item__actions {
display: flex;
gap: 6px;
margin-top: 4px;
}
/* ── Skill Group ─────────────────────────────────────────────────────── */
.pf-skill-group {
display: grid;
gap: 8px;
}
.pf-skill-group__title {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #06b6d4;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.pf-skill-group__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pf-skill-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 5px 12px;
border-radius: 999px;
background: rgba(6, 182, 212, 0.1);
border: 1px solid rgba(6, 182, 212, 0.25);
color: #06b6d4;
font-weight: 500;
}
.pf-skill-tag[data-level="5"] {
background: rgba(6, 182, 212, 0.2);
border-color: rgba(6, 182, 212, 0.5);
}
.pf-skill-tag[data-level="4"] {
background: rgba(6, 182, 212, 0.15);
border-color: rgba(6, 182, 212, 0.35);
}
.pf-skill-tag__actions {
display: inline-flex;
gap: 2px;
}
.pf-skill-tag__actions button {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0 2px;
font-size: 12px;
line-height: 1;
}
.pf-skill-tag__actions button:hover {
color: #06b6d4;
}
/* ── Filter Bar ──────────────────────────────────────────────────────── */
.pf-filter-bar {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.pf-filter-btn {
font-size: 12px;
padding: 6px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted);
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.pf-filter-btn:hover {
background: rgba(6, 182, 212, 0.1);
border-color: rgba(6, 182, 212, 0.3);
color: #06b6d4;
}
.pf-filter-btn.is-active {
background: rgba(6, 182, 212, 0.18);
border-color: rgba(6, 182, 212, 0.5);
color: #06b6d4;
font-weight: 600;
}
/* ── Project Tab ─────────────────────────────────────────────────────── */
.pf-project-tab {
display: grid;
gap: 16px;
}
.pf-project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.pf-project-card {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 16px;
display: grid;
gap: 8px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: var(--shadow-card);
}
.pf-project-card:hover {
border-color: rgba(6, 182, 212, 0.25);
box-shadow: var(--shadow-md);
}
.pf-project-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pf-project-card__cat {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.pf-project-card__cat--company {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.3);
}
.pf-project-card__cat--personal {
background: rgba(52, 211, 153, 0.15);
color: #34d399;
border: 1px solid rgba(52, 211, 153, 0.3);
}
.pf-project-card__cat--academy {
background: rgba(251, 146, 60, 0.15);
color: #fb923c;
border: 1px solid rgba(251, 146, 60, 0.3);
}
.pf-project-card__period {
font-size: 11px;
color: var(--text-muted);
}
.pf-project-card__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--text-bright);
}
.pf-project-card__role {
margin: 0;
font-size: 12px;
color: #06b6d4;
font-weight: 500;
}
.pf-project-card__desc {
margin: 0;
font-size: 12px;
color: var(--text-dim);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pf-project-card__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pf-tech-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
background: rgba(6, 182, 212, 0.08);
border: 1px solid rgba(6, 182, 212, 0.2);
color: #06b6d4;
}
.pf-tech-tag button {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0;
font-size: 12px;
line-height: 1;
}
.pf-tech-tag button:hover {
color: #f9b6b1;
}
.pf-project-card__link {
font-size: 12px;
color: #06b6d4;
text-decoration: none;
}
.pf-project-card__link:hover {
text-decoration: underline;
}
.pf-project-card__actions {
display: flex;
gap: 6px;
margin-top: 4px;
}
/* ── Tech Input ──────────────────────────────────────────────────────── */
.pf-tech-input {
display: grid;
gap: 8px;
}
.pf-tech-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* ── Intro Tab ───────────────────────────────────────────────────────── */
.pf-intro-tab {
display: grid;
gap: 16px;
}
.pf-intro-tab__toolbar {
display: flex;
gap: 10px;
}
.pf-intro-list {
display: grid;
gap: 12px;
}
.pf-intro-card {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 16px;
display: grid;
gap: 10px;
transition: border-color 0.2s ease;
}
.pf-intro-card.is-main {
border-color: rgba(6, 182, 212, 0.4);
background: rgba(6, 182, 212, 0.03);
}
.pf-intro-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pf-intro-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text-bright);
display: flex;
align-items: center;
gap: 8px;
}
.pf-intro-card__badge {
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: rgba(6, 182, 212, 0.2);
border: 1px solid rgba(6, 182, 212, 0.4);
color: #06b6d4;
letter-spacing: 0.08em;
}
.pf-intro-card__date {
font-size: 11px;
color: var(--text-muted);
}
.pf-intro-card__preview {
margin: 0;
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-line;
}
.pf-intro-card__actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* ── Resume View ─────────────────────────────────────────────────────── */
.pf-resume-overlay {
display: grid;
gap: 16px;
}
.pf-resume-actions {
display: flex;
gap: 10px;
}
.pf-resume {
background: #fff;
color: #1a1a2e;
border-radius: var(--radius-lg);
padding: 40px;
max-width: 800px;
margin: 0 auto;
font-family: 'Pretendard', -apple-system, system-ui, sans-serif;
line-height: 1.6;
}
.pf-resume__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
padding-bottom: 20px;
border-bottom: 2px solid #1a1a2e;
margin-bottom: 24px;
}
.pf-resume__name {
margin: 0;
font-size: 28px;
font-weight: 800;
color: #1a1a2e;
}
.pf-resume__role {
margin: 4px 0 0;
font-size: 14px;
color: #555;
}
.pf-resume__contact {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 12px;
color: #555;
}
.pf-resume__section {
margin-bottom: 24px;
}
.pf-resume__section h2 {
margin: 0 0 12px;
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #1a1a2e;
border-bottom: 1px solid #ddd;
padding-bottom: 6px;
}
.pf-resume__section p {
margin: 0;
font-size: 13px;
color: #333;
line-height: 1.7;
}
.pf-resume__item {
margin-bottom: 14px;
}
.pf-resume__item-header {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.pf-resume__item-header strong {
font-size: 14px;
color: #1a1a2e;
}
.pf-resume__item-header span {
font-size: 12px;
color: #555;
}
.pf-resume__period {
font-size: 11px !important;
color: #888 !important;
}
.pf-resume__item p {
margin-top: 4px;
}
.pf-resume__tech {
font-size: 11px !important;
color: #06b6d4 !important;
margin-top: 4px !important;
}
.pf-resume__skills {
font-size: 13px !important;
color: #333 !important;
}
/* ── Print ────────────────────────────────────────────────────────────── */
@media print {
body * {
visibility: hidden;
}
.pf-resume-overlay,
.pf-resume-overlay * {
visibility: visible;
}
.pf-resume-overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.no-print {
display: none !important;
}
.pf-resume {
padding: 0;
border-radius: 0;
box-shadow: none;
max-width: none;
}
.pf-resume__header {
border-bottom: 2px solid #000;
}
.pf-resume__section h2 {
border-bottom: 1px solid #000;
}
}
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.pf-toolbar {
display: none;
}
.pf-profile-card {
padding: 16px;
}
.pf-profile-card__photo {
width: 56px;
height: 56px;
}
.pf-profile-card__name {
font-size: 18px;
}
.pf-project-grid {
grid-template-columns: 1fr;
}
.pf-edit-form__row {
grid-template-columns: 1fr;
}
.pf-edit-form input,
.pf-edit-form textarea,
.pf-edit-form select {
font-size: 16px;
padding: 12px 14px;
}
.pf-edit-form label {
font-size: 14px;
}
.pf-modal__input {
font-size: 16px;
}
.pf-resume {
padding: 20px;
}
.pf-resume__header {
flex-direction: column;
}
.pf-resume__contact {
align-items: flex-start;
}
.pf-filter-btn {
font-size: 13px;
padding: 8px 16px;
min-height: 36px;
}
.pf-intro-card__actions .button {
min-height: 44px;
}
}

View File

@@ -0,0 +1,121 @@
import { useCallback, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import usePortfolioApi from './usePortfolioApi';
import PasswordModal from './PasswordModal';
import ProfileTab from './ProfileTab';
import ProjectTab from './ProjectTab';
import IntroTab from './IntroTab';
import ResumeView from './ResumeView';
import './Portfolio.css';
export default function Portfolio() {
const isMobile = useIsMobile();
const api = usePortfolioApi();
const [data, setData] = useState(null);
const [intros, setIntros] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editing, setEditing] = useState(false);
const [showPwModal, setShowPwModal] = useState(false);
const [showResume, setShowResume] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const d = await api.fetchPublic();
setData(d);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleEditToggle = () => {
if (editing) {
setEditing(false);
return;
}
if (api.token) {
setEditing(true);
} else {
setShowPwModal(true);
}
};
const handleAuth = async (pw) => {
const ok = await api.login(pw);
if (ok) {
setShowPwModal(false);
setEditing(true);
try {
const list = await api.fetchIntros();
setIntros(list);
} catch { /* 무시 */ }
}
};
const refresh = useCallback(async () => {
try {
const d = await api.fetchPublic();
setData(d);
if (api.token) {
const list = await api.fetchIntros();
setIntros(list);
}
} catch { /* 무시 */ }
}, [api.token]);
if (loading && !data) return <div className="pf-page"><p className="pf-loading">불러오는 ...</p></div>;
if (error && !data) return <div className="pf-page"><p className="pf-error">{error}</p></div>;
if (!data) return null;
if (showResume) {
return <ResumeView data={data} onClose={() => setShowResume(false)} />;
}
const tabs = [
{
key: 'profile',
label: '프로필',
content: <ProfileTab data={data} editing={editing} api={api} onRefresh={refresh} />,
},
{
key: 'projects',
label: '프로젝트',
content: <ProjectTab projects={data.projects} editing={editing} api={api} onRefresh={refresh} />,
},
{
key: 'intro',
label: '자기소개',
content: <IntroTab introductions={editing ? intros : (data.main_introduction ? [data.main_introduction] : [])} editing={editing} api={api} onRefresh={refresh} />,
},
];
return (
<div className="pf-page">
<div className="pf-toolbar">
<button className={`button ${editing ? 'primary' : 'ghost'}`} onClick={handleEditToggle}>
{editing ? '편집 완료' : '편집'}
</button>
<button className="button ghost" onClick={() => setShowResume(true)}>
PDF 내보내기
</button>
</div>
<SwipeableView tabs={tabs} />
<PasswordModal
open={showPwModal}
onAuth={handleAuth}
onClose={() => setShowPwModal(false)}
error={api.authError}
/>
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
const CAREER_CATEGORIES = { company: '회사', education: '교육', etc: '기타' };
const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', infra: '인프라', tool: '도구' };
const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 };
const emptySkill = { category: 'language', name: '', level: 3, sort_order: 0 };
export default function ProfileTab({ data, editing, api, onRefresh }) {
const { profile, careers, skills } = data;
const [editingProfile, setEditingProfile] = useState(null);
const [careerForm, setCareerForm] = useState(null);
const [skillForm, setSkillForm] = useState(null);
// ── Profile 편집 ──
const startEditProfile = () => setEditingProfile({ ...profile });
const saveProfileEdit = async () => {
await api.saveProfile(editingProfile);
setEditingProfile(null);
onRefresh();
};
// ── Career CRUD ──
const saveCareer = async () => {
if (careerForm.id) {
await api.editCareer(careerForm.id, careerForm);
} else {
await api.addCareer(careerForm);
}
setCareerForm(null);
onRefresh();
};
const deleteCareer = async (id) => {
await api.removeCareer(id);
onRefresh();
};
// ── Skill CRUD ──
const saveSkill = async () => {
if (skillForm.id) {
await api.editSkill(skillForm.id, skillForm);
} else {
await api.addSkill(skillForm);
}
setSkillForm(null);
onRefresh();
};
const deleteSkill = async (id) => {
await api.removeSkill(id);
onRefresh();
};
const grouped = (items, catMap) => {
const groups = {};
for (const key of Object.keys(catMap)) groups[key] = [];
for (const item of items) {
const cat = item.category || Object.keys(catMap)[0];
if (!groups[cat]) groups[cat] = [];
groups[cat].push(item);
}
return groups;
};
return (
<div className="pf-profile-tab">
{/* ── 프로필 카드 ── */}
<div className="pf-profile-card">
{editingProfile ? (
<div className="pf-edit-form">
<label>이름 <input value={editingProfile.name} onChange={(e) => setEditingProfile(p => ({...p, name: e.target.value}))} /></label>
<label>이름(영문) <input value={editingProfile.name_en} onChange={(e) => setEditingProfile(p => ({...p, name_en: e.target.value}))} /></label>
<label>직함 <input value={editingProfile.role} onChange={(e) => setEditingProfile(p => ({...p, role: e.target.value}))} /></label>
<label>직함(영문) <input value={editingProfile.role_en} onChange={(e) => setEditingProfile(p => ({...p, role_en: e.target.value}))} /></label>
<label>이메일 <input value={editingProfile.email} onChange={(e) => setEditingProfile(p => ({...p, email: e.target.value}))} /></label>
<label>전화번호 <input value={editingProfile.phone} onChange={(e) => setEditingProfile(p => ({...p, phone: e.target.value}))} /></label>
<label>GitHub <input value={editingProfile.github_url} onChange={(e) => setEditingProfile(p => ({...p, github_url: e.target.value}))} /></label>
<label>블로그 <input value={editingProfile.blog_url} onChange={(e) => setEditingProfile(p => ({...p, blog_url: e.target.value}))} /></label>
<label>사진 URL <input value={editingProfile.photo_url} onChange={(e) => setEditingProfile(p => ({...p, photo_url: e.target.value}))} /></label>
<label>소개 <textarea value={editingProfile.bio} rows={3} onChange={(e) => setEditingProfile(p => ({...p, bio: e.target.value}))} /></label>
<div className="pf-edit-form__actions">
<button className="button ghost" onClick={() => setEditingProfile(null)}>취소</button>
<button className="button primary" onClick={saveProfileEdit}>저장</button>
</div>
</div>
) : (
<>
<div className="pf-profile-card__header">
{profile.photo_url && <img className="pf-profile-card__photo" src={profile.photo_url} alt="" />}
<div>
<h2 className="pf-profile-card__name">{profile.name || '이름 미설정'}</h2>
{profile.name_en && <p className="pf-profile-card__name-en">{profile.name_en}</p>}
<p className="pf-profile-card__role">{profile.role || profile.role_en}</p>
</div>
</div>
{profile.bio && <p className="pf-profile-card__bio">{profile.bio}</p>}
<div className="pf-profile-card__links">
{profile.email && <a href={`mailto:${profile.email}`}>{profile.email}</a>}
{profile.github_url && <a href={profile.github_url} target="_blank" rel="noreferrer">GitHub</a>}
{profile.blog_url && <a href={profile.blog_url} target="_blank" rel="noreferrer">Blog</a>}
</div>
{editing && <button className="button ghost pf-edit-btn" onClick={startEditProfile}>프로필 수정</button>}
</>
)}
</div>
{/* ── 경력 타임라인 ── */}
<div className="pf-section">
<div className="pf-section__header">
<h3>경력</h3>
{editing && <button className="button ghost" onClick={() => setCareerForm({...emptyCareer})}>+ 추가</button>}
</div>
{careerForm && (
<div className="pf-edit-form">
<label>구분
<select value={careerForm.category} onChange={(e) => setCareerForm(f => ({...f, category: e.target.value}))}>
{Object.entries(CAREER_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</label>
<label>기관명 <input value={careerForm.organization} onChange={(e) => setCareerForm(f => ({...f, organization: e.target.value}))} /></label>
<label>직함 <input value={careerForm.role} onChange={(e) => setCareerForm(f => ({...f, role: e.target.value}))} /></label>
<label>설명 <textarea value={careerForm.description} rows={2} onChange={(e) => setCareerForm(f => ({...f, description: e.target.value}))} /></label>
<div className="pf-edit-form__row">
<label>시작 <input type="month" value={careerForm.start_date} onChange={(e) => setCareerForm(f => ({...f, start_date: e.target.value}))} /></label>
<label>종료 <input type="month" value={careerForm.end_date} onChange={(e) => setCareerForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
</div>
<div className="pf-edit-form__actions">
<button className="button ghost" onClick={() => setCareerForm(null)}>취소</button>
<button className="button primary" onClick={saveCareer}>저장</button>
</div>
</div>
)}
{Object.entries(grouped(careers, CAREER_CATEGORIES)).map(([cat, items]) =>
items.length > 0 && (
<div key={cat} className="pf-career-group">
<h4 className="pf-career-group__title">{CAREER_CATEGORIES[cat]}</h4>
{items.map((c) => (
<div key={c.id} className="pf-career-item">
<span className="pf-career-item__period">{c.start_date} {c.end_date || '현재'}</span>
<strong className="pf-career-item__role">{c.role}</strong>
<span className="pf-career-item__org">{c.organization}</span>
{c.description && <p className="pf-career-item__desc">{c.description}</p>}
{editing && (
<div className="pf-career-item__actions">
<button className="button ghost" onClick={() => setCareerForm({...c})}>수정</button>
<button className="button ghost" onClick={() => deleteCareer(c.id)}>삭제</button>
</div>
)}
</div>
))}
</div>
)
)}
</div>
{/* ── 기술 스택 ── */}
<div className="pf-section">
<div className="pf-section__header">
<h3>기술 스택</h3>
{editing && <button className="button ghost" onClick={() => setSkillForm({...emptySkill})}>+ 추가</button>}
</div>
{skillForm && (
<div className="pf-edit-form">
<label>구분
<select value={skillForm.category} onChange={(e) => setSkillForm(f => ({...f, category: e.target.value}))}>
{Object.entries(SKILL_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</label>
<label>기술명 <input value={skillForm.name} onChange={(e) => setSkillForm(f => ({...f, name: e.target.value}))} /></label>
<label>숙련도 (1~5)
<input type="range" min={1} max={5} value={skillForm.level} onChange={(e) => setSkillForm(f => ({...f, level: +e.target.value}))} />
<span>{skillForm.level}</span>
</label>
<div className="pf-edit-form__actions">
<button className="button ghost" onClick={() => setSkillForm(null)}>취소</button>
<button className="button primary" onClick={saveSkill}>저장</button>
</div>
</div>
)}
{Object.entries(grouped(skills, SKILL_CATEGORIES)).map(([cat, items]) =>
items.length > 0 && (
<div key={cat} className="pf-skill-group">
<h4 className="pf-skill-group__title">{SKILL_CATEGORIES[cat]}</h4>
<div className="pf-skill-group__tags">
{items.map((s) => (
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
{s.name}
{editing && (
<span className="pf-skill-tag__actions">
<button onClick={() => setSkillForm({...s})}>&#9998;</button>
<button onClick={() => deleteSkill(s.id)}>&times;</button>
</span>
)}
</span>
))}
</div>
</div>
)
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useState } from 'react';
const CATEGORIES = [
{ key: 'all', label: '전체' },
{ key: 'company', label: '회사' },
{ key: 'personal', label: '개인' },
{ key: 'academy', label: '아카데미' },
];
const emptyProject = {
category: 'personal', title: '', description: '', tech_stack: [],
role: '', start_date: '', end_date: '', url: '', image_url: '', sort_order: 0,
};
export default function ProjectTab({ projects, editing, api, onRefresh }) {
const [filter, setFilter] = useState('all');
const [form, setForm] = useState(null);
const [techInput, setTechInput] = useState('');
const filtered = filter === 'all' ? projects : projects.filter(p => p.category === filter);
const addTech = () => {
const tag = techInput.trim();
if (tag && !form.tech_stack.includes(tag)) {
setForm(f => ({ ...f, tech_stack: [...f.tech_stack, tag] }));
}
setTechInput('');
};
const removeTech = (tag) => {
setForm(f => ({ ...f, tech_stack: f.tech_stack.filter(t => t !== tag) }));
};
const save = async () => {
if (form.id) {
await api.editProject(form.id, form);
} else {
await api.addProject(form);
}
setForm(null);
setTechInput('');
onRefresh();
};
const remove = async (id) => {
await api.removeProject(id);
onRefresh();
};
return (
<div className="pf-project-tab">
{/* 카테고리 필터 */}
<div className="pf-filter-bar">
{CATEGORIES.map(c => (
<button
key={c.key}
className={`pf-filter-btn${filter === c.key ? ' is-active' : ''}`}
onClick={() => setFilter(c.key)}
>
{c.label}
</button>
))}
{editing && <button className="button ghost" onClick={() => { setForm({...emptyProject}); setTechInput(''); }}>+ 추가</button>}
</div>
{/* 추가/수정 폼 */}
{form && (
<div className="pf-edit-form">
<label>구분
<select value={form.category} onChange={(e) => setForm(f => ({...f, category: e.target.value}))}>
{CATEGORIES.filter(c => c.key !== 'all').map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
</select>
</label>
<label>프로젝트명 <input value={form.title} onChange={(e) => setForm(f => ({...f, title: e.target.value}))} /></label>
<label>설명 <textarea value={form.description} rows={3} onChange={(e) => setForm(f => ({...f, description: e.target.value}))} /></label>
<label>담당 역할 <input value={form.role} onChange={(e) => setForm(f => ({...f, role: e.target.value}))} /></label>
<div className="pf-edit-form__row">
<label>시작 <input type="month" value={form.start_date} onChange={(e) => setForm(f => ({...f, start_date: e.target.value}))} /></label>
<label>종료 <input type="month" value={form.end_date} onChange={(e) => setForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
</div>
<label>URL <input value={form.url} onChange={(e) => setForm(f => ({...f, url: e.target.value}))} /></label>
<label>기술 스택
<div className="pf-tech-input">
<input
value={techInput}
onChange={(e) => setTechInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTech(); } }}
placeholder="기술명 입력 후 Enter"
/>
<div className="pf-tech-tags">
{form.tech_stack.map(t => (
<span key={t} className="pf-tech-tag">{t} <button onClick={() => removeTech(t)}>&times;</button></span>
))}
</div>
</div>
</label>
<div className="pf-edit-form__actions">
<button className="button ghost" onClick={() => { setForm(null); setTechInput(''); }}>취소</button>
<button className="button primary" onClick={save}>저장</button>
</div>
</div>
)}
{/* 프로젝트 카드 그리드 */}
<div className="pf-project-grid">
{filtered.length === 0 && <p className="pf-empty">프로젝트가 없습니다.</p>}
{filtered.map(p => (
<div key={p.id} className="pf-project-card">
<div className="pf-project-card__header">
<span className={`pf-project-card__cat pf-project-card__cat--${p.category}`}>{CATEGORIES.find(c => c.key === p.category)?.label}</span>
<span className="pf-project-card__period">{p.start_date} {p.end_date || '현재'}</span>
</div>
<h4 className="pf-project-card__title">{p.title}</h4>
{p.role && <p className="pf-project-card__role">{p.role}</p>}
{p.description && <p className="pf-project-card__desc">{p.description}</p>}
{p.tech_stack?.length > 0 && (
<div className="pf-project-card__tags">
{p.tech_stack.map(t => <span key={t} className="pf-tech-tag">{t}</span>)}
</div>
)}
{p.url && <a className="pf-project-card__link" href={p.url} target="_blank" rel="noreferrer">링크 &rarr;</a>}
{editing && (
<div className="pf-project-card__actions">
<button className="button ghost" onClick={() => { setForm({...p}); setTechInput(''); }}>수정</button>
<button className="button ghost" onClick={() => remove(p.id)}>삭제</button>
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
export default function ResumeView({ data, onClose }) {
const { profile, careers, projects, skills, main_introduction } = data;
const handlePrint = () => {
window.print();
};
return (
<div className="pf-resume-overlay">
<div className="pf-resume-actions no-print">
<button className="button primary" onClick={handlePrint}>PDF 저장 / 인쇄</button>
<button className="button ghost" onClick={onClose}>닫기</button>
</div>
<div className="pf-resume">
{/* 헤더 */}
<header className="pf-resume__header">
<div>
<h1 className="pf-resume__name">{profile.name}</h1>
<p className="pf-resume__role">{profile.role}</p>
</div>
<div className="pf-resume__contact">
{profile.email && <span>{profile.email}</span>}
{profile.phone && <span>{profile.phone}</span>}
{profile.github_url && <span>{profile.github_url}</span>}
</div>
</header>
{/* About */}
{(main_introduction?.content || profile.bio) && (
<section className="pf-resume__section">
<h2>About</h2>
<p>{main_introduction?.content || profile.bio}</p>
</section>
)}
{/* Experience */}
{careers.length > 0 && (
<section className="pf-resume__section">
<h2>Experience</h2>
{careers.map(c => (
<div key={c.id} className="pf-resume__item">
<div className="pf-resume__item-header">
<strong>{c.role}</strong>
<span>{c.organization}</span>
<span className="pf-resume__period">{c.start_date} {c.end_date || '현재'}</span>
</div>
{c.description && <p>{c.description}</p>}
</div>
))}
</section>
)}
{/* Projects */}
{projects.length > 0 && (
<section className="pf-resume__section">
<h2>Projects</h2>
{projects.map(p => (
<div key={p.id} className="pf-resume__item">
<div className="pf-resume__item-header">
<strong>{p.title}</strong>
<span className="pf-resume__period">{p.start_date} {p.end_date || '현재'}</span>
</div>
{p.description && <p>{p.description}</p>}
{p.tech_stack?.length > 0 && (
<p className="pf-resume__tech">{p.tech_stack.join(' · ')}</p>
)}
</div>
))}
</section>
)}
{/* Skills */}
{skills.length > 0 && (
<section className="pf-resume__section">
<h2>Skills</h2>
<p className="pf-resume__skills">{skills.map(s => s.name).join(' · ')}</p>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { useState, useCallback } from 'react';
const BASE = '/api/profile';
async function apiFetch(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || res.statusText);
}
return res.json();
}
export default function usePortfolioApi() {
const [token, setToken] = useState(null);
const [authError, setAuthError] = useState('');
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
const login = useCallback(async (password) => {
setAuthError('');
try {
const data = await apiFetch('/auth', {
method: 'POST',
body: JSON.stringify({ password }),
});
setToken(data.token);
return true;
} catch (err) {
setAuthError(err.message);
return false;
}
}, []);
const logout = useCallback(() => setToken(null), []);
// ── Public ──
const fetchPublic = useCallback(() => apiFetch('/public'), []);
// ── Profile ──
const fetchProfile = useCallback(() =>
apiFetch('/profile', { headers: authHeaders }), [token]);
const saveProfile = useCallback((data) =>
apiFetch('/profile', { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
// ── Careers ──
const fetchCareers = useCallback(() =>
apiFetch('/careers', { headers: authHeaders }), [token]);
const addCareer = useCallback((data) =>
apiFetch('/careers', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
const editCareer = useCallback((id, data) =>
apiFetch(`/careers/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
const removeCareer = useCallback((id) =>
apiFetch(`/careers/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
// ── Projects ──
const fetchProjects = useCallback(() =>
apiFetch('/projects', { headers: authHeaders }), [token]);
const addProject = useCallback((data) =>
apiFetch('/projects', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
const editProject = useCallback((id, data) =>
apiFetch(`/projects/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
const removeProject = useCallback((id) =>
apiFetch(`/projects/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
// ── Skills ──
const fetchSkills = useCallback(() =>
apiFetch('/skills', { headers: authHeaders }), [token]);
const addSkill = useCallback((data) =>
apiFetch('/skills', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
const editSkill = useCallback((id, data) =>
apiFetch(`/skills/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
const removeSkill = useCallback((id) =>
apiFetch(`/skills/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
// ── Introductions ──
const fetchIntros = useCallback(() =>
apiFetch('/introductions', { headers: authHeaders }), [token]);
const addIntro = useCallback((data) =>
apiFetch('/introductions', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]);
const editIntro = useCallback((id, data) =>
apiFetch(`/introductions/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]);
const removeIntro = useCallback((id) =>
apiFetch(`/introductions/${id}`, { method: 'DELETE', headers: authHeaders }), [token]);
const setMainIntro = useCallback((id) =>
apiFetch(`/introductions/${id}/main`, { method: 'PATCH', headers: authHeaders }), [token]);
return {
token, authError, login, logout,
fetchPublic,
fetchProfile, saveProfile,
fetchCareers, addCareer, editCareer, removeCareer,
fetchProjects, addProject, editProject, removeProject,
fetchSkills, addSkill, editSkill, removeSkill,
fetchIntros, addIntro, editIntro, removeIntro, setMainIntro,
};
}

View File

@@ -10,6 +10,7 @@ import {
IconLab, IconLab,
IconTodo, IconTodo,
IconBlogMarketing, IconBlogMarketing,
IconPortfolio,
} from './components/Icons'; } from './components/Icons';
const Home = lazy(() => import('./pages/home/Home')); const Home = lazy(() => import('./pages/home/Home'));
@@ -25,6 +26,7 @@ const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
const Todo = lazy(() => import('./pages/todo/Todo')); const Todo = lazy(() => import('./pages/todo/Todo'));
const MusicStudio = lazy(() => import('./pages/music/MusicStudio')); const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing')); const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing'));
const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
export const navLinks = [ export const navLinks = [
{ {
@@ -117,6 +119,15 @@ export const navLinks = [
icon: <IconTodo />, icon: <IconTodo />,
accent: '#f472b6', accent: '#f472b6',
}, },
{
id: 'portfolio',
label: 'Portfolio',
path: '/portfolio',
subtitle: 'RESUME',
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
icon: <IconPortfolio />,
accent: '#06b6d4',
},
{ {
id: 'agent-office', id: 'agent-office',
label: 'Agent Office', label: 'Agent Office',
@@ -181,6 +192,10 @@ export const appRoutes = [
path: 'todo', path: 'todo',
element: <Todo />, element: <Todo />,
}, },
{
path: 'portfolio',
element: <Portfolio />,
},
{ {
path: 'agent-office', path: 'agent-office',
lazy: () => import('./pages/agent-office/AgentOffice'), lazy: () => import('./pages/agent-office/AgentOffice'),