Compare commits

...
8 Commits
Author SHA1 Message Date
headporter 61a5955378 fix : main banner modify
SpecialSource Frontend CD / deploy (push) Successful in 7s
2026-08-12 10:56:29 +09:00
headporter 365a3088ec fix : main banner modify
SpecialSource Frontend CD / deploy (push) Successful in 6s
2026-08-11 18:03:54 +09:00
headporter 816a1d7711 fix : main banner modify
SpecialSource Frontend CD / deploy (push) Failing after 4s
2026-08-11 17:59:52 +09:00
headporter 203d36b89b fix : main banner modify
SpecialSource Frontend CD / deploy (push) Successful in 8s
2026-08-11 11:08:25 +09:00
headporter 41e624b5b4 fix : main banner modify
SpecialSource Frontend CD / deploy (push) Successful in 7s
2026-08-11 10:27:53 +09:00
headporter 47120557a0 fix : main banner modify
SpecialSource Frontend CD / deploy (push) Successful in 9s
2026-08-11 10:09:26 +09:00
headporterandClaude Sonnet 5 319b3248ec fix : add missing TuiAlert component
SpecialSource Frontend CD / deploy (push) Successful in 6s
TuiAlert.vue was referenced by Main.vue but never committed, breaking the Docker/CI build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 22:47:33 +09:00
headporter ded7e6e6f9 fix : main banner modify
SpecialSource Frontend CD / deploy (push) Failing after 6s
2026-08-10 22:44:39 +09:00
5 changed files with 650 additions and 13 deletions
+3
View File
@@ -1,5 +1,8 @@
{
"hosting": {
"predeploy": [
"npm run build"
],
"public": "dist",
"ignore": [
"firebase.json",
+20
View File
@@ -0,0 +1,20 @@
<script setup>
defineProps({
message: {
type: String,
required: true
}
});
defineEmits(['close']);
</script>
<template>
<div class="tui-alert-window">
<div class="tui-alert-header">
<span>[SYSTEM ALERT]</span>
</div>
<div class="tui-alert-body">{{ message }}</div>
<button class="tui-alert-btn" @click="$emit('close')">확인</button>
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup>
defineProps({
post: {
type: Object,
required: true
}
});
defineEmits(['close']);
</script>
<template>
<div class="tui-post-detail">
<div class="post-detail-head">
<div>
<div class="post-detail-eyebrow">{{ post.sourceLabel }} · POST_DETAIL.DAT</div>
<h3 class="post-detail-title">{{ post.title }}</h3>
</div>
<button class="post-detail-close" @click="$emit('close')" aria-label="닫기">✕</button>
</div>
<div class="post-detail-meta">
<span v-if="post.author">{{ post.author }}</span>
<span v-if="post.createdAt">{{ post.createdAt }}</span>
<span v-if="post.views !== null">조회 {{ post.views }}</span>
<span v-if="post.likes !== null">추천 {{ post.likes }}</span>
</div>
<div v-if="post.content" class="post-detail-text" v-html="post.content"></div>
<div v-if="post.url" class="post-detail-foot">
<a :href="post.url" target="_blank" rel="noopener noreferrer">원문에서 보기 ↗</a>
</div>
</div>
</template>
+188 -9
View File
@@ -22,6 +22,12 @@ body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color:
.theme-selector,
.nav-stats,
.server-badge,
.board-card,
.rail-item,
.board-login-btn,
.board-load-more,
.mode-toggle-btn,
.post-detail-close,
.terminal,
.terminal span {
font-family: 'JetBrains Mono', monospace !important;
@@ -125,10 +131,10 @@ body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color:
border-radius: 2px;
transition: 0.3s;
}
.server-badge.nas { color: var(--tui-color); text-shadow: 0 0 5px var(--tui-glow); border-color: var(--tui-border); }
.server-badge.firebase_night { color: #ffb000; text-shadow: 0 0 5px rgba(255, 176, 0, 0.6); border-color: rgba(255, 176, 0, 0.3); }
.server-badge.firebase_direct { color: #ffb000; text-shadow: 0 0 8px rgba(255, 176, 0, 0.6); border-color: rgba(255, 176, 0, 0.4); }
.server-badge.firebase_failover { color: #ff3b3b; text-shadow: 0 0 8px #ff3b3b; border-color: rgba(255, 59, 59, 0.4); animation: badgeBlink 1.5s infinite ease-in-out; }
.server-badge.nas, .dash-chip.nas { color: var(--tui-color); text-shadow: 0 0 5px var(--tui-glow); border-color: var(--tui-border); }
.server-badge.firebase_night, .dash-chip.firebase_night { color: #ffb000; text-shadow: 0 0 5px rgba(255, 176, 0, 0.6); border-color: rgba(255, 176, 0, 0.3); }
.server-badge.firebase_direct, .dash-chip.firebase_direct { color: #ffb000; text-shadow: 0 0 8px rgba(255, 176, 0, 0.6); border-color: rgba(255, 176, 0, 0.4); }
.server-badge.firebase_failover, .dash-chip.firebase_failover { color: #ff3b3b; text-shadow: 0 0 8px #ff3b3b; border-color: rgba(255, 59, 59, 0.4); animation: badgeBlink 1.5s infinite ease-in-out; }
/* --- GNB --- */
.auth-nav {
@@ -160,11 +166,12 @@ body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color:
.status-dot.secure-mode { background: var(--tui-color); box-shadow: 0 0 8px var(--tui-color); }
/* --- 컨텐츠 영역 --- */
.content-area { flex: 1; padding: 8px 20px; min-height: 220px; overflow-y: auto; display: flex; flex-direction: column; }
.content-area { flex: 1 1 auto; padding: 8px 20px; min-height: 220px; overflow-y: auto; display: flex; flex-direction: column; transition: flex-grow 0.6s ease; }
.homepage-wrapper.intro-done .content-area { flex-grow: 0; }
.sub-page { max-width: 1000px; margin: 0 auto; padding-top: 15px; width: 100%; }
.page-title { border-bottom: 1px solid var(--tui-border); padding-bottom: 10px; margin-bottom: 20px; color: var(--tui-color);}
.main-page { display: flex; justify-content: center; align-items: center; flex: 1; position: relative; }
.main-page { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 28px; flex: 1; position: relative; }
.tui-global-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
@@ -172,7 +179,11 @@ body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color:
justify-content: center; align-items: center; pointer-events: all;
}
.ascii-container { width: 100%; overflow-x: auto; overflow-y: hidden; padding: 6px 20px; box-sizing: border-box; position: relative; }
.ascii-container {
width: 100%; overflow-x: auto; overflow-y: hidden; padding: 6px 20px; box-sizing: border-box; position: relative;
max-height: 600px; opacity: 1; transition: max-height 0.6s ease, opacity 0.5s ease, padding 0.6s ease;
}
.ascii-container.is-collapsed { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; }
.ascii-clip { width: max-content; margin: 0 auto; overflow: hidden; }
.ascii-art {
font-weight: bold; white-space: pre; display: block; margin: 0 auto; width: max-content;
@@ -183,6 +194,35 @@ body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color:
.bg-block { color: #050505 !important; text-shadow: none !important; }
/* 대시보드 패널 — ASCII 글로우 인트로(2.5s)가 끝난 뒤 뒤이어 등장 */
.dash-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px;
width: 100%; max-width: 720px; padding: 0 20px; box-sizing: border-box;
opacity: 0; animation: dashReveal 0.6s ease-out 2.5s forwards;
}
.dash-panel {
background: rgba(0, 0, 0, 0.5); border: 1px solid var(--tui-border); border-radius: 3px;
padding: 12px 14px;
}
.dash-panel h4 {
margin: 0 0 10px; font-size: 10.5px; letter-spacing: 0.1em; color: var(--tui-dim); font-weight: 500;
}
.dash-stat { display: flex; justify-content: space-between; font-variant-numeric: tabular-nums; font-size: 12.5px; margin-bottom: 6px; }
.dash-stat:last-child { margin-bottom: 0; }
.dash-stat span { color: var(--tui-dim); }
.dash-stat b { color: var(--tui-color); font-weight: 600; }
.dash-chip {
font-size: 11px; font-weight: bold; padding: 3px 8px; border: 1px solid currentColor;
background: rgba(0, 0, 0, 0.5); letter-spacing: 1px; border-radius: 2px; display: inline-block;
}
.weather-panel { text-align: center; }
.weather-ascii {
font-family: inherit; white-space: pre; text-align: left; display: inline-block;
font-size: 11px; line-height: 1.15; color: var(--tui-color); text-shadow: 0 0 4px var(--tui-glow);
margin: 0 0 8px; border: none; background: transparent;
}
.weather-loading { font-size: 11px; color: var(--tui-dim); letter-spacing: 0.05em; padding: 8px 0; }
/* 📺 유튜브 그리드 레이아웃 */
#videos-container {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px;
@@ -220,12 +260,119 @@ iframe { width: 100%; height: 180px; border-radius: 4px; }
.about-text { line-height: 2; color: var(--tui-dim);}
.contact-form { line-height: 2; color: var(--tui-dim);}
/* --- 게시판 (BOARD) --- */
.board-page-head {
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;
border-bottom: 1px solid var(--tui-border); padding-bottom: 10px; margin-bottom: 20px;
}
.board-page-head .page-title { border-bottom: none; padding-bottom: 0; margin-bottom: 0; }
.board-mode-toggle { display: flex; border: 1px solid var(--tui-border); }
.mode-toggle-btn {
background: none; border: none; color: var(--tui-dim); font-size: 12px;
padding: 6px 14px; cursor: pointer;
}
.mode-toggle-btn.active { color: #000; background: var(--tui-color); font-weight: bold; }
.mode-toggle-btn:not(.active):hover { color: var(--tui-color); }
.board-layout { display: grid; grid-template-columns: 168px 1fr; gap: 18px; align-items: start; }
.board-rail { display: flex; flex-direction: column; gap: 6px; }
.rail-item {
display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 7px 10px;
border: 1px solid var(--tui-border); color: var(--tui-dim); background: rgba(0, 0, 0, 0.4);
cursor: pointer;
}
.rail-item.active { color: var(--tui-color); border-color: var(--tui-color); box-shadow: 0 0 8px var(--tui-border) inset; }
.rail-item b { font-weight: 600; }
.board-feed { display: flex; flex-direction: column; gap: 10px; }
.board-status {
color: var(--tui-dim); font-size: 13px; padding: 30px 0; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 12px;
}
.board-login-btn {
background: none; border: 1px solid var(--tui-color); color: var(--tui-color);
padding: 7px 18px; font-size: 12px; font-weight: bold; cursor: pointer; transition: 0.3s;
}
.board-login-btn:hover { background: var(--tui-color); color: #000; box-shadow: 0 0 10px var(--tui-color); }
.board-load-more {
padding: 10px; border: 1px dashed var(--tui-border); background: none; color: var(--tui-dim);
cursor: pointer; font-size: 12px;
}
.board-load-more:hover { color: var(--tui-color); border-color: var(--tui-color); }
.board-load-more:disabled { opacity: 0.5; cursor: default; }
.board-card {
display: flex; gap: 12px; padding: 10px; border: 1px solid var(--tui-border); background: #080808;
cursor: pointer; text-align: left; width: 100%; color: inherit;
}
.board-card:hover { border-color: var(--tui-color); box-shadow: 0 0 10px var(--tui-border); }
.board-thumb { width: 64px; height: 48px; flex-shrink: 0; object-fit: cover; border: 1px solid #151515; background: #050505; }
.board-thumb-empty { background: repeating-linear-gradient(45deg, #0c0c0c, #0c0c0c 4px, #050505 4px, #050505 8px); }
.board-card-body { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 5px; }
.board-tag { font-size: 9.5px; font-weight: bold; padding: 2px 6px; letter-spacing: 0.04em; border: 1px solid var(--tui-color); color: var(--tui-color); align-self: flex-start; }
.board-card-title { font-size: 13px; color: var(--tui-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.board-card:hover .board-card-title { color: var(--tui-color); }
.board-card-meta { font-size: 10.5px; color: var(--tui-dim); display: flex; gap: 10px; }
/* --- 게시글 상세 (TuiPostDetail.vue) --- */
.tui-post-detail {
background-color: #000000 !important;
color: var(--tui-color) !important;
font-family: 'JetBrains Mono', monospace !important;
padding: 26px 26px 0 !important;
border-radius: 4px !important;
border: 1px solid var(--tui-color) !important;
box-shadow: 0 0 15px var(--tui-border) !important;
width: 100%;
max-width: 820px;
max-height: 90vh;
overflow-y: auto;
box-sizing: border-box;
}
.post-detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; }
.post-detail-eyebrow { font-size: 10px; letter-spacing: 0.12em; color: var(--tui-dim); margin-bottom: 8px; }
.post-detail-title { margin: 0; font-size: 16px; line-height: 1.5; color: var(--tui-color); font-weight: 700; text-shadow: 0 0 8px var(--tui-glow); }
.post-detail-close {
background: none; border: 1px solid var(--tui-border); color: var(--tui-dim);
width: 28px; height: 28px; flex-shrink: 0; cursor: pointer; font-size: 14px;
}
.post-detail-close:hover { border-color: var(--tui-color); color: var(--tui-color); }
.post-detail-meta {
font-size: 11px; color: var(--tui-dim); display: flex; gap: 12px; flex-wrap: wrap;
margin: 12px 0 16px; padding-bottom: 14px; border-bottom: 1px dashed var(--tui-border);
}
.post-detail-text { font-size: 13px; line-height: 1.85; color: #ffffff; margin: 0 0 18px; }
.post-detail-text p { margin: 0 0 12px; }
.post-detail-text p:last-child { margin-bottom: 0; }
.post-detail-text a { color: var(--tui-color); text-decoration: underline; }
.post-detail-text b, .post-detail-text strong { color: var(--tui-color); font-weight: 700; }
.post-detail-text img { max-width: 100%; height: auto; display: block; margin: 12px 0; border: 1px solid #151515; }
.post-detail-text blockquote {
margin: 12px 0; padding: 8px 14px; border-left: 2px solid var(--tui-border); color: var(--tui-dim);
}
.post-detail-text ul, .post-detail-text ol { margin: 0 0 12px; padding-left: 22px; }
.post-detail-text li { margin-bottom: 4px; }
.post-detail-text h1, .post-detail-text h2, .post-detail-text h3,
.post-detail-text h4, .post-detail-text h5, .post-detail-text h6 {
color: var(--tui-color); margin: 16px 0 8px; font-size: 14px;
}
.post-detail-text table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; }
.post-detail-text td, .post-detail-text th { border: 1px solid var(--tui-border); padding: 6px 8px; }
.post-detail-foot {
position: sticky; bottom: 0; z-index: 1;
margin: 18px -26px 0; padding: 14px 26px 20px;
background: #000; border-top: 1px solid var(--tui-border);
display: flex; justify-content: flex-end;
}
.post-detail-foot a { font-size: 11.5px; color: var(--tui-color); text-decoration: none; border: 1px solid var(--tui-color); padding: 7px 14px; }
.post-detail-foot a:hover { background: var(--tui-color); color: #000; }
/* 애니메이션 트랜지션 */
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* 하단 CRT 터미널 */
.crt-monitor-frame { height: 300px; margin: 6px 40px 14px; border: 2px solid #1a1a1a; background: #0a0a0a; overflow: hidden; }
.crt-monitor-frame { height: 460px; margin: 6px 40px 14px; border: 2px solid #1a1a1a; background: #0a0a0a; overflow: hidden; }
.monitor-header { background: #151515; padding: 5px 15px; color: #555; display: flex; justify-content: space-between; font-size: 12px;}
.monitor-status { color: var(--tui-dim); animation: blink 2s infinite; }
.crt-screen-overlay { position: relative; height: calc(100% - 30px); background: #020802; }
@@ -236,6 +383,27 @@ iframe { width: 100%; height: 180px; border-radius: 4px; }
}
.terminal-box { width: 100%; height: 100%; padding: 10px; box-sizing: border-box; }
/* ==============================================================
부팅 로그 스크린 (초기 진입 시 로그 출력 후 사라짐)
============================================================== */
.boot-log-screen {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background-color: #000; z-index: 10000; display: flex;
justify-content: center; align-items: center; pointer-events: all;
}
.boot-log-box {
width: 100%; max-width: 620px; padding: 0 20px; box-sizing: border-box;
font-family: 'JetBrains Mono', monospace;
}
.boot-log-line {
margin: 0 0 6px; font-size: 13px; color: var(--tui-color);
text-shadow: 0 0 5px var(--tui-glow); letter-spacing: 0.5px;
animation: bootLineIn 0.2s ease-out;
}
.boot-cursor {
color: var(--tui-color); font-size: 14px; animation: blink 1s infinite;
}
/* ==============================================================
진행률 바 (TuiProgressBar.vue)
============================================================== */
@@ -369,6 +537,7 @@ iframe { width: 100%; height: 180px; border-radius: 4px; }
@media screen and (max-width: 1100px) {
#videos-container { grid-template-columns: repeat(2, 1fr); }
.project-grid { grid-template-columns: repeat(2, 1fr); }
.board-layout { grid-template-columns: 130px 1fr; }
}
@media screen and (max-width: 650px) {
@@ -376,6 +545,10 @@ iframe { width: 100%; height: 180px; border-radius: 4px; }
.content-area { padding: 10px; }
#videos-container { grid-template-columns: 1fr; padding: 10px; }
.project-grid { grid-template-columns: 1fr; }
.board-layout { grid-template-columns: 1fr; }
.board-rail { flex-direction: row; flex-wrap: wrap; }
.tui-post-detail { max-width: 92vw !important; padding: 16px 16px 0 !important; }
.post-detail-foot { margin: 18px -16px 0; padding: 12px 16px 16px; }
/* 2. 네비게이션바 줄바꿈 허용 (화면 밖으로 넘어가지 않게 방어) */
.auth-nav {
@@ -430,10 +603,14 @@ iframe { width: 100%; height: 180px; border-radius: 4px; }
font-size: 9px !important; /* 모바일 사이즈에 맞게 축소 */
line-height: 1.2 !important;
}
.dash-grid {
grid-template-columns: 1fr;
padding: 0 15px;
}
/* 5. 터미널 창 모바일 최적화 */
.crt-monitor-frame {
height: 180px !important;
height: 280px !important;
margin: 6px 10px !important;
}
.monitor-header { padding: 4px 10px; font-size: 10px; }
@@ -454,6 +631,8 @@ iframe { width: 100%; height: 180px; border-radius: 4px; }
@keyframes blink { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
@keyframes textPulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; text-shadow: 0 0 8px var(--tui-glow); } }
@keyframes badgeBlink { 0%, 100% { opacity: 0.6; box-shadow: 0 0 5px rgba(255, 59, 59, 0.2); } 50% { opacity: 1; box-shadow: 0 0 15px rgba(255, 59, 59, 0.6); text-shadow: 0 0 12px #ff3b3b; } }
@keyframes dashReveal { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes bootLineIn { from { opacity: 0; transform: translateX(-4px); } to { opacity: 1; transform: translateX(0); } }
@keyframes once-cyber-glitch-wobble {
0%, 25% { transform: translate(0) scale(1) skewX(0deg); color: var(--tui-color) !important; text-shadow: 0 0 8px var(--tui-glow) !important; opacity: 1; }
26% { transform: translate(-3px, 1.5px) scale(1.02); color: #ffffff !important; text-shadow: 2px 0 #ff0000, -2px 0 #0000ff !important; }
+404 -4
View File
@@ -1,5 +1,5 @@
<template>
<div :class="['homepage-wrapper', `theme-${activeTheme}`]">
<div :class="['homepage-wrapper', `theme-${activeTheme}`, { 'intro-done': logoDone }]">
<nav class="auth-nav">
<div class="nav-left">
<span class="status-dot" :class="{ 'secure-mode': isLoggedIn }"></span>
@@ -50,9 +50,46 @@
<transition name="fade" mode="out-in">
<div v-if="activeMenu === 'main'" class="sub-page main-page" key="main">
<div class="ascii-container">
<div class="ascii-container" :class="{ 'is-collapsed': logoDone }">
<div class="ascii-clip">
<pre class="ascii-art" v-html="formattedAscii"></pre>
<transition name="fade">
<pre
v-if="!isBooting && !logoDone"
class="ascii-art"
v-html="formattedAscii"
@animationend="logoDone = true"
></pre>
</transition>
</div>
</div>
<div v-if="!isBooting" class="dash-grid">
<div v-if="isLoggedIn" class="dash-panel">
<h4>LIVE SESSION</h4>
<div class="dash-stat"><span>LIVE</span><b class="pulse-text">{{ activeNow }}</b></div>
<div class="dash-stat"><span>TODAY</span><b>{{ todayUnique }}</b></div>
</div>
<div class="dash-panel">
<h4>SERVER SOURCE</h4>
<span :class="['dash-chip', currentServerSource]">[{{ currentServerSourceDisplay }}]</span>
</div>
<div class="dash-panel weather-panel">
<h4>SEOUL WEATHER</h4>
<div v-if="weather.loading" class="weather-loading">SYNCING...</div>
<div v-else-if="weather.error" class="weather-loading">SYNC FAILED</div>
<template v-else>
<pre class="weather-ascii">{{ weatherIcon }}</pre>
<div class="dash-stat"><span>TEMP</span><b>{{ weather.temp }}°C</b></div>
<div class="dash-stat"><span>COND</span><b>{{ weatherLabel }}</b></div>
</template>
</div>
<div class="dash-panel">
<h4>SESSION</h4>
<div class="dash-stat"><span>USER</span><b>{{ isLoggedIn ? currentUsername : 'anonymous' }}</b></div>
<div class="dash-stat"><span>DISPLAY</span><b>{{ activeThemeLabel }}</b></div>
</div>
</div>
</div>
@@ -74,6 +111,99 @@
</div>
</div>
<div v-else-if="activeMenu === 'board'" class="sub-page board-page" key="board">
<div class="board-page-head">
<h2 class="page-title">> EXECUTING COMMUNITY_FEED.EXE</h2>
<div class="board-mode-toggle">
<button
class="mode-toggle-btn"
:class="{ active: boardViewMode === 'unread' }"
@click="setBoardViewMode('unread')"
>
안 읽음
</button>
<button
class="mode-toggle-btn"
:class="{ active: boardViewMode === 'viewed' }"
@click="setBoardViewMode('viewed')"
>
읽음
</button>
</div>
</div>
<div class="board-layout">
<div class="board-rail">
<button
class="rail-item"
:class="{ active: activeBoardKey === '' }"
@click="selectBoardSource('')"
>
<span>ALL</span>
</button>
<button
v-for="src in boardSources"
:key="src.boardKey"
class="rail-item"
:class="{ active: activeBoardKey === src.boardKey }"
@click="selectBoardSource(src.boardKey)"
>
<span>{{ src.siteName }} · {{ src.boardName }}</span>
</button>
</div>
<div class="board-feed">
<div v-if="board.error === 'auth'" class="board-status">
로그인이 필요한 기능입니다.
<button class="board-login-btn" @click="changeMenu('login')">LOGIN</button>
</div>
<div v-else-if="board.loading && board.posts.length === 0" class="board-status">SYNCING BOARD DATA...</div>
<div v-else-if="board.error" class="board-status">FAILED TO LOAD BOARD DATA.</div>
<div v-else-if="board.posts.length === 0" class="board-status">
{{ boardViewMode === 'viewed' ? '아직 읽은 게시물이 없습니다.' : 'NO POSTS FOUND.' }}
</div>
<template v-else>
<button
v-for="post in board.posts"
:key="post.id"
class="board-card"
@click="openPostDetail(post)"
>
<img
v-if="post.images[0]"
:src="post.images[0]"
referrerpolicy="no-referrer"
loading="lazy"
class="board-thumb"
@error="$event.target.style.visibility = 'hidden'"
/>
<div v-else class="board-thumb board-thumb-empty"></div>
<div class="board-card-body">
<div class="board-card-top">
<span class="board-tag">{{ post.sourceLabel }}</span>
</div>
<div class="board-card-title">{{ post.title }}</div>
<div class="board-card-meta">
<span v-if="post.createdAt">{{ post.createdAt }}</span>
<span v-if="post.views !== null">조회 {{ post.views }}</span>
<span v-if="post.likes !== null">추천 {{ post.likes }}</span>
<span v-if="boardViewMode === 'viewed' && post.viewedAt">읽음 {{ post.viewedAt }}</span>
</div>
</div>
</button>
<button
v-if="board.hasMore"
class="board-load-more"
:disabled="board.loading"
@click="loadMoreBoardPosts"
>
{{ board.loading ? 'LOADING...' : '더 보기' }}
</button>
</template>
</div>
</div>
</div>
<div v-else-if="activeMenu === 'login'" class="sub-page login-page" key="login">
<div class="login-terminal-frame">
<h2 class="login-gate-title">> SECURE_AUTHENTICATION_GATEWAY.EXE</h2>
@@ -158,6 +288,21 @@
<TuiAlert :message="systemAlert.message" @close="systemAlert.show = false" />
</div>
</transition>
<transition name="fade">
<div v-if="postDetail.show" class="tui-global-overlay" @click.self="closePostDetail">
<TuiPostDetail :post="postDetail.post" @close="closePostDetail" />
</div>
</transition>
<transition name="fade">
<div v-if="isBooting" class="boot-log-screen">
<div class="boot-log-box">
<p v-for="(line, idx) in bootLines" :key="idx" class="boot-log-line">{{ line }}</p>
<span class="boot-cursor">_</span>
</div>
</div>
</transition>
</div>
</template>
@@ -166,6 +311,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
import axios from 'axios';
import TuiProgressBar from '@/components/TuiProgressBar.vue';
import TuiAlert from '@/components/TuiAlert.vue';
import TuiPostDetail from '@/components/TuiPostDetail.vue';
const videos = ref([]);
const terminalRef = ref(null);
@@ -177,9 +323,18 @@ const loginPw = ref('');
const activeMenu = ref('main');
const authToken = ref(localStorage.getItem('token'));
const currentUsername = ref(localStorage.getItem('username') || '');
const VALID_THEMES = ['green', 'amber', 'vga', 'ega', 'white', 'plasma'];
const THEME_LABELS = {
green: 'IBM 5151 (그린)',
amber: 'ZVM-1240 (호박)',
vga: 'IBM 8503 (회색)',
ega: 'IBM 5154 (청록)',
white: 'VT220 (백색)',
plasma: 'GRiD COMPASS (플라즈마)'
};
const VALID_THEMES = Object.keys(THEME_LABELS);
const storedTheme = localStorage.getItem('theme');
const activeTheme = ref(VALID_THEMES.includes(storedTheme) ? storedTheme : 'green');
const activeThemeLabel = computed(() => THEME_LABELS[activeTheme.value]);
watch(activeTheme, (theme) => {
localStorage.setItem('theme', theme);
@@ -189,6 +344,48 @@ const isProcessing = ref(false);
const overlayProgress = ref(0);
const overlayStatus = ref('');
const isBooting = ref(true);
const logoDone = ref(false);
const bootLines = ref([]);
const BOOT_SEQUENCE = [
'[SYS] SPECIALSOURCE.SYSTEM BIOS V3.2',
'[SYS] PERFORMING MEMORY CHECK... OK',
'[SYS] INITIALIZING KERNEL MODULES...',
'[SYS] MOUNTING VIRTUAL FILESYSTEM... OK',
'[SYS] ESTABLISHING SECURE NETWORK LINK...',
'[SYS] LOADING DASHBOARD INTERFACE...',
'[SYS] SPAWNING TERMINAL CONSOLE...',
'[SUCCESS] BOOT SEQUENCE COMPLETE.'
];
const BOOT_LOG_STORAGE_KEY = 'lastBootLogTimestamp';
const BOOT_LOG_INTERVAL_MS = 60 * 60 * 1000;
const runBootSequence = () => {
let i = 0;
const pushLine = () => {
if (i < BOOT_SEQUENCE.length) {
bootLines.value.push(BOOT_SEQUENCE[i]);
i++;
setTimeout(pushLine, 220);
} else {
setTimeout(() => {
isBooting.value = false;
}, 500);
}
};
pushLine();
};
const shouldShowBootLog = () => {
const lastShown = Number(localStorage.getItem(BOOT_LOG_STORAGE_KEY));
return !lastShown || Date.now() - lastShown >= BOOT_LOG_INTERVAL_MS;
};
const markBootLogShown = () => {
localStorage.setItem(BOOT_LOG_STORAGE_KEY, String(Date.now()));
};
const systemAlert = ref({ show: false, message: '' });
const showSystemAlert = (message) => {
systemAlert.value = { show: true, message };
@@ -197,12 +394,204 @@ const showSystemAlert = (message) => {
const todayUnique = ref(0);
const activeNow = ref(0);
let analyticsTimer = null;
let weatherTimer = null;
const currentServerSource = ref('nas');
const WEATHER_ICONS = {
clear: " \\ /\n .-.\n ― ( ) ―\n `-'\n / \\",
cloudy: " .--.\n .-( ).\n (___.__)__)",
overcast: " .--.\n .-( ).\n (___.__)__)",
fog: " _ - _ - _\n_ - _ - _ -\n _ - _ - _",
drizzle: " .-.\n ( ).\n (___(__)\n ' ' ' '",
rain: " .-.\n ( ).\n (___(__)\n ' ' ' ' '",
snow: " .-.\n ( ).\n (___(__)\n * * *",
thunder: " .-.\n ( ).\n (___(__)\n ⚡ ' ⚡"
};
const WEATHER_LABELS = {
clear: '맑음',
cloudy: '구름 조금',
overcast: '흐림',
fog: '안개',
drizzle: '이슬비',
rain: '비',
snow: '눈',
thunder: '뇌우'
};
const getWeatherIconKey = (code) => {
if (code === 0) return 'clear';
if (code === 1 || code === 2) return 'cloudy';
if (code === 3) return 'overcast';
if (code === 45 || code === 48) return 'fog';
if ([51, 53, 55, 56, 57].includes(code)) return 'drizzle';
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return 'rain';
if ([71, 73, 75, 77, 85, 86].includes(code)) return 'snow';
if ([95, 96, 99].includes(code)) return 'thunder';
return 'cloudy';
};
const weather = ref({ loading: true, error: false, temp: null, iconKey: 'clear' });
const weatherIcon = computed(() => WEATHER_ICONS[weather.value.iconKey] || WEATHER_ICONS.cloudy);
const weatherLabel = computed(() => WEATHER_LABELS[weather.value.iconKey] || '');
const fetchWeatherData = async () => {
try {
const res = await axios.get('https://api.open-meteo.com/v1/forecast', {
params: {
latitude: 37.5665,
longitude: 126.9780,
current_weather: true,
timezone: 'Asia/Seoul'
}
});
const current = res.data && res.data.current_weather;
if (current) {
weather.value = {
loading: false,
error: false,
temp: Math.round(current.temperature),
iconKey: getWeatherIconKey(current.weathercode)
};
} else {
weather.value.loading = false;
weather.value.error = true;
}
} catch (err) {
console.error("날씨 데이터 로드 실패:", err);
weather.value.loading = false;
weather.value.error = true;
}
};
const BOARD_PAGE_SIZE = 20;
const board = ref({ loading: false, error: null, loaded: false, posts: [], page: 0, hasMore: false });
const boardSources = ref([]);
const activeBoardKey = ref('');
const boardViewMode = ref('unread'); // 'unread' | 'viewed'
const postDetail = ref({ show: false, post: null });
const boardAuthHeaders = () => ({ Authorization: `Bearer ${authToken.value}` });
const mapScrapedPost = (row) => ({
id: row.id,
source: row.boardKey,
sourceLabel: `${row.siteName} · ${row.boardName}`,
title: row.title || '(제목 없음)',
url: row.link || null,
author: row.author || '',
createdAt: row.postedAt || '',
views: row.viewCount ?? null,
likes: row.likeCount ?? null,
content: row.content || '',
images: Array.isArray(row.imageUrls) ? row.imageUrls : [],
viewedAt: row.viewedAt || null
});
const fetchBoardMeta = async () => {
try {
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
const res = await axios.get(`${apiBaseUrl}/api/scraped-posts/boards`, {
withCredentials: true,
headers: boardAuthHeaders()
});
boardSources.value = res.data || [];
} catch (err) {
console.error("게시판 목록 로드 실패:", err);
}
};
const fetchBoardPosts = async (reset) => {
if (reset) {
board.value.page = 0;
board.value.posts = [];
}
board.value.loading = true;
board.value.error = null;
try {
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
const endpoint = boardViewMode.value === 'viewed' ? '/api/scraped-posts/viewed' : '/api/scraped-posts';
const res = await axios.get(`${apiBaseUrl}${endpoint}`, {
params: {
board: activeBoardKey.value || undefined,
page: board.value.page,
size: BOARD_PAGE_SIZE
},
withCredentials: true,
headers: boardAuthHeaders()
});
const rows = (res.data && res.data.content) || [];
const mapped = rows.map(mapScrapedPost);
board.value.posts = reset ? mapped : board.value.posts.concat(mapped);
board.value.hasMore = res.data.last === false;
board.value.loaded = true;
} catch (err) {
console.error("게시판 데이터 로드 실패:", err);
board.value.error = err?.response?.status === 401 || err?.response?.status === 403 ? 'auth' : 'network';
} finally {
board.value.loading = false;
}
};
const selectBoardSource = (boardKey) => {
if (activeBoardKey.value === boardKey) return;
activeBoardKey.value = boardKey;
fetchBoardPosts(true);
};
const setBoardViewMode = (mode) => {
if (boardViewMode.value === mode) return;
boardViewMode.value = mode;
fetchBoardPosts(true);
};
const loadMoreBoardPosts = () => {
board.value.page += 1;
fetchBoardPosts(false);
};
const openPostDetail = async (post) => {
postDetail.value = { show: true, post };
// '읽음' 목록에서는 조회 처리를 다시 할 필요도, 목록에서 뺄 필요도 없다.
if (boardViewMode.value === 'viewed') return;
try {
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
await axios.post(`${apiBaseUrl}/api/scraped-posts/${post.id}/view`, null, {
withCredentials: true,
headers: boardAuthHeaders()
});
board.value.posts = board.value.posts.filter((p) => p.id !== post.id);
} catch (err) {
console.error("게시물 조회 처리 실패:", err);
}
};
const closePostDetail = () => {
postDetail.value.show = false;
};
watch(activeMenu, (id) => {
if (id !== 'board') return;
if (!authToken.value) {
board.value.error = 'auth';
return;
}
if (!board.value.loaded && !board.value.loading) {
fetchBoardMeta();
fetchBoardPosts(true);
}
});
const menuItems = [
{ id: 'main', name: 'HOME' },
{ id: 'youtube', name: 'YOUTUBE' },
{ id: 'board', name: 'BOARD' },
// { id: 'project', name: 'PROJECT' },
{ id: 'about', name: 'ABOUT' },
{ id: 'contact', name: 'CONTACT' }
@@ -421,8 +810,16 @@ const handleLoginSubmit = async () => {
};
onMounted(() => {
if (shouldShowBootLog()) {
runBootSequence();
markBootLogShown();
} else {
isBooting.value = false;
}
fetchVideos();
fetchAnalyticsData();
fetchWeatherData();
detectRealtimeServerSource();
@@ -431,6 +828,8 @@ onMounted(() => {
detectRealtimeServerSource();
}, 30000);
weatherTimer = setInterval(fetchWeatherData, 600000);
const globaljQuery = window.$;
if (globaljQuery && terminalRef.value) {
termInstance.value = globaljQuery(terminalRef.value).terminal({
@@ -519,5 +918,6 @@ onMounted(() => {
onUnmounted(() => {
if (analyticsTimer) clearInterval(analyticsTimer);
if (weatherTimer) clearInterval(weatherTimer);
});
</script>