chore: Claude Code 설정·디자인 레퍼런스·포털 로고 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
844
design-refs/AI DEV 포털.dc.html
Normal file
844
design-refs/AI DEV 포털.dc.html
Normal file
@@ -0,0 +1,844 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg:#ffffff; --panel:#f6f8fa; --card:#ffffff; --tile:#f3f5f8; --tile-bd:#e1e6eb;
|
||||
--inset:#ffffff; --bg-soft:#fbfcfd;
|
||||
--border:#d0d7de; --border-soft:#d8dee4; --divider:#eaecef;
|
||||
--fg:#1f2328; --fg2:#656d76; --fg3:#8b949e;
|
||||
--link:#0969da; --accent-bg:#ddf4ff; --accent-bd:#b6e3ff; --accent-fg:#0969da; --accent-strong:#0a3069;
|
||||
--success:#1f883d; --success-hover:#1a7f37; --success-bg:#dafbe1; --success-bd:#b7ebc4; --success-bd2:#4ac26b; --success-fg:#1a7f37;
|
||||
--attn:#d4a72c; --attn-fg:#9a6700; --attn-bg:#fff8e6; --attn-bd:#eac54f;
|
||||
--admin:#8250df; --admin-fg:#8250df; --admin-bg:#f3effc; --admin-bd:#e7defb;
|
||||
--avatar-bg:#eaeef2; --overlay:rgba(31,35,40,.42);
|
||||
--shadow:rgba(31,35,40,.08); --shadow-hover:rgba(31,35,40,.12); --shadow-strong:rgba(31,35,40,.22);
|
||||
--btn-bg:#f6f8fa; --btn-bd:#d0d7de; --btn-hover:#eef1f4;
|
||||
--tab-underline:#fd8c73; --focus-ring:rgba(9,105,218,.12); --danger:#cf222e;
|
||||
--heart:#bf3989; --heart-fg:#bf3989; --heart-bg:#ffeff5; --heart-bd:#ffc1da;
|
||||
--hbg:#24292f; --hfg:#ffffff; --hfg2:#b9c0c9; --hbd:#444c56; --hsearch-bg:#383f47; --hsearch-bd:#565b62; --hsearch-fg:#cdd3da;
|
||||
}
|
||||
:root[data-aidev-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg:#0d1117; --panel:#161b22; --card:#1c2128; --tile:#2d333b; --tile-bd:#373e47;
|
||||
--inset:#0d1117; --bg-soft:#161b22;
|
||||
--border:#30363d; --border-soft:#373e47; --divider:#262c34;
|
||||
--fg:#e6edf3; --fg2:#9198a1; --fg3:#6e7681;
|
||||
--link:#4493f8; --accent-bg:rgba(56,139,253,.15); --accent-bd:rgba(56,139,253,.4); --accent-fg:#4493f8; --accent-strong:#a5d6ff;
|
||||
--success:#238636; --success-hover:#2ea043; --success-bg:rgba(46,160,67,.15); --success-bd:rgba(46,160,67,.4); --success-bd2:rgba(63,185,80,.6); --success-fg:#3fb950;
|
||||
--attn:#e3b341; --attn-fg:#e3b341; --attn-bg:rgba(227,179,65,.12); --attn-bd:rgba(187,128,9,.6);
|
||||
--admin:#bc8cff; --admin-fg:#bc8cff; --admin-bg:rgba(163,113,247,.15); --admin-bd:rgba(163,113,247,.4);
|
||||
--avatar-bg:#2d333b; --overlay:rgba(1,4,9,.7);
|
||||
--shadow:rgba(1,4,9,.4); --shadow-hover:rgba(1,4,9,.55); --shadow-strong:rgba(1,4,9,.6);
|
||||
--btn-bg:#21262d; --btn-bd:#30363d; --btn-hover:#30363d;
|
||||
--tab-underline:#f78166; --focus-ring:rgba(56,139,253,.3); --danger:#f85149;
|
||||
--heart:#db61a2; --heart-fg:#db61a2; --heart-bg:rgba(219,97,162,.15); --heart-bd:rgba(219,97,162,.4);
|
||||
--hbg:#010409; --hfg:#e6edf3; --hfg2:#9198a1; --hbd:#21262d; --hsearch-bg:#0d1117; --hsearch-bd:#30363d; --hsearch-fg:#9198a1;
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--fg); }
|
||||
body {
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color .25s ease, color .2s ease;
|
||||
}
|
||||
::selection { background: color-mix(in srgb, var(--link) 22%, transparent); }
|
||||
::placeholder { color: var(--fg3); }
|
||||
@keyframes aidev-flyup { 0%{transform:translateY(0) scale(.5);opacity:0} 12%{opacity:1} 100%{transform:translateY(-180px) scale(1.15);opacity:0} }
|
||||
@keyframes aidev-sparkle { 0%{transform:scale(0) rotate(0);opacity:1} 100%{transform:scale(1.5) rotate(120deg);opacity:0} }
|
||||
@keyframes aidev-rocket { 0%{transform:translateX(-40px) rotate(-12deg);opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{transform:translateX(calc(100vw + 60px)) rotate(-12deg);opacity:0} }
|
||||
@keyframes aidev-shake { 10%,90%{transform:translateX(-1px) rotate(-1deg)} 20%,80%{transform:translateX(2px) rotate(1.5deg)} 30%,50%,70%{transform:translateX(-4px) rotate(-2deg)} 40%,60%{transform:translateX(4px) rotate(2deg)} }
|
||||
@keyframes aidev-stamp { 0%{transform:scale(2.4) rotate(-22deg);opacity:0} 55%{transform:scale(.86) rotate(-11deg);opacity:1} 100%{transform:scale(1) rotate(-11deg);opacity:1} }
|
||||
@keyframes aidev-pop { 0%{transform:scale(.96) translateY(6px);opacity:0} 100%{transform:scale(1) translateY(0);opacity:1} }
|
||||
@keyframes aidev-fade { from{opacity:0} to{opacity:1} }
|
||||
@keyframes aidev-toast { 0%{transform:translateY(12px);opacity:0} 12%{transform:translateY(0);opacity:1} 88%{transform:translateY(0);opacity:1} 100%{transform:translateY(8px);opacity:0} }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="min-height:100vh;background:var(--bg);">
|
||||
|
||||
<header style="position:sticky;top:0;z-index:300;background:var(--hbg);border-bottom:1px solid var(--hbd);">
|
||||
<div style="max-width:1180px;margin:0 auto;padding:0 24px;height:60px;display:flex;align-items:center;gap:16px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none;flex:0 0 auto;" ref="{{ logoRef }}" onClick="{{ onLogoClick }}">
|
||||
<span style="display:inline-flex;color:var(--hfg);" innerel="{{ logoMarkEl }}">{{ logoMarkEl }}</span>
|
||||
<span style="font-weight:800;font-size:15px;letter-spacing:.13em;color:var(--hfg);">AI DEV</span>
|
||||
<span style="font-size:11px;color:var(--hfg2);font-weight:500;border-left:1px solid var(--hbd);padding-left:10px;margin-left:2px;white-space:nowrap;">사내 개발 포털</span>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;max-width:300px;position:relative;display:flex;align-items:center;">
|
||||
<span style="position:absolute;left:10px;display:inline-flex;color:var(--hfg2);pointer-events:none;" innerel="{{ searchIconEl }}">{{ searchIconEl }}</span>
|
||||
<input value="{{ searchQuery }}" onInput="{{ onSearchInput }}" placeholder="프로젝트 검색…" style="width:100%;font-family:inherit;font-size:13px;color:var(--hfg);background:var(--hsearch-bg);border:1px solid var(--hsearch-bd);border-radius:7px;padding:6px 10px 6px 32px;outline:none;" style-focus="border-color:var(--link);background:var(--bg);">
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:10px;flex:0 0 auto;margin-left:auto;">
|
||||
<button onClick="{{ onToggleTheme }}" title="{{ themeTitle }}" style="width:32px;height:32px;border-radius:8px;border:1px solid var(--hbd);background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--hfg2);transition:background .14s ease,color .14s ease;" style-hover="background:rgba(255,255,255,.08);color:var(--hfg);" innerel="{{ themeIconEl }}">{{ themeIconEl }}</button>
|
||||
|
||||
<button onClick="{{ onToggleAdmin }}" title="관리자 권한 분기 시연" style="display:flex;align-items:center;gap:9px;background:transparent;border:1px solid var(--hbd);border-radius:999px;padding:5px 6px 5px 12px;cursor:pointer;font-family:inherit;">
|
||||
<span style="font-size:12.5px;font-weight:600;color:var(--hfg2);white-space:nowrap;">관리자 보기</span>
|
||||
<span style="{{ switchTrackStyle }}"><span style="{{ switchKnobStyle }}"></span></span>
|
||||
</button>
|
||||
|
||||
<div style="position:relative;">
|
||||
<button onClick="{{ onToggleUserMenu }}" style="display:flex;align-items:center;gap:8px;background:transparent;border:1px solid transparent;border-radius:8px;padding:4px 6px 4px 8px;cursor:pointer;font-family:inherit;" style-hover="background:rgba(255,255,255,.08);">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--hfg);font-variant-numeric:tabular-nums;">{{ currentUser }}</span>
|
||||
<span style="display:inline-flex;" innerel="{{ headerAvatarEl }}">{{ headerAvatarEl }}</span>
|
||||
<span style="display:inline-flex;color:var(--hfg2);" innerel="{{ caretEl }}">{{ caretEl }}</span>
|
||||
</button>
|
||||
<sc-if value="{{ userMenuOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="position:absolute;top:46px;right:0;width:212px;background:var(--card);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px var(--shadow-strong);padding:6px;z-index:320;animation:aidev-pop .14s ease-out;">
|
||||
<div style="padding:8px 10px 10px;border-bottom:1px solid var(--divider);margin-bottom:6px;">
|
||||
<div style="font-size:12px;color:var(--fg2);">로그인 계정</div>
|
||||
<div style="font-size:13.5px;font-weight:600;color:var(--fg);font-variant-numeric:tabular-nums;">{{ currentUser }}</div>
|
||||
<div style="font-size:11.5px;color:var(--fg2);margin-top:2px;">{{ roleLabel }}</div>
|
||||
</div>
|
||||
<button onClick="{{ onLogout }}" style="width:100%;text-align:left;background:transparent;border:0;border-radius:6px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--fg);display:flex;align-items:center;gap:8px;" style-hover="background:var(--panel);">
|
||||
<span style="display:inline-flex;color:var(--fg2);" innerel="{{ logoutIconEl }}">{{ logoutIconEl }}</span>로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main style="max-width:1180px;margin:0 auto;padding:32px 24px 64px;">
|
||||
|
||||
<!-- ===================== SECTION 1 — TOOLS ===================== -->
|
||||
<section style="margin-bottom:40px;">
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px;">
|
||||
<div style="display:flex;align-items:baseline;gap:10px;">
|
||||
<h2 style="margin:0;font-size:18px;font-weight:700;color:var(--fg);letter-spacing:-.01em;">개발 도구</h2>
|
||||
<span style="font-size:13px;color:var(--fg2);">SSO로 한 번에 로그인 · 새 탭으로 이동</span>
|
||||
</div>
|
||||
<sc-if value="{{ adminView }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--admin-fg);background:var(--admin-bg);border:1px solid var(--admin-bd);border-radius:999px;padding:3px 10px;white-space:nowrap;">
|
||||
<span style="display:inline-flex;" innerel="{{ lockAdminEl }}">{{ lockAdminEl }}</span>관리자 도구 표시 중
|
||||
</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;">
|
||||
<sc-for list="{{ visibleTools }}" as="tool" hint-placeholder-count="4">
|
||||
<button onClick="{{ tool.onOpen }}" style="{{ tool.cardStyle }}" style-hover="border-color:var(--link);box-shadow:0 6px 18px var(--shadow-hover);transform:translateY(-3px);">
|
||||
<span style="position:absolute;top:13px;right:13px;font-size:10px;font-weight:700;letter-spacing:.06em;color:var(--accent-fg);background:var(--accent-bg);border:1px solid var(--accent-bd);border-radius:4px;padding:2px 6px;">SSO</span>
|
||||
<span style="width:44px;height:44px;border-radius:11px;display:inline-flex;align-items:center;justify-content:center;{{ tool.iconWrapStyle }}" innerel="{{ tool.iconEl }}">{{ tool.iconEl }}</span>
|
||||
<span style="display:block;margin-top:14px;font-size:14.5px;font-weight:700;color:var(--fg);">{{ tool.name }}</span>
|
||||
<span style="display:block;margin-top:3px;font-size:12.5px;color:var(--fg2);line-height:1.45;">{{ tool.desc }}</span>
|
||||
<sc-if value="{{ tool.isAdmin }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="display:inline-flex;align-items:center;gap:5px;margin-top:11px;font-size:11px;font-weight:600;color:var(--admin-fg);background:var(--admin-bg);border:1px solid var(--admin-bd);border-radius:999px;padding:2px 8px;white-space:nowrap;">
|
||||
<span style="display:inline-flex;" innerel="{{ lockAdminEl }}">{{ lockAdminEl }}</span>관리자 전용
|
||||
</span>
|
||||
</sc-if>
|
||||
</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== SECTION 2 — MY PROJECTS ===================== -->
|
||||
<section style="margin-bottom:40px;position:relative;background:var(--panel);border:1px solid var(--border-soft);border-radius:14px;padding:22px 22px 24px;">
|
||||
<span style="position:absolute;left:0;top:18px;bottom:18px;width:4px;border-radius:0 4px 4px 0;background:var(--link);"></span>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;padding-left:8px;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:baseline;gap:10px;">
|
||||
<h2 style="margin:0;font-size:18px;font-weight:700;color:var(--fg);letter-spacing:-.01em;">내 프로젝트</h2>
|
||||
<span style="font-size:13px;color:var(--fg2);">{{ currentUser }} 님의 작업 공간</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
<div style="display:inline-flex;align-items:center;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:2px;gap:2px;">
|
||||
<button onClick="{{ onFilterAll }}" style="{{ filterAllStyle }}">전체 {{ myTotalCount }}</button>
|
||||
<button onClick="{{ onFilterShared }}" style="{{ filterSharedStyle }}"><span style="display:inline-flex;" innerel="{{ globeMiniEl }}">{{ globeMiniEl }}</span>공개 {{ mySharedCount }}</button>
|
||||
<button onClick="{{ onFilterPrivate }}" style="{{ filterPrivateStyle }}"><span style="display:inline-flex;" innerel="{{ lockMiniEl }}">{{ lockMiniEl }}</span>비공개 {{ myPrivateCount }}</button>
|
||||
</div>
|
||||
<button onClick="{{ onOpenShare }}" title="새 프로젝트 등록" style="{{ newProjectBtnStyle }}" style-hover="background:var(--success-hover);"><span style="display:inline-flex;" innerel="{{ plusWhiteEl }}">{{ plusWhiteEl }}</span>새 프로젝트</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:14px;padding-left:8px;">
|
||||
<sc-for list="{{ myProjects }}" as="p" hint-placeholder-count="3">
|
||||
<div onClick="{{ p.onOpen }}" style="{{ p.myCardStyle }}" style-hover="border-color:var(--link);box-shadow:0 6px 18px var(--shadow);transform:translateY(-2px);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
||||
<div style="display:flex;align-items:center;gap:7px;min-width:0;">
|
||||
<span style="display:inline-flex;color:var(--fg2);flex:0 0 auto;" innerel="{{ repoIconEl }}">{{ repoIconEl }}</span>
|
||||
<span style="font-size:14px;font-weight:700;color:var(--link);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ p.title }}</span>
|
||||
</div>
|
||||
<span style="{{ p.visBadgeStyle }}"><span style="display:inline-flex;" innerel="{{ p.visIconEl }}">{{ p.visIconEl }}</span>{{ p.visLabel }}</span>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:12.5px;color:var(--fg2);line-height:1.5;flex:1;">{{ p.description }}</div>
|
||||
<div style="margin-top:11px;display:flex;flex-wrap:wrap;gap:6px;">
|
||||
<sc-for list="{{ p.tags }}" as="tag" hint-placeholder-count="2">
|
||||
<span style="font-size:11px;font-weight:500;color:var(--accent-fg);background:var(--accent-bg);border-radius:999px;padding:1px 9px;">{{ tag }}</span>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="margin-top:13px;display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
||||
<sc-if value="{{ p.shared }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="display:flex;align-items:center;gap:13px;font-size:12px;color:var(--fg2);">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;"><span style="width:9px;height:9px;border-radius:50%;background:{{ p.langColor }};display:inline-block;"></span>{{ p.lang }}</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;"><span style="display:inline-flex;color:var(--heart);" innerel="{{ p.heartMiniEl }}">{{ p.heartMiniEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.heartCount }}</span></span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;"><span style="display:inline-flex;color:var(--attn);" innerel="{{ p.starMiniEl }}">{{ p.starMiniEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.likeCount }}</span></span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;"><span style="display:inline-flex;" innerel="{{ commentIconEl }}">{{ commentIconEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.commentCount }}</span></span>
|
||||
</div>
|
||||
<button onClick="{{ p.onToggleShare }}" style="background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:11.5px;font-weight:600;color:var(--fg3);white-space:nowrap;" style-hover="color:var(--fg)">비공개로</button>
|
||||
</sc-if>
|
||||
<sc-if value="{{ p.isPrivate }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="font-size:11.5px;color:var(--fg3);">나만 볼 수 있어요</span>
|
||||
<button onClick="{{ p.onToggleShare }}" style="{{ shareMiniBtnStyle }}"><span style="display:inline-flex;" innerel="{{ globeMiniWhiteEl }}">{{ globeMiniWhiteEl }}</span>공유하기</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
|
||||
<sc-if value="{{ showAddCard }}" hint-placeholder-val="{{ true }}">
|
||||
<button onClick="{{ onOpenShare }}" style="background:transparent;border:1.5px dashed var(--border);border-radius:12px;padding:16px;cursor:pointer;font-family:inherit;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--fg2);min-height:158px;width:100%;transition:border-color .16s ease,color .16s ease,background .16s ease;" style-hover="border-color:var(--success);color:var(--success);background:var(--card);">
|
||||
<span style="width:38px;height:38px;border-radius:50%;background:var(--success-bg);border:1px solid var(--success-bd);display:inline-flex;align-items:center;justify-content:center;color:var(--success);" innerel="{{ plusIconEl }}">{{ plusIconEl }}</span>
|
||||
<span style="font-size:13.5px;font-weight:700;white-space:nowrap;">새 프로젝트 등록</span>
|
||||
<span style="font-size:11.5px;text-align:center;line-height:1.4;">공개·비공개를 정해 등록하세요</span>
|
||||
</button>
|
||||
</sc-if>
|
||||
<sc-if value="{{ myEmpty }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="grid-column:1 / -1;text-align:center;padding:30px;color:var(--fg2);font-size:13px;border:1px dashed var(--border);border-radius:12px;">{{ myEmptyMsg }}</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== SECTION 3 — SHARED FEED ===================== -->
|
||||
<section>
|
||||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:16px;border-bottom:1px solid var(--border-soft);">
|
||||
<div style="display:flex;align-items:baseline;gap:10px;padding-bottom:14px;">
|
||||
<h2 style="margin:0;font-size:18px;font-weight:700;color:var(--fg);letter-spacing:-.01em;">공유 프로젝트</h2>
|
||||
<span style="font-size:13px;color:var(--fg2);">동료들이 만든 결과물 둘러보기</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:14px;">
|
||||
<button onClick="{{ onToggleFeedStarred }}" title="내가 별표한 프로젝트" style="{{ feedStarBtnStyle }}" style-hover="border-color:var(--attn-bd);color:var(--attn-fg);"><span style="display:inline-flex;" innerel="{{ feedStarIconEl }}">{{ feedStarIconEl }}</span>즐겨찾기 <span style="font-variant-numeric:tabular-nums;">{{ starredCount }}</span></button>
|
||||
<div style="display:flex;gap:2px;">
|
||||
<button onClick="{{ onSortLatest }}" style="{{ tabLatestStyle }}">최신순</button>
|
||||
<button onClick="{{ onSortPopular }}" style="{{ tabPopularStyle }}">인기순</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ feedEmpty }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="text-align:center;padding:48px 20px;color:var(--fg2);font-size:14px;border:1px dashed var(--border);border-radius:12px;">{{ feedEmptyMsg }}</div>
|
||||
</sc-if>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;">
|
||||
<sc-for list="{{ feedProjects }}" as="p" hint-placeholder-count="4">
|
||||
<div onClick="{{ p.onOpen }}" style="background:var(--card);border:1px solid var(--border-soft);border-radius:12px;padding:18px;cursor:pointer;transition:box-shadow .16s ease,border-color .16s ease,transform .16s ease;display:flex;flex-direction:column;" style-hover="border-color:var(--link);box-shadow:0 6px 18px var(--shadow);transform:translateY(-2px);">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0;">
|
||||
<span style="display:inline-flex;color:var(--fg2);flex:0 0 auto;" innerel="{{ repoIconEl }}">{{ repoIconEl }}</span>
|
||||
<span style="font-size:15px;font-weight:700;color:var(--link);line-height:1.3;">{{ p.title }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;flex:0 0 auto;">
|
||||
<button onClick="{{ p.onToggleHeart }}" title="좋아요" style="{{ p.heartBtnStyle }}"><span style="display:inline-flex;" innerel="{{ p.heartEl }}">{{ p.heartEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.heartCount }}</span></button>
|
||||
<button onClick="{{ p.onToggleLike }}" title="Star" style="{{ p.starBtnStyle }}"><span style="display:inline-flex;" innerel="{{ p.starEl }}">{{ p.starEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.likeCount }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:13px;color:var(--fg2);line-height:1.55;">{{ p.description }}</div>
|
||||
<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px;">
|
||||
<sc-for list="{{ p.tags }}" as="tag" hint-placeholder-count="3">
|
||||
<span style="font-size:11px;font-weight:500;color:var(--accent-fg);background:var(--accent-bg);border-radius:999px;padding:1px 9px;">{{ tag }}</span>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="margin-top:15px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0;">
|
||||
<span style="display:inline-flex;flex:0 0 auto;" innerel="{{ p.avatarEl }}">{{ p.avatarEl }}</span>
|
||||
<span style="font-size:12.5px;font-weight:600;color:var(--fg);font-variant-numeric:tabular-nums;">{{ p.ownerId }}</span>
|
||||
<span style="font-size:11.5px;color:var(--fg3);">· {{ p.createdAt }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:13px;font-size:12px;color:var(--fg2);">
|
||||
<span style="display:inline-flex;align-items:center;gap:5px;"><span style="width:9px;height:9px;border-radius:50%;background:{{ p.langColor }};display:inline-block;"></span>{{ p.lang }}</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:5px;"><span style="display:inline-flex;" innerel="{{ commentIconEl }}">{{ commentIconEl }}</span><span style="font-variant-numeric:tabular-nums;">{{ p.commentCount }}</span></span>
|
||||
<sc-if value="{{ p.hasAppUrl }}" hint-placeholder-val="{{ true }}">
|
||||
<button onClick="{{ p.onOpenApp }}" style="display:inline-flex;align-items:center;gap:4px;background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--link);white-space:nowrap;">바로가기 <span style="display:inline-flex;" innerel="{{ extIconEl }}">{{ extIconEl }}</span></button>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer style="border-top:1px solid var(--border-soft);">
|
||||
<div style="max-width:1180px;margin:0 auto;padding:20px 24px 28px;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:center;gap:8px;color:var(--fg3);font-size:12px;">
|
||||
<span style="display:inline-flex;color:var(--fg3);" innerel="{{ footerMarkEl }}">{{ footerMarkEl }}</span>
|
||||
AI DEV · 사내 개발 포털
|
||||
</div>
|
||||
<div style="font-size:11.5px;color:var(--fg3);font-family:'SF Mono',ui-monospace,Menlo,monospace;">Express + Postgres + Keycloak SSO · deployed on Kubero</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- ===================== DETAIL MODAL ===================== -->
|
||||
<sc-if value="{{ modalOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div onClick="{{ onCloseModal }}" style="position:fixed;inset:0;z-index:500;background:var(--overlay);display:flex;align-items:flex-start;justify-content:center;padding:48px 20px;overflow-y:auto;animation:aidev-fade .16s ease-out;">
|
||||
<div onClick="{{ stopProp }}" style="width:100%;max-width:640px;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 16px 48px var(--shadow-strong);overflow:hidden;animation:aidev-pop .18s ease-out;">
|
||||
|
||||
<div style="padding:22px 24px 18px;border-bottom:1px solid var(--divider);">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:14px;">
|
||||
<div style="min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span style="display:inline-flex;color:var(--fg2);" innerel="{{ repoIconEl }}">{{ repoIconEl }}</span>
|
||||
<h3 style="margin:0;font-size:20px;font-weight:800;color:var(--link);line-height:1.3;letter-spacing:-.01em;">{{ modalProject.title }}</h3>
|
||||
<span style="{{ modalProject.visBadgeStyle }}"><span style="display:inline-flex;" innerel="{{ modalProject.visIconEl }}">{{ modalProject.visIconEl }}</span>{{ modalProject.visLabel }}</span>
|
||||
</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<span style="display:inline-flex;" innerel="{{ modalProject.avatarEl }}">{{ modalProject.avatarEl }}</span>
|
||||
<span style="font-size:13px;font-weight:600;color:var(--fg);font-variant-numeric:tabular-nums;">{{ modalProject.ownerId }}</span>
|
||||
<span style="font-size:12px;color:var(--fg3);">· {{ modalProject.createdAt }} 공유</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--fg2);margin-left:4px;"><span style="width:9px;height:9px;border-radius:50%;background:{{ modalProject.langColor }};display:inline-block;"></span>{{ modalProject.lang }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick="{{ onCloseModal }}" style="flex:0 0 auto;width:32px;height:32px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--fg2);" style-hover="background:var(--panel);border-color:var(--border-soft);" innerel="{{ closeIconEl }}">{{ closeIconEl }}</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:14px;">
|
||||
<sc-for list="{{ modalProject.tags }}" as="tag" hint-placeholder-count="3">
|
||||
<span style="font-size:11px;font-weight:500;color:var(--accent-fg);background:var(--accent-bg);border-radius:999px;padding:1px 9px;">{{ tag }}</span>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
<p style="margin:14px 0 0;font-size:14px;color:var(--fg);line-height:1.65;">{{ modalProject.description }}</p>
|
||||
|
||||
<div style="margin-top:18px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
<button onClick="{{ modalProject.onToggleHeart }}" style="{{ modalHeartBtnStyle }}"><span style="display:inline-flex;" innerel="{{ modalProject.heartEl }}">{{ modalProject.heartEl }}</span><span>좋아요</span><span style="font-variant-numeric:tabular-nums;padding-left:8px;margin-left:2px;border-left:1px solid var(--border);">{{ modalProject.heartCount }}</span></button>
|
||||
<button onClick="{{ modalProject.onToggleLike }}" style="{{ modalStarBtnStyle }}"><span style="display:inline-flex;" innerel="{{ modalProject.starEl }}">{{ modalProject.starEl }}</span><span>{{ modalProject.starLabel }}</span><span style="font-variant-numeric:tabular-nums;padding-left:8px;margin-left:2px;border-left:1px solid var(--border);">{{ modalProject.likeCount }}</span></button>
|
||||
<sc-if value="{{ modalProject.hasAppUrl }}" hint-placeholder-val="{{ true }}">
|
||||
<button onClick="{{ modalProject.onOpenApp }}" style="display:inline-flex;align-items:center;gap:6px;background:var(--success);border:1px solid color-mix(in srgb, var(--fg) 12%, transparent);border-radius:8px;padding:8px 14px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;color:#fff;white-space:nowrap;transition:background .14s ease;" style-hover="background:var(--success-hover);">바로가기 <span style="display:inline-flex;" innerel="{{ extIconWhiteEl }}">{{ extIconWhiteEl }}</span></button>
|
||||
</sc-if>
|
||||
<sc-if value="{{ modalProject.hasRepo }}" hint-placeholder-val="{{ true }}">
|
||||
<button onClick="{{ modalProject.onOpenRepo }}" style="display:inline-flex;align-items:center;gap:6px;background:var(--btn-bg);border:1px solid var(--btn-bd);border-radius:8px;padding:8px 14px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;color:var(--fg);white-space:nowrap;transition:background .14s ease;" style-hover="background:var(--btn-hover);"><span style="display:inline-flex;color:var(--fg2);" innerel="{{ codeIconEl }}">{{ codeIconEl }}</span>저장소</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding:18px 24px 4px;">
|
||||
<div style="font-size:13px;font-weight:700;color:var(--fg);margin-bottom:14px;display:flex;align-items:center;gap:7px;">댓글 <span style="font-weight:600;color:var(--fg2);background:var(--panel);border:1px solid var(--border-soft);border-radius:999px;padding:0 8px;font-size:12px;font-variant-numeric:tabular-nums;">{{ modalProject.commentCount }}</span></div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
<sc-for list="{{ threadedComments }}" as="c" hint-placeholder-count="3">
|
||||
<div>
|
||||
<div style="display:flex;gap:11px;padding:11px 0;">
|
||||
<span style="display:inline-flex;flex:0 0 auto;margin-top:1px;" innerel="{{ c.avatarEl }}">{{ c.avatarEl }}</span>
|
||||
<div style="min-width:0;flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<span style="font-size:13px;font-weight:700;color:var(--fg);font-variant-numeric:tabular-nums;">{{ c.userId }}</span>
|
||||
<span style="font-size:11.5px;color:var(--fg3);">{{ c.time }}</span>
|
||||
<sc-if value="{{ c.stamped }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:800;letter-spacing:.04em;color:var(--success-fg);background:var(--success-bg);border:1px solid var(--success-bd2);border-radius:5px;padding:1px 7px;animation:aidev-stamp .42s cubic-bezier(.2,.8,.3,1.2);"><span style="display:inline-flex;" innerel="{{ checkIconEl }}">{{ checkIconEl }}</span>LGTM</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
<div style="margin-top:3px;font-size:13.5px;color:var(--fg);line-height:1.6;white-space:pre-wrap;">{{ c.body }}</div>
|
||||
<div style="margin-top:5px;display:flex;align-items:center;gap:12px;">
|
||||
<button onClick="{{ c.onReply }}" style="background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--fg2);display:inline-flex;align-items:center;gap:5px;" style-hover="color:var(--link);"><span style="display:inline-flex;" innerel="{{ replyIconEl }}">{{ replyIconEl }}</span>답글</button>
|
||||
<sc-if value="{{ c.deletable }}" hint-placeholder-val="{{ false }}">
|
||||
<button onClick="{{ c.onDelete }}" style="background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--fg3);" style-hover="color:var(--danger);">삭제</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<sc-if value="{{ c.hasReplies }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="margin-left:21px;padding-left:18px;border-left:2px solid var(--divider);display:flex;flex-direction:column;">
|
||||
<sc-for list="{{ c.replies }}" as="r" hint-placeholder-count="1">
|
||||
<div style="display:flex;gap:10px;padding:10px 0;">
|
||||
<span style="display:inline-flex;flex:0 0 auto;margin-top:1px;" innerel="{{ r.avatarEl }}">{{ r.avatarEl }}</span>
|
||||
<div style="min-width:0;flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<span style="font-size:12.5px;font-weight:700;color:var(--fg);font-variant-numeric:tabular-nums;">{{ r.userId }}</span>
|
||||
<span style="font-size:11px;color:var(--fg3);">{{ r.time }}</span>
|
||||
<sc-if value="{{ r.stamped }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:800;letter-spacing:.04em;color:var(--success-fg);background:var(--success-bg);border:1px solid var(--success-bd2);border-radius:5px;padding:1px 6px;animation:aidev-stamp .42s cubic-bezier(.2,.8,.3,1.2);"><span style="display:inline-flex;" innerel="{{ checkIconEl }}">{{ checkIconEl }}</span>LGTM</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
<div style="margin-top:3px;font-size:13px;color:var(--fg);line-height:1.6;white-space:pre-wrap;">{{ r.body }}</div>
|
||||
<sc-if value="{{ r.deletable }}" hint-placeholder-val="{{ false }}">
|
||||
<button onClick="{{ r.onDelete }}" style="margin-top:4px;background:transparent;border:0;padding:0;cursor:pointer;font-family:inherit;font-size:11.5px;font-weight:600;color:var(--fg3);" style-hover="color:var(--danger);">삭제</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding:14px 24px 22px;border-top:1px solid var(--divider);margin-top:6px;background:var(--bg-soft);">
|
||||
<sc-if value="{{ replyActive }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:9px;background:var(--accent-bg);border:1px solid var(--accent-bd);border-radius:7px;padding:6px 10px;">
|
||||
<span style="font-size:12px;color:var(--accent-strong);"><b style="font-variant-numeric:tabular-nums;">{{ replyTargetId }}</b> 님에게 답글 작성 중</span>
|
||||
<button onClick="{{ onCancelReply }}" style="background:transparent;border:0;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:var(--link);">취소</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="display:flex;gap:10px;align-items:flex-end;">
|
||||
<span style="display:inline-flex;flex:0 0 auto;" innerel="{{ headerAvatarLightEl }}">{{ headerAvatarLightEl }}</span>
|
||||
<textarea value="{{ commentDraft }}" onInput="{{ onCommentInput }}" onKeyDown="{{ onCommentKey }}" placeholder="{{ commentPlaceholder }}" rows="1" style="flex:1;resize:none;font-family:inherit;font-size:13.5px;line-height:1.5;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:9px;padding:9px 12px;outline:none;min-height:40px;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);"></textarea>
|
||||
<button onClick="{{ onSubmitComment }}" style="{{ submitBtnStyle }}">등록</button>
|
||||
</div>
|
||||
<div style="margin-top:7px;font-size:11px;color:var(--fg3);padding-left:38px;">Enter 등록 · Shift+Enter 줄바꿈 · 대댓글은 1뎁스까지만 가능합니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- ===================== SHARE MODAL ===================== -->
|
||||
<sc-if value="{{ shareOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div onClick="{{ onCloseShare }}" style="position:fixed;inset:0;z-index:500;background:var(--overlay);display:flex;align-items:flex-start;justify-content:center;padding:64px 20px;overflow-y:auto;animation:aidev-fade .16s ease-out;">
|
||||
<div onClick="{{ stopProp }}" style="width:100%;max-width:520px;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 16px 48px var(--shadow-strong);overflow:hidden;animation:aidev-pop .18s ease-out;">
|
||||
<div style="padding:20px 24px;border-bottom:1px solid var(--divider);display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3 style="margin:0;font-size:17px;font-weight:800;color:var(--fg);">새 프로젝트 등록</h3>
|
||||
<button onClick="{{ onCloseShare }}" style="width:32px;height:32px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--fg2);" style-hover="background:var(--panel);border-color:var(--border-soft);" innerel="{{ closeIconEl }}">{{ closeIconEl }}</button>
|
||||
</div>
|
||||
<div style="padding:20px 24px;display:flex;flex-direction:column;gap:15px;">
|
||||
<label style="display:block;">
|
||||
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">제목 <span style="color:var(--danger);">*</span></span>
|
||||
<input value="{{ shareTitle }}" onInput="{{ onShareTitle }}" placeholder="예) 방화벽 정책 DSL 변환기" style="width:100%;font-family:inherit;font-size:14px;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:9px 12px;outline:none;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);">
|
||||
</label>
|
||||
<label style="display:block;">
|
||||
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">한 줄 설명</span>
|
||||
<input value="{{ shareDesc }}" onInput="{{ onShareDesc }}" placeholder="무엇을 하는 프로젝트인가요?" style="width:100%;font-family:inherit;font-size:14px;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:9px 12px;outline:none;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);">
|
||||
</label>
|
||||
<label style="display:block;">
|
||||
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">태그 <span style="font-weight:500;color:var(--fg3);">쉼표로 구분</span></span>
|
||||
<input value="{{ shareTags }}" onInput="{{ onShareTags }}" placeholder="infra, react, poc" style="width:100%;font-family:inherit;font-size:14px;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:9px 12px;outline:none;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);">
|
||||
</label>
|
||||
<label style="display:block;">
|
||||
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">배포 URL <span style="font-weight:500;color:var(--fg3);">선택 · 없으면 바로가기 숨김</span></span>
|
||||
<input value="{{ shareUrl }}" onInput="{{ onShareUrl }}" placeholder="https://myapp.apps.bokdev.in" style="width:100%;font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:13px;color:var(--fg);background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:9px 12px;outline:none;" style-focus="border-color:var(--link);box-shadow:0 0 0 3px var(--focus-ring);">
|
||||
</label>
|
||||
<div>
|
||||
<span style="display:block;font-size:12.5px;font-weight:700;color:var(--fg);margin-bottom:6px;">공개 범위</span>
|
||||
<div style="display:inline-flex;background:var(--inset);border:1px solid var(--border);border-radius:8px;padding:3px;gap:3px;">
|
||||
<button onClick="{{ onShareVisPublic }}" style="{{ visPublicStyle }}"><span style="display:inline-flex;" innerel="{{ globeMiniEl }}">{{ globeMiniEl }}</span>공개</button>
|
||||
<button onClick="{{ onShareVisPrivate }}" style="{{ visPrivateStyle }}"><span style="display:inline-flex;" innerel="{{ lockMiniEl }}">{{ lockMiniEl }}</span>비공개</button>
|
||||
</div>
|
||||
<div style="margin-top:7px;font-size:11.5px;color:var(--fg3);">{{ visHint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 24px;border-top:1px solid var(--divider);background:var(--bg-soft);display:flex;align-items:center;justify-content:flex-end;gap:9px;">
|
||||
<button onClick="{{ onCloseShare }}" style="background:var(--btn-bg);border:1px solid var(--btn-bd);border-radius:8px;padding:9px 16px;cursor:pointer;font-family:inherit;font-size:13.5px;font-weight:600;color:var(--fg);" style-hover="background:var(--btn-hover);">취소</button>
|
||||
<button onClick="{{ onSubmitShare }}" style="{{ shareSubmitStyle }}">등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- ===================== TOAST ===================== -->
|
||||
<sc-if value="{{ toastOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:600;background:#1f2328;color:#fff;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:11px 16px;font-size:13px;font-weight:500;box-shadow:0 8px 24px rgba(1,4,9,.4);display:flex;align-items:center;gap:9px;max-width:90vw;animation:aidev-toast 2.6s ease forwards;">
|
||||
<span style="display:inline-flex;color:#3fb950;" innerel="{{ toastIconEl }}">{{ toastIconEl }}</span>{{ toastMsg }}
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- ===================== FLYERS OVERLAY ===================== -->
|
||||
<div style="position:fixed;inset:0;z-index:650;pointer-events:none;overflow:hidden;">
|
||||
<sc-for list="{{ flyers }}" as="f" hint-placeholder-count="0">
|
||||
<span style="{{ f.style }}">{{ f.content }}</span>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":1240},"brandName":{"editor":"text","default":"AI DEV","tsType":"string","section":"브랜드"},"defaultTheme":{"editor":"enum","default":"light","options":["light","dark"],"tsType":"string","section":"테마"},"defaultAdminView":{"editor":"boolean","default":false,"tsType":"boolean","section":"권한"},"defaultSort":{"editor":"enum","default":"latest","options":["latest","popular"],"tsType":"string","section":"피드"},"enableEasterEggs":{"editor":"boolean","default":true,"tsType":"boolean","section":"재미 요소"}}">
|
||||
class Component extends DCLogic {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const sortDefault = (this.props.defaultSort === 'popular') ? 'popular' : 'latest';
|
||||
this.state = {
|
||||
theme: 'light',
|
||||
adminView: this.props.defaultAdminView === true,
|
||||
userMenuOpen: false,
|
||||
sortMode: sortDefault,
|
||||
myFilter: 'all',
|
||||
feedView: 'all',
|
||||
searchQuery: '',
|
||||
modalId: null,
|
||||
shareOpen: false,
|
||||
commentDraft: '',
|
||||
replyParent: null,
|
||||
shareTitle: '', shareDesc: '', shareTags: '', shareUrl: '', shareVisibility: 'public',
|
||||
toast: null,
|
||||
flyers: [],
|
||||
projects: this.seedProjects(),
|
||||
};
|
||||
this.logoRef = React.createRef();
|
||||
this._fid = 0; this._logoClicks = 0; this._logoTimer = null; this._starTaps = {}; this._toastTimer = null; this._cid = 1000;
|
||||
}
|
||||
|
||||
seedProjects() {
|
||||
const me = '0310700';
|
||||
return [
|
||||
{ id: 1, ownerId: me, title: '방화벽 정책 DSL 변환기', description: '사람이 읽는 규칙을 장비별 설정으로 컴파일하는 사내 DSL. 검토·승인 후 배포.', tags: ['infra', 'fastapi', 'internal'], lang: 'Python', appUrl: 'https://fwdsl.apps.bokdev.in', repoUrl: 'https://gitea.bokdev.in/0310700/fw-dsl', shared: true, createdAt: '2026.06.22', createdAtN: 22, heartCount: 19, hearted: false, likeCount: 14, liked: false, comments: [
|
||||
{ id: 11, userId: '0298211', parentId: null, body: '규칙 파싱은 PEG로 하셨어요? 오류 메시지가 꽤 친절하네요.', time: '3일 전', stamped: false },
|
||||
{ id: 12, userId: '0310700', parentId: 11, body: 'lark 기반 PEG입니다. 라인/컬럼까지 잡아줘서 디버깅이 편해요.', time: '3일 전', stamped: false },
|
||||
] },
|
||||
{ id: 2, ownerId: me, title: 'QR 출결 앱 Attendly', description: '세미나·교육 QR 체크인과 통계 대시보드. 주말에 만든 사이드 프로젝트.', tags: ['react', 'k8s', 'poc'], lang: 'TypeScript', appUrl: 'https://attendly.apps.bokdev.in', repoUrl: 'https://gitea.bokdev.in/0310700/attendly', shared: true, createdAt: '2026.06.18', createdAtN: 18, heartCount: 28, hearted: false, likeCount: 9, liked: false, comments: [
|
||||
{ id: 21, userId: '0265533', parentId: null, body: '지난 세미나 때 잘 썼습니다. 출석률 그래프 깔끔하더라구요.', time: '5일 전', stamped: false },
|
||||
] },
|
||||
{ id: 7, ownerId: me, title: '사번 OCR 자동입력 확장', description: '사내 폼에 사번을 자동으로 채워주는 크롬 확장. 아직 다듬는 중이라 비공개.', tags: ['poc', 'internal'], lang: 'TypeScript', appUrl: '', repoUrl: 'https://gitea.bokdev.in/0310700/sabun-ocr', shared: false, createdAt: '2026.06.27', createdAtN: 27, heartCount: 0, hearted: false, likeCount: 0, liked: false, comments: [] },
|
||||
{ id: 3, ownerId: '0298211', title: '사내 패치 저장소 콘솔', description: '폐쇄망 오프라인 패치 미러링과 2인 승인 워크플로를 관리하는 운영 콘솔.', tags: ['infra', 'internal'], lang: 'Go', appUrl: 'https://patchhub.apps.bokdev.in', repoUrl: 'https://gitea.bokdev.in/0298211/patchhub', shared: true, createdAt: '2026.06.25', createdAtN: 25, heartCount: 31, hearted: false, likeCount: 23, liked: true, comments: [
|
||||
{ id: 31, userId: '0310700', parentId: null, body: '오프라인 미러링 무결성은 어떻게 검증하세요? 해시 비교인가요?', time: '2일 전', stamped: false },
|
||||
{ id: 32, userId: '0298211', parentId: 31, body: '네, sha256 매니페스트 비교 후 통과한 것만 승인 큐로 넘어갑니다.', time: '2일 전', stamped: false },
|
||||
{ id: 33, userId: '0310700', parentId: 31, body: '오 깔끔하네요 👍', time: '2일 전', stamped: false },
|
||||
{ id: 34, userId: '0311902', parentId: null, body: '구조 깔끔합니다. 저희 팀 패치 배포에도 붙이고 싶어요. LGTM 👍', time: '어제', stamped: true },
|
||||
{ id: 35, userId: '0265533', parentId: null, body: '승인 워크플로에 2인 결재 외에 긴급 단독 승인 모드도 있나요?', time: '7시간 전', stamped: false },
|
||||
] },
|
||||
{ id: 4, ownerId: '0311902', title: 'LLM 사내 위키 검색', description: '사내 문서를 RAG로 검색하는 프로토타입. LiteLLM 게이트웨이 경유로 모델 교체 자유.', tags: ['poc', 'fastapi', 'internal'], lang: 'Python', appUrl: 'https://wikillm.apps.bokdev.in', repoUrl: 'https://gitea.bokdev.in/0311902/wiki-rag', shared: true, createdAt: '2026.06.20', createdAtN: 20, heartCount: 12, hearted: false, likeCount: 18, liked: true, comments: [
|
||||
{ id: 41, userId: '0287740', parentId: null, body: '청크 사이즈랑 임베딩 모델 뭐 쓰셨는지 공유 가능할까요?', time: '4일 전', stamped: false },
|
||||
] },
|
||||
{ id: 5, ownerId: '0265533', title: '회의실 예약 슬랙봇', description: '슬랙 슬래시 커맨드로 회의실 예약·취소·조회. 캘린더 연동까지.', tags: ['poc', 'internal'], lang: 'Python', appUrl: '', repoUrl: 'https://gitea.bokdev.in/0265533/room-bot', shared: true, createdAt: '2026.06.12', createdAtN: 12, heartCount: 15, hearted: false, likeCount: 6, liked: false, comments: [] },
|
||||
{ id: 6, ownerId: '0287740', title: '로그 이상탐지 대시보드', description: 'k8s 로그 스트림에서 이상치를 실시간 시각화. 아직 URL 정리 중이라 저장소만 공개.', tags: ['k8s', 'react', 'infra'], lang: 'TypeScript', appUrl: '', repoUrl: 'https://gitea.bokdev.in/0287740/log-anomaly', shared: true, createdAt: '2026.06.15', createdAtN: 15, heartCount: 7, hearted: false, likeCount: 11, liked: false, comments: [
|
||||
{ id: 61, userId: '0310700', parentId: null, body: '이상탐지 임계값은 고정인가요, 아니면 학습 기반인가요?', time: '6일 전', stamped: false },
|
||||
{ id: 62, userId: '0287740', parentId: 61, body: 'EWMA 기반 동적 임계값이에요. 트래픽 패턴 따라 조정됩니다.', time: '6일 전', stamped: false },
|
||||
] },
|
||||
];
|
||||
}
|
||||
|
||||
langColor(l) { return ({ Python: '#3572A5', TypeScript: '#3178c6', Go: '#00ADD8', JavaScript: '#f1e05a', Rust: '#dea584', Shell: '#89e051' })[l] || '#8b949e'; }
|
||||
|
||||
/* ---------- icons ---------- */
|
||||
oct(path, o) { o = o || {}; const s = o.size || 16, f = o.fill || 'currentColor'; return '<svg xmlns="http://www.w3.org/2000/svg" width="' + s + '" height="' + s + '" viewBox="0 0 16 16" fill="' + f + '" style="display:block">' + path + '</svg>'; }
|
||||
html(str, style) { return React.createElement('span', { style: Object.assign({ display: 'inline-flex' }, style || {}), dangerouslySetInnerHTML: { __html: str } }); }
|
||||
|
||||
OC(name) {
|
||||
const P = {
|
||||
code: '<path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/>',
|
||||
search: '<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/>',
|
||||
sun: '<path d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.061a.75.75 0 0 1 1.061 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.751.751 0 0 1-1.061-1.061l1.06-1.06a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8Zm-8 5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm3.536-1.464a.75.75 0 0 1 1.06 0l1.061 1.06a.751.751 0 0 1-1.061 1.061l-1.06-1.06a.75.75 0 0 1 0-1.061ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-1.06 1.06l-1.061-1.06a.75.75 0 0 1 0-1.06Z"/>',
|
||||
moon: '<path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"/>',
|
||||
caret: '<path d="m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"/>',
|
||||
signout: '<path d="M2 2.75C2 1.784 2.784 1 3.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 2 13.25Zm10.44 4.5-1.97-1.97a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H6.75a.75.75 0 0 1 0-1.5Z"/>',
|
||||
lock: '<path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/>',
|
||||
globe: '<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM5.78 8.75a9.64 9.64 0 0 0 1.363 4.177c.255.426.542.832.857 1.215.245-.296.551-.705.857-1.215A9.64 9.64 0 0 0 10.22 8.75Zm4.44-1.5a9.64 9.64 0 0 0-1.363-4.177c-.307-.51-.612-.919-.857-1.215a9.927 9.927 0 0 0-.857 1.215A9.64 9.64 0 0 0 5.78 7.25Zm-5.944 1.5H1.543a6.507 6.507 0 0 0 4.666 5.5c-.123-.181-.24-.365-.352-.552-.715-1.192-1.437-2.874-1.581-4.948Zm-2.733-1.5h2.733c.144-2.074.866-3.756 1.58-4.948.12-.197.237-.381.353-.552a6.507 6.507 0 0 0-4.666 5.5Zm10.181 1.5c-.144 2.074-.866 3.756-1.58 4.948-.12.197-.237.381-.353.552a6.507 6.507 0 0 0 4.666-5.5Zm2.733-1.5a6.507 6.507 0 0 0-4.666-5.5c.123.181.24.365.353.552.714 1.192 1.436 2.874 1.58 4.948Z"/>',
|
||||
repo: '<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/>',
|
||||
starFill: '<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/>',
|
||||
star: '<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z"/>',
|
||||
heart: '<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002ZM4.25 2.5c-1.336 0-2.75 1.164-2.75 3 0 2.15 1.58 4.144 3.365 5.682A20.58 20.58 0 0 0 8 13.393a20.58 20.58 0 0 0 3.135-2.211C12.92 9.644 14.5 7.65 14.5 5.5c0-1.836-1.414-3-2.75-3-1.373 0-2.609.986-3.029 2.456a.749.749 0 0 1-1.442 0C6.859 3.486 5.623 2.5 4.25 2.5Z"/>',
|
||||
heartFill: '<path d="m8 14.25.345.666a.75.75 0 0 1-.69 0l-.008-.004-.018-.01a7.152 7.152 0 0 1-.31-.17 22.055 22.055 0 0 1-3.434-2.414C2.045 10.731 0 8.35 0 5.5 0 2.836 2.086 1 4.25 1 5.797 1 7.153 1.802 8 3.02 8.847 1.802 10.203 1 11.75 1 13.914 1 16 2.836 16 5.5c0 2.85-2.045 5.231-3.885 6.818a22.066 22.066 0 0 1-3.744 2.584l-.018.01-.006.003h-.002Z"/>',
|
||||
comment: '<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>',
|
||||
plus: '<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/>',
|
||||
x: '<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>',
|
||||
reply: '<path d="M6.78 1.97a.75.75 0 0 1 0 1.06L3.81 6h6.44A4.75 4.75 0 0 1 15 10.75v2.5a.75.75 0 0 1-1.5 0v-2.5a3.25 3.25 0 0 0-3.25-3.25H3.81l2.97 2.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L1.47 7.28a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"/>',
|
||||
check: '<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L1.72 8.78a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/>',
|
||||
ext: '<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>',
|
||||
package: '<path d="m8.878.392 5.25 3.045c.54.314.872.89.872 1.514v6.098a1.75 1.75 0 0 1-.872 1.514l-5.25 3.045a1.75 1.75 0 0 1-1.756 0l-5.25-3.045A1.75 1.75 0 0 1 1 11.049V4.951c0-.624.332-1.201.872-1.514L7.122.392a1.75 1.75 0 0 1 1.756 0ZM7.875 1.69l-4.63 2.685L8 7.133l4.755-2.758-4.63-2.685a.248.248 0 0 0-.25 0ZM2.5 5.677v5.372c0 .09.047.171.125.216l4.625 2.683V8.432Zm6.25 8.271 4.625-2.683a.25.25 0 0 0 .125-.216V5.677L8.75 8.432Z"/>',
|
||||
server: '<path d="M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v4c0 .372-.116.717-.314 1 .198.283.314.628.314 1v4a1.75 1.75 0 0 1-1.75 1.75H1.75A1.75 1.75 0 0 1 0 12.75v-4c0-.358.109-.707.314-1a1.739 1.739 0 0 1-.314-1v-4C0 1.784.784 1 1.75 1ZM1.5 2.75v4c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25Zm.25 5.75a.25.25 0 0 0-.25.25v4c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25ZM7 4.75A.75.75 0 0 1 7.75 4h4.5a.75.75 0 0 1 0 1.5h-4.5A.75.75 0 0 1 7 4.75ZM7.75 10h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM3 4.75A.75.75 0 0 1 3.75 4h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 4.75ZM3.75 10h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5Z"/>',
|
||||
database: '<path d="M8 0c4.42 0 8 1.234 8 2.75v10.5C16 14.766 12.42 16 8 16s-8-1.234-8-2.75V2.75C0 1.234 3.58 0 8 0Zm6.5 9.376c-1.732.886-4.041 1.374-6.5 1.374s-4.768-.488-6.5-1.374v3.874c0 .145.116.353.448.582.327.225.84.452 1.54.65C4.86 14.872 6.348 15 8 15c1.652 0 3.14-.128 4.512-.518.7-.198 1.213-.425 1.54-.65.332-.229.448-.437.448-.582Zm0-3.5c-1.732.886-4.041 1.374-6.5 1.374s-4.768-.488-6.5-1.374v1.624c0 .145.116.353.448.582.327.225.84.452 1.54.65C4.86 11.372 6.348 11.5 8 11.5c1.652 0 3.14-.128 4.512-.518.7-.198 1.213-.425 1.54-.65.332-.229.448-.437.448-.582ZM8 1.5c-1.652 0-3.14.128-4.512.518-.7.198-1.213.425-1.54.65-.332.229-.448.437-.448.582 0 .145.116.353.448.582.327.225.84.452 1.54.65C4.86 5.872 6.348 6 8 6c1.652 0 3.14-.128 4.512-.518.7-.198 1.213-.425 1.54-.65.332-.229.448-.437.448-.582 0-.145-.116-.353-.448-.582-.327-.225-.84-.452-1.54-.65C11.14 1.628 9.652 1.5 8 1.5Z"/>',
|
||||
};
|
||||
return P[name] || P.repo;
|
||||
}
|
||||
|
||||
starSvg(filled, size) { return this.oct(filled ? this.OC('starFill') : this.OC('star'), { size: size || 15, fill: filled ? 'var(--attn)' : 'currentColor' }); }
|
||||
heartSvg(filled, size) { return this.oct(filled ? this.OC('heartFill') : this.OC('heart'), { size: size || 14, fill: filled ? 'var(--heart)' : 'currentColor' }); }
|
||||
|
||||
hue(seed) { let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; return h; }
|
||||
identicon(seed, size) {
|
||||
size = size || 28;
|
||||
const h = this.hue(seed), hue = h % 360, sat = 48 + (h % 22);
|
||||
const color = 'hsl(' + hue + ',' + sat + '%,52%)';
|
||||
const grid = 5, on = new Array(25).fill(false);
|
||||
for (let col = 0; col < 3; col++) for (let row = 0; row < 5; row++) { const bit = (h >> (col * 5 + row)) & 1; on[row * 5 + col] = !!bit; on[row * 5 + (4 - col)] = !!bit; }
|
||||
const inner = Math.round(size * 0.74), cell = inner / grid; let rects = '';
|
||||
for (let r = 0; r < grid; r++) for (let c = 0; c < grid; c++) if (on[r * grid + c]) rects += '<rect x="' + (c * cell).toFixed(2) + '" y="' + (r * cell).toFixed(2) + '" width="' + cell.toFixed(2) + '" height="' + cell.toFixed(2) + '"/>';
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + inner + '" height="' + inner + '" viewBox="0 0 ' + inner + ' ' + inner + '" fill="' + color + '" style="display:block">' + rects + '</svg>';
|
||||
return React.createElement('span', { style: { width: size, height: size, borderRadius: Math.max(5, Math.round(size * 0.2)), background: 'var(--avatar-bg)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: '0 0 auto', overflow: 'hidden', boxShadow: 'inset 0 0 0 1px color-mix(in srgb, var(--fg) 6%, transparent)' }, dangerouslySetInnerHTML: { __html: svg } });
|
||||
}
|
||||
|
||||
toolIconEl(tool, isAdmin, ink, adminInk) {
|
||||
const color = isAdmin ? adminInk : ink;
|
||||
if (tool.slug) {
|
||||
return React.createElement('img', { src: 'https://cdn.simpleicons.org/' + tool.slug + '/' + color, width: 23, height: 23, alt: tool.name + ' 로고', draggable: false, loading: 'lazy', style: { display: 'block', width: 23, height: 23 } });
|
||||
}
|
||||
return this.html(this.oct(this.OC(tool.glyph), { size: 23, fill: isAdmin ? 'var(--admin-fg)' : 'var(--fg)' }));
|
||||
}
|
||||
|
||||
/* ---------- theme ---------- */
|
||||
applyTheme(t) { try { document.documentElement.setAttribute('data-aidev-theme', t); } catch (e) {} }
|
||||
toggleTheme() { const t = this.state.theme === 'dark' ? 'light' : 'dark'; this.applyTheme(t); try { localStorage.setItem('aidev-theme', t); } catch (e) {} this.setState({ theme: t }); }
|
||||
|
||||
/* ---------- effects ---------- */
|
||||
eggsOn() { return this.props.enableEasterEggs !== false; }
|
||||
addFlyer(content, style, ttl) { const id = 'fly' + (this._fid++); this.setState(s => ({ flyers: s.flyers.concat([{ id, content, style }]) })); setTimeout(() => { this.setState(s => ({ flyers: s.flyers.filter(f => f.id !== id) })); }, ttl); }
|
||||
base(extra) { return Object.assign({ position: 'fixed', pointerEvents: 'none', userSelect: 'none', willChange: 'transform,opacity' }, extra); }
|
||||
burstThumbs() { if (!this.eggsOn()) return; const cx = window.innerWidth / 2; for (let i = 0; i < 6; i++) { const dx = (Math.random() - 0.5) * 180; this.addFlyer('👍', this.base({ left: (cx + dx) + 'px', top: (window.innerHeight * 0.62) + 'px', fontSize: (22 + Math.random() * 14) + 'px', animation: 'aidev-flyup ' + (0.9 + Math.random() * 0.3) + 's ease-out ' + (i * 60) + 'ms forwards', opacity: 0 }), 1500); } }
|
||||
burstRocket() { if (!this.eggsOn()) return; this.addFlyer('🚀', this.base({ left: '-40px', top: (window.innerHeight * (0.18 + Math.random() * 0.2)) + 'px', fontSize: '34px', animation: 'aidev-rocket 1.1s ease-in forwards' }), 1300); }
|
||||
burstSparkle(x, y) { if (!this.eggsOn()) return; const chars = ['✨', '⭐', '💫']; for (let i = 0; i < 7; i++) { const dx = (Math.random() - 0.5) * 90, dy = (Math.random() - 0.5) * 90; this.addFlyer(chars[i % 3], this.base({ left: (x + dx) + 'px', top: (y + dy) + 'px', fontSize: (12 + Math.random() * 12) + 'px', animation: 'aidev-sparkle ' + (0.5 + Math.random() * 0.3) + 's ease-out forwards' }), 900); } }
|
||||
showToast(msg) { this.setState({ toast: msg }); if (this._toastTimer) clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => this.setState({ toast: null }), 2550); }
|
||||
|
||||
/* ---------- handlers ---------- */
|
||||
toggleAdmin() { this.setState(s => ({ adminView: !s.adminView })); }
|
||||
toggleUserMenu() { this.setState(s => ({ userMenuOpen: !s.userMenuOpen })); }
|
||||
logout() { this.setState({ userMenuOpen: false }); this.showToast('로그아웃 — Keycloak 세션 종료 (데모)'); }
|
||||
logoClick() {
|
||||
if (!this.eggsOn()) return;
|
||||
this._logoClicks++; clearTimeout(this._logoTimer); this._logoTimer = setTimeout(() => { this._logoClicks = 0; }, 700);
|
||||
if (this._logoClicks >= 4) { this._logoClicks = 0; const el = this.logoRef.current; if (el) { el.style.animation = 'aidev-shake .5s ease'; const old = el.style.filter; el.style.filter = 'hue-rotate(180deg) saturate(1.5)'; setTimeout(() => { el.style.animation = ''; el.style.filter = old; }, 520); } this.burstThumbs(); this.showToast('🌈 git commit -m "found an easter egg"'); }
|
||||
}
|
||||
openTool(tool) { this.setState({ userMenuOpen: false }); this.showToast(tool.name + ' · SSO 자동로그인으로 새 탭에서 열기 (데모)'); try { console.log('%c[AI DEV] open tool → ' + tool.name + ' (' + tool.url + ', target=_blank)', 'color:#0969da'); } catch (e) {} }
|
||||
openApp(p, e) { if (e) e.stopPropagation(); this.showToast('바로가기 → ' + (p.appUrl || '') + ' (새 탭, 데모)'); }
|
||||
openRepo(p, e) { if (e) e.stopPropagation(); this.showToast('저장소 → ' + (p.repoUrl || '') + ' (새 탭, 데모)'); }
|
||||
toggleLike(id, e) {
|
||||
if (e) e.stopPropagation();
|
||||
const now = Date.now(); const taps = (this._starTaps[id] || []).filter(t => now - t < 1400); taps.push(now); this._starTaps[id] = taps;
|
||||
if (taps.length >= 4 && e) this.burstSparkle(e.clientX, e.clientY);
|
||||
this.setState(s => ({ projects: s.projects.map(p => p.id === id ? Object.assign({}, p, { liked: !p.liked, likeCount: p.likeCount + (p.liked ? -1 : 1) }) : p) }));
|
||||
}
|
||||
toggleHeart(id, e) { if (e) e.stopPropagation(); this.setState(s => ({ projects: s.projects.map(p => p.id === id ? Object.assign({}, p, { hearted: !p.hearted, heartCount: (p.heartCount || 0) + (p.hearted ? -1 : 1) }) : p) })); }
|
||||
toggleShare(id) {
|
||||
let nowShared = false, title = '';
|
||||
this.setState(s => ({ projects: s.projects.map(p => { if (p.id !== id) return p; nowShared = !p.shared; title = p.title; return Object.assign({}, p, { shared: !p.shared }); }) }), () => { this.showToast(nowShared ? '“' + title + '” 공개됨 — 이제 피드에 표시됩니다' : '“' + title + '” 비공개로 전환됨'); });
|
||||
}
|
||||
openModal(id) { this.setState({ modalId: id, userMenuOpen: false, commentDraft: '', replyParent: null }); try { document.body.style.overflow = 'hidden'; } catch (e) {} }
|
||||
closeModal() { this.setState({ modalId: null, replyParent: null, commentDraft: '' }); try { document.body.style.overflow = ''; } catch (e) {} }
|
||||
startReply(commentId) { this.setState({ replyParent: commentId }); }
|
||||
cancelReply() { this.setState({ replyParent: null }); }
|
||||
detectStamp(body) { return /\blgtm\b/i.test(body); }
|
||||
hasRocket(body) { return body.indexOf('🚀') >= 0 || body.toLowerCase().indexOf(':shipit:') >= 0; }
|
||||
submitComment() {
|
||||
const body = (this.state.commentDraft || '').trim(); if (!body || this.state.modalId == null) return;
|
||||
const id = this._cid++; const stamped = this.detectStamp(body), rocket = this.hasRocket(body); const parentId = this.state.replyParent;
|
||||
const newC = { id, userId: '0310700', parentId, body, time: '방금 전', stamped };
|
||||
this.setState(s => ({ projects: s.projects.map(p => p.id === s.modalId ? Object.assign({}, p, { comments: p.comments.concat([newC]) }) : p), commentDraft: '', replyParent: null }));
|
||||
if (stamped) { this.burstThumbs(); this.showToast('LGTM 👍 — Looks Good To Me!'); }
|
||||
if (rocket) this.burstRocket();
|
||||
}
|
||||
onCommentKey(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.submitComment(); } }
|
||||
deleteComment(cid) { this.setState(s => ({ projects: s.projects.map(p => p.id !== s.modalId ? p : Object.assign({}, p, { comments: p.comments.filter(c => c.id !== cid && c.parentId !== cid) })) })); }
|
||||
openShare() { this.setState({ shareOpen: true, userMenuOpen: false, shareTitle: '', shareDesc: '', shareTags: '', shareUrl: '', shareVisibility: 'public' }); try { document.body.style.overflow = 'hidden'; } catch (e) {} }
|
||||
closeShare() { this.setState({ shareOpen: false }); try { document.body.style.overflow = ''; } catch (e) {} }
|
||||
langFromTags(tags) { const t = tags.map(x => x.toLowerCase()); if (t.indexOf('fastapi') >= 0 || t.indexOf('python') >= 0) return 'Python'; if (t.indexOf('react') >= 0 || t.indexOf('ts') >= 0) return 'TypeScript'; if (t.indexOf('go') >= 0 || t.indexOf('k8s') >= 0) return 'Go'; return 'TypeScript'; }
|
||||
submitShare() {
|
||||
const title = (this.state.shareTitle || '').trim(); if (!title) { this.showToast('제목을 입력해주세요'); return; }
|
||||
const tags = (this.state.shareTags || '').split(',').map(t => t.trim().replace(/^#/, '')).filter(Boolean).slice(0, 5);
|
||||
const id = Date.now(), url = (this.state.shareUrl || '').trim(); const fin = tags.length ? tags : ['internal'];
|
||||
const isPub = this.state.shareVisibility !== 'private';
|
||||
const np = { id, ownerId: '0310700', title, description: (this.state.shareDesc || '').trim() || '설명 없음', tags: fin, lang: this.langFromTags(fin), appUrl: url, repoUrl: '', shared: isPub, createdAt: '방금', createdAtN: 999, likeCount: 0, liked: false, heartCount: 0, hearted: false, comments: [] };
|
||||
this.setState(s => ({ projects: [np].concat(s.projects), shareOpen: false, myFilter: isPub ? 'all' : 'private' }));
|
||||
try { document.body.style.overflow = ''; } catch (e) {}
|
||||
this.showToast(isPub ? '“' + title + '” 등록 완료 · 피드에 공유됨' : '“' + title + '” 비공개로 등록됨');
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
let t = 'light';
|
||||
try { t = localStorage.getItem('aidev-theme') || this.props.defaultTheme || 'light'; } catch (e) { t = this.props.defaultTheme || 'light'; }
|
||||
if (t !== 'light' && t !== 'dark') t = 'light';
|
||||
this.applyTheme(t); this.setState({ theme: t });
|
||||
if (!this.eggsOn()) return;
|
||||
try {
|
||||
const art = [' ___ ____ ____ _______ __', ' / | / _/ / __ \\/ ____/ | / /', ' / /| | / / / / / / __/ | | / / ', '/ ___ |_/ / / /_/ / /___ | |/ / ', '/_/ |_/___/ /_____/_____/ |___/ '].join('\n');
|
||||
console.log('%c' + art, 'color:#0969da;font-family:monospace;font-size:11px;line-height:1.1');
|
||||
console.log('%c👀 코드 구경 왔어요? 환영합니다.', 'color:#1f883d;font-size:13px;font-weight:bold');
|
||||
console.log('%c여기까지 열어봤다면 우리랑 잘 맞을지도. 사내 개발팀은 늘 동료를 찾고 있어요 — git blame 하지 말고 git praise 합시다. 🙇', 'color:#656d76;font-size:12px');
|
||||
console.log('%cps. 댓글에 LGTM 한 번 쳐보세요.', 'color:#8b949e;font-size:11px');
|
||||
} catch (e) {}
|
||||
}
|
||||
componentWillUnmount() { try { document.body.style.overflow = ''; } catch (e) {} }
|
||||
|
||||
threadFor(p) {
|
||||
const me = '0310700';
|
||||
return p.comments.filter(c => c.parentId == null).map(c => {
|
||||
const replies = p.comments.filter(r => r.parentId === c.id).map(r => ({ id: r.id, userId: r.userId, time: r.time, body: r.body, stamped: r.stamped, avatarEl: this.identicon(r.userId, 24), deletable: r.userId === me, onDelete: (e) => { if (e) e.stopPropagation(); this.deleteComment(r.id); } }));
|
||||
return { id: c.id, userId: c.userId, time: c.time, body: c.body, stamped: c.stamped, avatarEl: this.identicon(c.userId, 28), hasReplies: replies.length > 0, replies, deletable: c.userId === me, onReply: (e) => { if (e) e.stopPropagation(); this.startReply(c.id); }, onDelete: (e) => { if (e) e.stopPropagation(); this.deleteComment(c.id); } };
|
||||
});
|
||||
}
|
||||
|
||||
mapProject(p) {
|
||||
const shared = p.shared !== false;
|
||||
const visBadgeStyle = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '5px', flex: '0 0 auto',
|
||||
fontSize: '11px', fontWeight: 600, borderRadius: '999px', padding: '1px 8px 1px 7px', whiteSpace: 'nowrap',
|
||||
color: shared ? 'var(--success-fg)' : 'var(--fg2)',
|
||||
background: shared ? 'var(--success-bg)' : 'var(--panel)',
|
||||
border: '1px solid ' + (shared ? 'var(--success-bd)' : 'var(--border)'),
|
||||
};
|
||||
return {
|
||||
id: p.id, title: p.title, description: p.description, ownerId: p.ownerId, createdAt: p.createdAt, tags: p.tags,
|
||||
lang: p.lang || 'TypeScript', langColor: this.langColor(p.lang || 'TypeScript'),
|
||||
likeCount: p.likeCount, commentCount: p.comments.length, liked: p.liked,
|
||||
shared, isPrivate: !shared,
|
||||
visLabel: shared ? '공개' : '비공개',
|
||||
visIconEl: this.html(this.oct(this.OC(shared ? 'globe' : 'lock'), { size: 11 })),
|
||||
visBadgeStyle,
|
||||
hasAppUrl: !!p.appUrl, hasRepo: !!p.repoUrl,
|
||||
avatarEl: this.identicon(p.ownerId, 22),
|
||||
starEl: this.html(this.starSvg(p.liked, 14)), starMiniEl: this.html(this.starSvg(true, 13)),
|
||||
starLabel: p.liked ? 'Starred' : 'Star',
|
||||
heartCount: p.heartCount || 0, hearted: !!p.hearted,
|
||||
heartEl: this.html(this.heartSvg(p.hearted, 14)), heartMiniEl: this.html(this.heartSvg(true, 13)),
|
||||
heartBtnStyle: { display: 'inline-flex', alignItems: 'center', gap: '6px', flex: '0 0 auto', background: p.hearted ? 'var(--heart-bg)' : 'var(--btn-bg)', border: '1px solid ' + (p.hearted ? 'var(--heart-bd)' : 'var(--btn-bd)'), borderRadius: '7px', padding: '4px 9px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '12px', fontWeight: 600, color: p.hearted ? 'var(--heart-fg)' : 'var(--fg)', transition: 'background .14s ease,border-color .14s ease,color .14s ease' },
|
||||
onToggleHeart: (e) => this.toggleHeart(p.id, e),
|
||||
myCardStyle: { background: shared ? 'var(--card)' : 'var(--bg)', border: shared ? '1px solid var(--border-soft)' : '1.5px dashed var(--border)', borderRadius: '12px', padding: '15px 16px 14px', cursor: 'pointer', transition: 'box-shadow .16s ease,border-color .16s ease,transform .16s ease', display: 'flex', flexDirection: 'column' },
|
||||
starBtnStyle: { display: 'inline-flex', alignItems: 'center', gap: '6px', flex: '0 0 auto', background: p.liked ? 'var(--attn-bg)' : 'var(--btn-bg)', border: '1px solid ' + (p.liked ? 'var(--attn-bd)' : 'var(--btn-bd)'), borderRadius: '7px', padding: '4px 9px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '12px', fontWeight: 600, color: p.liked ? 'var(--attn-fg)' : 'var(--fg)', transition: 'background .14s ease,border-color .14s ease,color .14s ease' },
|
||||
onOpen: () => this.openModal(p.id),
|
||||
onToggleLike: (e) => this.toggleLike(p.id, e),
|
||||
onToggleShare: (e) => { if (e) e.stopPropagation(); this.toggleShare(p.id); },
|
||||
onOpenApp: (e) => this.openApp(p, e),
|
||||
onOpenRepo: (e) => this.openRepo(p, e),
|
||||
};
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const me = '0310700', admin = this.state.adminView, dark = this.state.theme === 'dark';
|
||||
const ink = dark ? 'e6edf3' : '1f2328', adminInk = dark ? 'bc8cff' : '8250df';
|
||||
|
||||
const userTools = [
|
||||
{ key: 'coder', slug: 'coder', name: 'Coder', desc: '클라우드 IDE' },
|
||||
{ key: 'gitea', slug: 'gitea', name: 'Gitea', desc: 'Git 저장소' },
|
||||
{ key: 'kubero', glyph: 'package', name: 'Kubero', desc: '배포 (PaaS)' },
|
||||
{ key: 'harbor', slug: 'harbor', name: 'Harbor', desc: '컨테이너 레지스트리' },
|
||||
];
|
||||
const adminTools = [
|
||||
{ key: 'openeverest', glyph: 'database', name: 'OpenEverest', desc: 'DB 콘솔' },
|
||||
{ key: 'minio', slug: 'minio', name: 'MinIO', desc: '오브젝트 스토리지' },
|
||||
{ key: 'keycloak', slug: 'keycloak', name: 'Keycloak', desc: '계정 / SSO 관리' },
|
||||
{ key: 'litellm', glyph: 'server', name: 'LiteLLM', desc: 'LLM 게이트웨이' },
|
||||
];
|
||||
const toolList = admin ? userTools.concat(adminTools) : userTools;
|
||||
const visibleTools = toolList.map((t, i) => {
|
||||
const isAdmin = i >= 4;
|
||||
return {
|
||||
name: t.name, desc: t.desc, isAdmin, url: 'https://' + t.key + '.bokdev.in/sso',
|
||||
iconEl: this.toolIconEl(t, isAdmin, ink, adminInk),
|
||||
iconWrapStyle: isAdmin ? { background: 'var(--admin-bg)', border: '1px solid var(--admin-bd)' } : { background: 'var(--tile)', border: '1px solid var(--tile-bd)' },
|
||||
cardStyle: { position: 'relative', textAlign: 'left', background: 'var(--card)', border: '1px solid ' + (isAdmin ? 'var(--admin-bd)' : 'var(--border-soft)'), borderRadius: '12px', padding: '16px', cursor: 'pointer', fontFamily: 'inherit', transition: 'box-shadow .16s ease,border-color .16s ease,transform .16s ease' },
|
||||
onOpen: () => this.openTool(t),
|
||||
};
|
||||
});
|
||||
|
||||
const q = this.state.searchQuery.trim().toLowerCase();
|
||||
let feed = this.state.projects.filter(p => p.shared !== false);
|
||||
const starredCount = feed.filter(p => p.liked).length;
|
||||
if (this.state.feedView === 'starred') feed = feed.filter(p => p.liked);
|
||||
if (q) feed = feed.filter(p => (p.title + ' ' + p.description + ' ' + p.tags.join(' ') + ' ' + p.ownerId).toLowerCase().indexOf(q) >= 0);
|
||||
if (this.state.sortMode === 'popular') feed.sort((a, b) => (b.heartCount || 0) - (a.heartCount || 0) || b.likeCount - a.likeCount || b.createdAtN - a.createdAtN);
|
||||
else feed.sort((a, b) => b.createdAtN - a.createdAtN);
|
||||
const feedProjects = feed.map(p => this.mapProject(p));
|
||||
|
||||
const mineAll = this.state.projects.filter(p => p.ownerId === me).sort((a, b) => b.createdAtN - a.createdAtN);
|
||||
const mySharedCount = mineAll.filter(p => p.shared !== false).length;
|
||||
const myPrivateCount = mineAll.length - mySharedCount;
|
||||
let mineFiltered = mineAll;
|
||||
if (this.state.myFilter === 'shared') mineFiltered = mineAll.filter(p => p.shared !== false);
|
||||
else if (this.state.myFilter === 'private') mineFiltered = mineAll.filter(p => p.shared === false);
|
||||
const myProjects = mineFiltered.map(p => this.mapProject(p));
|
||||
|
||||
const modalRaw = this.state.modalId != null ? this.state.projects.find(p => p.id === this.state.modalId) : null;
|
||||
const modalProject = modalRaw ? this.mapProject(modalRaw) : { tags: [], title: '', description: '', ownerId: '', createdAt: '', commentCount: 0, hasAppUrl: false, hasRepo: false, isPrivate: false, likeCount: 0, heartCount: 0, hearted: false, heartEl: null, lang: '', langColor: '#8b949e', visLabel: '', visIconEl: null, visBadgeStyle: {}, avatarEl: null, starEl: null, starLabel: 'Star' };
|
||||
const threadedComments = modalRaw ? this.threadFor(modalRaw) : [];
|
||||
|
||||
const switchTrackStyle = { width: '34px', height: '20px', borderRadius: '999px', position: 'relative', background: admin ? '#2ea043' : 'var(--hbd)', transition: 'background .18s ease', flex: '0 0 auto', display: 'inline-block' };
|
||||
const switchKnobStyle = { position: 'absolute', top: '2px', left: admin ? '16px' : '2px', width: '16px', height: '16px', borderRadius: '50%', background: '#fff', transition: 'left .18s ease', boxShadow: '0 1px 2px rgba(0,0,0,.3)' };
|
||||
|
||||
const tabBase = { background: 'transparent', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, padding: '8px 14px 14px', transition: 'color .14s ease' };
|
||||
const tabLatestStyle = Object.assign({}, tabBase, this.state.sortMode === 'latest' ? { color: 'var(--fg)', boxShadow: 'inset 0 -2px 0 var(--tab-underline)' } : { color: 'var(--fg2)' });
|
||||
const tabPopularStyle = Object.assign({}, tabBase, this.state.sortMode === 'popular' ? { color: 'var(--fg)', boxShadow: 'inset 0 -2px 0 var(--tab-underline)' } : { color: 'var(--fg2)' });
|
||||
const fv = this.state.feedView === 'starred';
|
||||
const feedStarBtnStyle = { display: 'inline-flex', alignItems: 'center', gap: '6px', background: fv ? 'var(--attn-bg)' : 'transparent', border: '1px solid ' + (fv ? 'var(--attn-bd)' : 'var(--btn-bd)'), borderRadius: '7px', padding: '6px 12px', marginBottom: '8px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, color: fv ? 'var(--attn-fg)' : 'var(--fg2)', whiteSpace: 'nowrap', transition: 'background .14s ease,border-color .14s ease,color .14s ease' };
|
||||
|
||||
const fBase = { display: 'inline-flex', alignItems: 'center', gap: '5px', background: 'transparent', border: 0, borderRadius: '6px', padding: '5px 11px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '12.5px', fontWeight: 600, whiteSpace: 'nowrap', transition: 'background .14s ease,color .14s ease' };
|
||||
const fOn = { background: 'var(--panel)', color: 'var(--fg)', boxShadow: 'inset 0 0 0 1px var(--border)' };
|
||||
const fOff = { color: 'var(--fg2)' };
|
||||
const mf = this.state.myFilter;
|
||||
const filterAllStyle = Object.assign({}, fBase, mf === 'all' ? fOn : fOff);
|
||||
const filterSharedStyle = Object.assign({}, fBase, mf === 'shared' ? fOn : fOff);
|
||||
const filterPrivateStyle = Object.assign({}, fBase, mf === 'private' ? fOn : fOff);
|
||||
|
||||
const modalStarBtnStyle = { display: 'inline-flex', alignItems: 'center', gap: '7px', background: modalProject.liked ? 'var(--attn-bg)' : 'var(--btn-bg)', border: '1px solid ' + (modalProject.liked ? 'var(--attn-bd)' : 'var(--btn-bd)'), borderRadius: '8px', padding: '8px 14px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, color: modalProject.liked ? 'var(--attn-fg)' : 'var(--fg)', transition: 'background .14s ease,border-color .14s ease,color .14s ease' };
|
||||
const modalHeartBtnStyle = { display: 'inline-flex', alignItems: 'center', gap: '7px', background: modalProject.hearted ? 'var(--heart-bg)' : 'var(--btn-bg)', border: '1px solid ' + (modalProject.hearted ? 'var(--heart-bd)' : 'var(--btn-bd)'), borderRadius: '8px', padding: '8px 14px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, color: modalProject.hearted ? 'var(--heart-fg)' : 'var(--fg)', transition: 'background .14s ease,border-color .14s ease,color .14s ease' };
|
||||
const visBase = { display: 'inline-flex', alignItems: 'center', gap: '5px', background: 'transparent', border: 0, borderRadius: '6px', padding: '6px 16px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap', transition: 'background .14s ease,color .14s ease' };
|
||||
const vPub = this.state.shareVisibility !== 'private';
|
||||
const visPublicStyle = Object.assign({}, visBase, vPub ? { background: 'var(--success-bg)', color: 'var(--success-fg)', boxShadow: 'inset 0 0 0 1px var(--success-bd)' } : { color: 'var(--fg2)' });
|
||||
const visPrivateStyle = Object.assign({}, visBase, !vPub ? { background: 'var(--panel)', color: 'var(--fg)', boxShadow: 'inset 0 0 0 1px var(--border)' } : { color: 'var(--fg2)' });
|
||||
const submitBtnStyle = { flex: '0 0 auto', background: 'var(--success)', border: '1px solid color-mix(in srgb, var(--fg) 12%, transparent)', borderRadius: '9px', padding: '10px 16px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 700, color: '#fff', transition: 'background .14s ease', alignSelf: 'stretch', whiteSpace: 'nowrap' };
|
||||
const shareSubmitStyle = { background: 'var(--success)', border: '1px solid color-mix(in srgb, var(--fg) 12%, transparent)', borderRadius: '8px', padding: '9px 18px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 700, color: '#fff', whiteSpace: 'nowrap' };
|
||||
const shareMiniBtnStyle = { display: 'inline-flex', alignItems: 'center', gap: '5px', background: 'var(--success)', border: '1px solid color-mix(in srgb, var(--fg) 12%, transparent)', borderRadius: '7px', padding: '4px 10px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '12px', fontWeight: 700, color: '#fff', whiteSpace: 'nowrap', transition: 'background .14s ease' };
|
||||
|
||||
return {
|
||||
currentUser: me, roleLabel: admin ? '관리자 (admin)' : '일반 사용자',
|
||||
logoRef: this.logoRef,
|
||||
logoMarkEl: this.html(this.oct(this.OC('code'), { size: 22, fill: 'var(--hfg)' })),
|
||||
footerMarkEl: this.html(this.oct(this.OC('code'), { size: 14, fill: 'var(--fg3)' })),
|
||||
onLogoClick: () => this.logoClick(),
|
||||
headerAvatarEl: this.identicon(me, 26), headerAvatarLightEl: this.identicon(me, 28),
|
||||
caretEl: this.html(this.oct(this.OC('caret'), { size: 14 })),
|
||||
searchIconEl: this.html(this.oct(this.OC('search'), { size: 15 })),
|
||||
searchQuery: this.state.searchQuery, onSearchInput: (e) => this.setState({ searchQuery: e.target.value }),
|
||||
logoutIconEl: this.html(this.oct(this.OC('signout'), { size: 15 })),
|
||||
userMenuOpen: this.state.userMenuOpen, onToggleUserMenu: () => this.toggleUserMenu(), onLogout: () => this.logout(),
|
||||
adminView: admin, onToggleAdmin: () => this.toggleAdmin(), switchTrackStyle, switchKnobStyle,
|
||||
lockAdminEl: this.html(this.oct(this.OC('lock'), { size: 11, fill: 'var(--admin-fg)' })),
|
||||
lockMiniEl: this.html(this.oct(this.OC('lock'), { size: 12 })),
|
||||
globeMiniEl: this.html(this.oct(this.OC('globe'), { size: 12 })),
|
||||
globeMiniWhiteEl: this.html(this.oct(this.OC('globe'), { size: 12, fill: '#fff' })),
|
||||
repoIconEl: this.html(this.oct(this.OC('repo'), { size: 16 })),
|
||||
codeIconEl: this.html(this.oct(this.OC('code'), { size: 14 })),
|
||||
|
||||
onToggleTheme: () => this.toggleTheme(),
|
||||
themeIconEl: this.html(this.oct(this.OC(dark ? 'sun' : 'moon'), { size: 16 })),
|
||||
themeTitle: dark ? '라이트 모드로 전환' : '다크 모드로 전환',
|
||||
|
||||
visibleTools,
|
||||
|
||||
myProjects, myTotalCount: mineAll.length, mySharedCount, myPrivateCount,
|
||||
onFilterAll: () => this.setState({ myFilter: 'all' }), onFilterShared: () => this.setState({ myFilter: 'shared' }), onFilterPrivate: () => this.setState({ myFilter: 'private' }),
|
||||
filterAllStyle, filterSharedStyle, filterPrivateStyle,
|
||||
showAddCard: this.state.myFilter === 'all',
|
||||
myEmpty: myProjects.length === 0,
|
||||
myEmptyMsg: this.state.myFilter === 'private' ? '비공개 프로젝트가 없습니다.' : (this.state.myFilter === 'shared' ? '공개한 프로젝트가 없습니다.' : '아직 등록한 프로젝트가 없습니다.'),
|
||||
onOpenShare: () => this.openShare(),
|
||||
plusIconEl: this.html(this.oct(this.OC('plus'), { size: 18, fill: 'var(--success)' })),
|
||||
plusWhiteEl: this.html(this.oct(this.OC('plus'), { size: 15, fill: '#fff' })),
|
||||
newProjectBtnStyle: { display: 'inline-flex', alignItems: 'center', gap: '6px', background: 'var(--success)', border: '1px solid color-mix(in srgb, var(--fg) 12%, transparent)', borderRadius: '8px', padding: '7px 13px', cursor: 'pointer', fontFamily: 'inherit', fontSize: '13px', fontWeight: 700, color: '#fff', whiteSpace: 'nowrap', transition: 'background .14s ease' },
|
||||
commentIconEl: this.html(this.oct(this.OC('comment'), { size: 14 })),
|
||||
shareMiniBtnStyle,
|
||||
|
||||
feedProjects, feedEmpty: feedProjects.length === 0 && (!!q || fv),
|
||||
feedEmptyMsg: fv ? '아직 별표한 프로젝트가 없어요 · 카드의 별 버튼으로 즐겨찾기하세요' : ('“' + this.state.searchQuery + '” 와 일치하는 프로젝트가 없습니다.'),
|
||||
onSortLatest: () => this.setState({ sortMode: 'latest' }), onSortPopular: () => this.setState({ sortMode: 'popular' }),
|
||||
tabLatestStyle, tabPopularStyle,
|
||||
starredCount, feedStarBtnStyle,
|
||||
feedStarIconEl: this.html(this.starSvg(fv, 14), fv ? {} : { color: 'var(--fg2)' }),
|
||||
onToggleFeedStarred: () => this.setState(s => ({ feedView: s.feedView === 'starred' ? 'all' : 'starred' })),
|
||||
extIconEl: this.html(this.oct(this.OC('ext'), { size: 12, fill: 'var(--link)' })),
|
||||
extIconWhiteEl: this.html(this.oct(this.OC('ext'), { size: 12, fill: '#fff' })),
|
||||
|
||||
modalOpen: this.state.modalId != null, modalProject, threadedComments, modalStarBtnStyle, modalHeartBtnStyle,
|
||||
onCloseModal: () => this.closeModal(), stopProp: (e) => { if (e) e.stopPropagation(); },
|
||||
closeIconEl: this.html(this.oct(this.OC('x'), { size: 16 })),
|
||||
checkIconEl: this.html(this.oct(this.OC('check'), { size: 11, fill: 'var(--success-fg)' })),
|
||||
replyIconEl: this.html(this.oct(this.OC('reply'), { size: 13 })),
|
||||
|
||||
commentDraft: this.state.commentDraft, onCommentInput: (e) => this.setState({ commentDraft: e.target.value }), onCommentKey: (e) => this.onCommentKey(e), onSubmitComment: () => this.submitComment(), submitBtnStyle,
|
||||
replyActive: this.state.replyParent != null,
|
||||
replyTargetId: (() => { if (this.state.replyParent == null || !modalRaw) return ''; const t = modalRaw.comments.find(c => c.id === this.state.replyParent); return t ? t.userId : ''; })(),
|
||||
onCancelReply: () => this.cancelReply(),
|
||||
commentPlaceholder: this.state.replyParent != null ? '답글을 입력하세요…' : '댓글을 입력하세요…',
|
||||
|
||||
shareOpen: this.state.shareOpen, onCloseShare: () => this.closeShare(), onSubmitShare: () => this.submitShare(), shareSubmitStyle,
|
||||
visPublicStyle, visPrivateStyle,
|
||||
onShareVisPublic: () => this.setState({ shareVisibility: 'public' }), onShareVisPrivate: () => this.setState({ shareVisibility: 'private' }),
|
||||
visHint: vPub ? '피드에 공개되어 동료가 볼 수 있어요' : '나만 볼 수 있어요 · 언제든 공유로 전환 가능',
|
||||
shareTitle: this.state.shareTitle, onShareTitle: (e) => this.setState({ shareTitle: e.target.value }),
|
||||
shareDesc: this.state.shareDesc, onShareDesc: (e) => this.setState({ shareDesc: e.target.value }),
|
||||
shareTags: this.state.shareTags, onShareTags: (e) => this.setState({ shareTags: e.target.value }),
|
||||
shareUrl: this.state.shareUrl, onShareUrl: (e) => this.setState({ shareUrl: e.target.value }),
|
||||
|
||||
toastOpen: !!this.state.toast, toastMsg: this.state.toast || '',
|
||||
toastIconEl: this.html(this.oct(this.OC('check'), { size: 14, fill: '#3fb950' })),
|
||||
flyers: this.state.flyers,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user