feat(portfolio): 포트폴리오 페이지 전체 구현
- 3탭 구조: 프로필&경력, 프로젝트, 자기소개 - 비밀번호 인증 → 편집 모드 - 클립보드 복사, PDF 내보내기 (window.print) - 사이버펑크 테마 CSS, 모바일 반응형 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = () =>
|
||||
svg(
|
||||
<>
|
||||
|
||||
94
src/pages/portfolio/IntroTab.jsx
Normal file
94
src/pages/portfolio/IntroTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/pages/portfolio/PasswordModal.jsx
Normal file
43
src/pages/portfolio/PasswordModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
873
src/pages/portfolio/Portfolio.css
Normal file
873
src/pages/portfolio/Portfolio.css
Normal 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;
|
||||
}
|
||||
}
|
||||
121
src/pages/portfolio/Portfolio.jsx
Normal file
121
src/pages/portfolio/Portfolio.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
src/pages/portfolio/ProfileTab.jsx
Normal file
202
src/pages/portfolio/ProfileTab.jsx
Normal 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})}>✎</button>
|
||||
<button onClick={() => deleteSkill(s.id)}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
src/pages/portfolio/ProjectTab.jsx
Normal file
133
src/pages/portfolio/ProjectTab.jsx
Normal 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)}>×</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">링크 →</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>
|
||||
);
|
||||
}
|
||||
82
src/pages/portfolio/ResumeView.jsx
Normal file
82
src/pages/portfolio/ResumeView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/pages/portfolio/usePortfolioApi.js
Normal file
100
src/pages/portfolio/usePortfolioApi.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconLab,
|
||||
IconTodo,
|
||||
IconBlogMarketing,
|
||||
IconPortfolio,
|
||||
} from './components/Icons';
|
||||
|
||||
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 MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
|
||||
const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing'));
|
||||
const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
|
||||
|
||||
export const navLinks = [
|
||||
{
|
||||
@@ -117,6 +119,15 @@ export const navLinks = [
|
||||
icon: <IconTodo />,
|
||||
accent: '#f472b6',
|
||||
},
|
||||
{
|
||||
id: 'portfolio',
|
||||
label: 'Portfolio',
|
||||
path: '/portfolio',
|
||||
subtitle: 'RESUME',
|
||||
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
|
||||
icon: <IconPortfolio />,
|
||||
accent: '#06b6d4',
|
||||
},
|
||||
{
|
||||
id: 'agent-office',
|
||||
label: 'Agent Office',
|
||||
@@ -181,6 +192,10 @@ export const appRoutes = [
|
||||
path: 'todo',
|
||||
element: <Todo />,
|
||||
},
|
||||
{
|
||||
path: 'portfolio',
|
||||
element: <Portfolio />,
|
||||
},
|
||||
{
|
||||
path: 'agent-office',
|
||||
lazy: () => import('./pages/agent-office/AgentOffice'),
|
||||
|
||||
Reference in New Issue
Block a user