Skip to content

Commit 8285e00

Browse files
SOIVclaude
andcommitted
feat(web): Phase 2 pre 나머지 — HomeView·SettingsView 모듈 API 연동
- HomeView: MOCK_INSTALLED_MODULES 제거 → GET /core/modules/me 실시간 조회 - Admin Overview "설치된 모듈" 카드 실제 카운트 반영 - Installed Modules 그리드 실제 모듈 데이터로 렌더링 - bypass 모드에서는 fetch 스킵 - SettingsView: 유저별 모듈 활성화/비활성화 토글 UI 추가 - normal 모드 + 모듈 존재 시 "모듈 활성화" 섹션 표시 - PATCH /core/modules/:name/toggle 호출 후 즉시 UI 반영 - 토글 중 버튼 비활성화로 중복 요청 방지 - settings.css: 모듈 리스트 아이템 스타일 추가 - docs: 영상 다운로더 모듈 기획 문서 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5453463 commit 8285e00

5 files changed

Lines changed: 257 additions & 14 deletions

File tree

apps/web/src/main.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,7 @@ function App({ installMode }: { installMode: InstallMode }) {
545545
{effectiveRoute === "home" && (
546546
<HomeView
547547
isAdmin={isAdmin}
548+
installMode={installMode}
548549
isFirstVisit={isFirstVisit}
549550
onDismissFirstVisit={onDismissFirstVisit}
550551
onOpenSettings={() => setIsSettingsOpen(true)}

apps/web/src/styles/settings.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,47 @@
137137
color: var(--text-faint);
138138
}
139139

140+
.settings-module-list {
141+
list-style: none;
142+
margin: 0;
143+
padding: 0;
144+
display: grid;
145+
gap: 6px;
146+
}
147+
148+
.settings-module-item {
149+
display: flex;
150+
align-items: center;
151+
justify-content: space-between;
152+
gap: 8px;
153+
padding: 8px 10px;
154+
border: 1px solid var(--border-subtle);
155+
border-radius: 8px;
156+
background: var(--bg-elevated);
157+
}
158+
159+
.settings-module-info {
160+
display: flex;
161+
align-items: center;
162+
gap: 8px;
163+
min-width: 0;
164+
}
165+
166+
.settings-module-name {
167+
font-size: 13px;
168+
font-weight: 600;
169+
color: var(--text);
170+
overflow: hidden;
171+
text-overflow: ellipsis;
172+
white-space: nowrap;
173+
}
174+
175+
.settings-module-version {
176+
font-size: 11px;
177+
color: var(--text-faint);
178+
flex-shrink: 0;
179+
}
180+
140181
.settings-dialog-footer {
141182
padding: 14px 20px;
142183
border-top: 1px solid var(--border-subtle);

apps/web/src/views/HomeView.tsx

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1+
import { useState, useEffect } from "react";
12
import "../styles/home.css";
23

34
import { Button, EmptyState } from "@fieldstack/controls";
45

5-
import type { NavigationItem } from "../loader";
6-
7-
const MOCK_INSTALLED_MODULES: NavigationItem[] = [];
8-
96
const MODULE_ICONS: Record<string, string> = {
107
ledger: "💰",
118
subscription: "📅",
@@ -19,16 +16,41 @@ const MOCK_RECENT_ACTIVITY = [
1916
{ id: 3, text: "관리자 권한 상태 확인", time: "7분 전", dot: "warn" as const },
2017
];
2118

19+
interface InstalledModule {
20+
name: string;
21+
basePath: string;
22+
enabled: boolean;
23+
}
24+
2225
interface HomeViewProps {
2326
isAdmin: boolean;
27+
installMode: "normal" | "bypass";
2428
isFirstVisit: boolean;
2529
onDismissFirstVisit: () => void;
2630
onOpenSettings: () => void;
2731
onNavigateAdmin: () => void;
2832
}
2933

30-
export function HomeView({ isAdmin, isFirstVisit, onDismissFirstVisit, onOpenSettings, onNavigateAdmin }: HomeViewProps) {
31-
const hasModules = MOCK_INSTALLED_MODULES.length > 0;
34+
export function HomeView({ isAdmin, installMode, isFirstVisit, onDismissFirstVisit, onOpenSettings, onNavigateAdmin }: HomeViewProps) {
35+
const [installedModules, setInstalledModules] = useState<InstalledModule[]>([]);
36+
37+
// 로그인 후 GET /core/modules/me 로 활성 모듈 목록 조회
38+
useEffect(() => {
39+
if (installMode === "bypass") return;
40+
const token = sessionStorage.getItem("fs_token") ?? "";
41+
if (!token) return;
42+
43+
fetch("/core/modules/me", { headers: { Authorization: `Bearer ${token}` } })
44+
.then((r) => r.json())
45+
.then((json: { success: boolean; data?: { modules: InstalledModule[] } }) => {
46+
if (json.success) {
47+
setInstalledModules((json.data?.modules ?? []).filter((m) => m.enabled));
48+
}
49+
})
50+
.catch(() => { /* 모듈 로드 실패는 무음 처리 */ });
51+
}, [installMode]);
52+
53+
const hasModules = installedModules.length > 0;
3254

3355
return (
3456
<section className="panel home-root" aria-labelledby="home-title">
@@ -88,7 +110,7 @@ export function HomeView({ isAdmin, isFirstVisit, onDismissFirstVisit, onOpenSet
88110
</div>
89111
<div className="home-admin-card">
90112
<p className="home-admin-card-label">설치된 모듈</p>
91-
<p className="home-admin-card-value">{MOCK_INSTALLED_MODULES.length}</p>
113+
<p className="home-admin-card-value">{installedModules.length}</p>
92114
</div>
93115
<div className="home-admin-card">
94116
<p className="home-admin-card-label">시스템 상태</p>
@@ -102,7 +124,7 @@ export function HomeView({ isAdmin, isFirstVisit, onDismissFirstVisit, onOpenSet
102124
<div className="home-stat-grid">
103125
<article className="home-stat-card">
104126
<p className="home-stat-label">Installed Modules</p>
105-
<p className="home-stat-value">{MOCK_INSTALLED_MODULES.length}</p>
127+
<p className="home-stat-value">{installedModules.length}</p>
106128
</article>
107129
<article className="home-stat-card">
108130
<p className="home-stat-label">Pending Alerts</p>
@@ -159,15 +181,15 @@ export function HomeView({ isAdmin, isFirstVisit, onDismissFirstVisit, onOpenSet
159181

160182
{hasModules ? (
161183
<div className="home-modules-grid">
162-
{MOCK_INSTALLED_MODULES.map((mod) => (
184+
{installedModules.map((mod) => (
163185
<button
164-
key={mod.id}
186+
key={mod.name}
165187
className="module-card"
166188
type="button"
167-
onClick={() => { window.location.hash = mod.path; }}
189+
onClick={() => { window.location.hash = mod.name; }}
168190
>
169-
<p className="module-card-icon">{MODULE_ICONS[mod.id] ?? "🧩"}</p>
170-
<p className="module-card-name">{mod.label}</p>
191+
<p className="module-card-icon">{MODULE_ICONS[mod.name] ?? "🧩"}</p>
192+
<p className="module-card-name">{mod.name}</p>
171193
<p className="module-card-desc">Open module workspace</p>
172194
</button>
173195
))}

apps/web/src/views/SettingsView.tsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useState, useEffect, useCallback } from "react";
22

33
import { Button, FormField, Input, Modal, Select } from "@fieldstack/controls";
44

@@ -19,6 +19,13 @@ interface SettingsViewProps {
1919
onSaved: () => void;
2020
}
2121

22+
interface UserModule {
23+
name: string;
24+
basePath: string;
25+
version: string;
26+
enabled: boolean;
27+
}
28+
2229
const INIT_DISPLAY_NAME = "";
2330
const INIT_LANGUAGE = "ko";
2431

@@ -37,6 +44,44 @@ export function SettingsView({
3744
const [language, setLanguage] = useState(INIT_LANGUAGE);
3845
const [startupRoute, setStartupRoute] = useState<StartupRoute>(initialStartupRoute);
3946
const [isSaving, setIsSaving] = useState(false);
47+
const [modules, setModules] = useState<UserModule[]>([]);
48+
const [togglingModule, setTogglingModule] = useState<string | null>(null);
49+
50+
const fetchModules = useCallback(() => {
51+
if (installMode === "bypass") return;
52+
const token = sessionStorage.getItem("fs_token") ?? "";
53+
if (!token) return;
54+
fetch("/core/modules/me", { headers: { Authorization: `Bearer ${token}` } })
55+
.then((r) => r.json())
56+
.then((json: { success: boolean; data?: { modules: UserModule[] } }) => {
57+
if (json.success) setModules(json.data?.modules ?? []);
58+
})
59+
.catch(() => { /* 무음 처리 */ });
60+
}, [installMode]);
61+
62+
useEffect(() => { fetchModules(); }, [fetchModules]);
63+
64+
const handleToggleModule = async (name: string) => {
65+
const token = sessionStorage.getItem("fs_token") ?? "";
66+
if (!token || togglingModule) return;
67+
setTogglingModule(name);
68+
try {
69+
const res = await fetch(`/core/modules/${name}/toggle`, {
70+
method: "PATCH",
71+
headers: { Authorization: `Bearer ${token}` },
72+
});
73+
const json = (await res.json()) as { success: boolean; data?: { enabled: boolean } };
74+
if (json.success && json.data !== undefined) {
75+
setModules((prev) =>
76+
prev.map((m) => (m.name === name ? { ...m, enabled: json.data!.enabled } : m)),
77+
);
78+
}
79+
} catch {
80+
/* 무음 처리 */
81+
} finally {
82+
setTogglingModule(null);
83+
}
84+
};
4085

4186
// 테마는 변경 즉시 localStorage에 저장되므로 dirty 체크 제외
4287
const isDirty =
@@ -143,6 +188,31 @@ export function SettingsView({
143188
</FormField>
144189
</section>
145190

191+
{installMode !== "bypass" && modules.length > 0 && (
192+
<section className="settings-section" aria-labelledby="settings-modules">
193+
<p className="settings-section-label" id="settings-modules">모듈 활성화</p>
194+
<ul className="settings-module-list">
195+
{modules.map((mod) => (
196+
<li key={mod.name} className="settings-module-item">
197+
<div className="settings-module-info">
198+
<span className="settings-module-name">{mod.name}</span>
199+
<span className="settings-module-version">v{mod.version}</span>
200+
</div>
201+
<Button
202+
type="button"
203+
size="sm"
204+
variant={mod.enabled ? undefined : "primary"}
205+
disabled={togglingModule === mod.name}
206+
onClick={() => handleToggleModule(mod.name)}
207+
>
208+
{togglingModule === mod.name ? "..." : mod.enabled ? "비활성화" : "활성화"}
209+
</Button>
210+
</li>
211+
))}
212+
</ul>
213+
</section>
214+
)}
215+
146216
{installMode === "bypass" && (
147217
<section className="settings-section" aria-labelledby="settings-dev">
148218
<p className="settings-section-label" id="settings-dev">개발 (Phase 1.5 Mock)</p>
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# 영상 다운로더 모듈
2+
3+
## 개요
4+
5+
Streamlink / yt-dlp 기반의 영상 다운로드 관리 모듈.
6+
라이브 스트림 녹화, 예약 다운로드, 일반 다운로드를 지원하며
7+
저장 위치를 자유롭게 지정할 수 있다 (로컬 PC, NAS 마운트 경로 등).
8+
9+
> **개발 시점:** Phase 2 이후 검토
10+
> **참고 도구:** [Streamlink](https://streamlink.github.io/), [yt-dlp](https://github.com/yt-dlp/yt-dlp), [hitomi_downloader](https://github.com/KurtBestor/Hitomi-Downloader)
11+
12+
---
13+
14+
## 주요 기능 (검토 중)
15+
16+
### 1. 다운로드 유형
17+
18+
- **일반 다운로드**: URL 입력 → yt-dlp로 영상 다운로드 (YouTube, Twitch VOD 등)
19+
- **라이브 스트림 녹화**: Streamlink 기반 실시간 스트림 캡처 및 저장
20+
- **예약 다운로드**: 지정 시각에 자동 시작 (방송 시작 시간 예약 등)
21+
- **배치 다운로드**: URL 목록을 한 번에 등록, 순차 또는 병렬 처리
22+
23+
### 2. 저장 경로 설정
24+
25+
- 기본 저장 경로 지정 (모듈 설정에서 관리)
26+
- 다운로드 추가 시 개별 경로 오버라이드 가능
27+
- 경로 예시:
28+
- 로컬: `/home/user/downloads/videos`
29+
- NAS: `/mnt/nas/media/downloads` (OS 레벨 마운트 포인트 그대로 사용)
30+
- 경로 존재 여부 및 쓰기 권한 사전 검증
31+
32+
### 3. 진행 상황 모니터링
33+
34+
- SSE(Server-Sent Events)로 실시간 진행률 스트리밍
35+
- 다운로드 속도, 진행 %, 예상 남은 시간
36+
- 큐 목록에서 전체 작업 상태 확인 (대기 / 진행 중 / 완료 / 실패)
37+
- 실패 시 에러 메시지 + 재시도 버튼
38+
39+
### 4. 이력 관리
40+
41+
- 완료된 다운로드 이력 보관
42+
- 파일 경로, 크기, 소요 시간 기록
43+
- 이력 삭제 (파일 삭제는 선택 옵션)
44+
45+
---
46+
47+
## 기술 구조 (설계안)
48+
49+
### 백엔드
50+
51+
```
52+
modules/downloader/
53+
module.json
54+
backend/
55+
index.ts ← createRouter() — API 라우트 등록
56+
queue.ts ← 다운로드 큐 관리 + node-cron 예약 스케줄러
57+
runner.ts ← streamlink / yt-dlp 프로세스 스폰 + stdout 파싱
58+
storage.ts ← 저장 경로 검증 + 파일 시스템 헬퍼
59+
```
60+
61+
**런타임 의존성 (서버에 설치 필요):**
62+
- `streamlink` — 라이브 스트림 캡처
63+
- `yt-dlp` — 일반 영상 다운로드 (YouTube, Twitch VOD 등)
64+
- 설치 여부는 모듈 초기화 시 `which streamlink` / `which yt-dlp`로 감지, UI에 표시
65+
66+
### API 설계 (안)
67+
68+
| 메서드 | 경로 | 설명 |
69+
|---|---|---|
70+
| `GET` | `/api/downloader/status` | streamlink/yt-dlp 설치 여부 확인 |
71+
| `POST` | `/api/downloader/add` | 다운로드 추가 (URL, 저장 경로, 예약 시각) |
72+
| `GET` | `/api/downloader/queue` | 큐 + 이력 목록 조회 |
73+
| `GET` | `/api/downloader/:id/progress` | SSE 실시간 진행률 |
74+
| `POST` | `/api/downloader/:id/cancel` | 진행 중 작업 취소 |
75+
| `POST` | `/api/downloader/:id/retry` | 실패한 작업 재시도 |
76+
| `DELETE` | `/api/downloader/:id` | 이력 삭제 |
77+
| `GET` | `/api/downloader/settings` | 저장 경로 등 설정 조회 |
78+
| `PUT` | `/api/downloader/settings` | 설정 저장 |
79+
80+
### DB 테이블 (안)
81+
82+
```sql
83+
-- 다운로드 작업 테이블
84+
CREATE TABLE downloader_jobs (
85+
id UUID PRIMARY KEY,
86+
url TEXT NOT NULL,
87+
title TEXT, -- yt-dlp로 미리 조회한 제목
88+
type TEXT NOT NULL, -- 'vod' | 'live' | 'batch'
89+
status TEXT NOT NULL, -- 'pending' | 'running' | 'done' | 'failed' | 'cancelled'
90+
save_path TEXT NOT NULL,
91+
file_size BIGINT,
92+
error_msg TEXT,
93+
scheduled_at TEXT, -- 예약 시각 (NULL이면 즉시)
94+
started_at TEXT,
95+
finished_at TEXT,
96+
created_by TEXT NOT NULL REFERENCES users(id),
97+
created_at TEXT DEFAULT (NOW())
98+
);
99+
```
100+
101+
---
102+
103+
## 미결 사항
104+
105+
- **hitomi_downloader 통합**: Python 기반이라 별도 환경 필요. yt-dlp로 커버 안 되는 사이트 대상으로 검토.
106+
- **동시 다운로드 수 제한**: 서버 리소스 보호를 위한 최대 동시 작업 수 설정.
107+
- **알림**: 다운로드 완료/실패 시 알림 (Phase 3.x SMTP 또는 웹훅 연동).
108+
- **스트림 품질 선택**: Streamlink 스트림 품질(`best`, `720p` 등) 옵션 UI.
109+
- **ffmpeg 의존**: 일부 포맷 병합에 ffmpeg 필요 — 설치 감지 항목에 추가.

0 commit comments

Comments
 (0)