Initial commit for AI Media Hub
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
AI Assistant
2026-03-12 14:13:05 +09:00
parent b9940fa4d2
commit d030e737cb
17 changed files with 1051 additions and 0 deletions

198
frontend/app.js Normal file
View File

@@ -0,0 +1,198 @@
const statusText = document.getElementById('status-text');
const wsIndicator = document.getElementById('ws-indicator');
const searchBtn = document.getElementById('search-btn');
const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('discovery-results');
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('file-input');
const dlBtn = document.getElementById('dl-btn');
const dlUrl = document.getElementById('dl-url');
const dlStart = document.getElementById('dl-start');
const dlEnd = document.getElementById('dl-end');
const confirmModal = document.getElementById('confirm-modal');
const dlConfirmBtn = document.getElementById('dl-confirm-btn');
const dlCancelBtn = document.getElementById('dl-cancel-btn');
// --- WebSocket ---
let ws;
function connectWS() {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${proto}://${window.location.host}/ws`;
// For local dev without docker, port might be 3000
const finalWsUrl = window.location.port === '' ? `${proto}://${window.location.hostname}:3000/ws` : wsUrl;
ws = new WebSocket(finalWsUrl);
ws.onopen = () => {
wsIndicator.className = 'h-2 w-2 rounded-full bg-green-500';
};
ws.onmessage = (event) => {
statusText.textContent = event.data;
// flash text
statusText.classList.add('text-blue-400');
setTimeout(() => statusText.classList.remove('text-blue-400'), 500);
};
ws.onclose = () => {
wsIndicator.className = 'h-2 w-2 rounded-full bg-red-500';
setTimeout(connectWS, 2000); // Reconnect
};
}
connectWS();
// --- Zone A: Search ---
searchBtn.addEventListener('click', async () => {
const query = searchInput.value.trim();
if (!query) return;
searchBtn.disabled = true;
searchBtn.textContent = '검색 중...';
resultsContainer.innerHTML = '<div class="col-span-full h-full flex items-center justify-center text-gray-500 animate-pulse">인공지능이 이미지를 탐색하고 선별 중입니다...</div>';
try {
const port = window.location.port ? `:${window.location.port}` : ':3000';
const res = await fetch(`http://${window.location.hostname}${port}/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
if (data.error) {
resultsContainer.innerHTML = `<div class="col-span-full text-red-500 p-4">Error: ${data.error}</div>`;
} else if (data.recommended && data.recommended.length > 0) {
resultsContainer.innerHTML = '';
data.recommended.forEach((item, index) => {
const delay = index * 100;
resultsContainer.innerHTML += `
<div class="recommend-card bg-[#252525] rounded-lg overflow-hidden border border-purple-500/30 hover:border-purple-500 transition-colors cursor-pointer group relative" style="animation-delay: ${delay}ms; opacity:0;">
<span class="absolute top-2 left-2 bg-purple-600/90 text-xs px-2 py-1 rounded text-white font-medium backdrop-blur-sm z-10 shadow-lg border border-purple-400/50">✨ AI Recommended</span>
<div class="h-32 w-full bg-black overflow-hidden relative">
<img src="${item.url}" class="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300" onerror="this.src='https://via.placeholder.com/300x200?text=Image+Load+Error'" />
</div>
<div class="p-3">
<p class="text-xs text-gray-300 line-clamp-3 leading-relaxed">${item.reason}</p>
</div>
<a href="${item.url}" target="_blank" class="absolute inset-0"></a>
</div>
`;
});
} else {
resultsContainer.innerHTML = `<div class="col-span-full text-gray-500 p-4">결과를 찾을 수 없습니다.</div>`;
}
} catch (err) {
resultsContainer.innerHTML = `<div class="col-span-full text-red-500 p-4">Exception: ${err.message}</div>`;
} finally {
searchBtn.disabled = false;
searchBtn.textContent = '검색';
}
});
// --- Zone B: Upload ---
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('border-purple-500', 'bg-[#303030]');
});
dropzone.addEventListener('dragleave', () => {
dropzone.classList.remove('border-purple-500', 'bg-[#303030]');
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('border-purple-500', 'bg-[#303030]');
if (e.dataTransfer.files.length > 0) {
uploadFile(e.dataTransfer.files[0]);
}
});
dropzone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
uploadFile(e.target.files[0]);
}
});
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
statusText.textContent = `Uploading ${file.name}...`;
try {
const port = window.location.port ? `:${window.location.port}` : ':3000';
const res = await fetch(`http://${window.location.hostname}${port}/api/upload`, {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.error) {
statusText.textContent = `Upload Error: ${data.error}`;
} else {
statusText.textContent = `Upload Success: ${data.filename}`;
}
} catch (err) {
statusText.textContent = `Upload Failed: ${err.message}`;
}
}
// --- Zone C: Download ---
async function requestDownload(confirm = false) {
const url = dlUrl.value.trim();
if (!url) return;
dlBtn.disabled = true;
dlBtn.textContent = '요청 중...';
const payload = {
url: url,
start: dlStart.value.trim(),
end: dlEnd.value.trim()
};
try {
const port = window.location.port ? `:${window.location.port}` : ':3000';
const res = await fetch(`http://${window.location.hostname}${port}/api/download${confirm ? '?confirm=true' : ''}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.status === 409) {
// Duplicate!
confirmModal.classList.remove('hidden');
confirmModal.classList.add('flex');
dlBtn.disabled = false;
dlBtn.textContent = '다운로드 시작';
return;
}
const data = await res.json();
if (data.error) {
statusText.textContent = `Download Error: ${data.error}`;
} else {
// The WS will handle the progress
dlUrl.value = '';
dlStart.value = '';
dlEnd.value = '';
confirmModal.classList.add('hidden');
confirmModal.classList.remove('flex');
}
} catch (err) {
statusText.textContent = `Download Request Failed: ${err.message}`;
} finally {
dlBtn.disabled = false;
dlBtn.textContent = '다운로드 시작';
}
}
dlBtn.addEventListener('click', () => requestDownload(false));
dlConfirmBtn.addEventListener('click', () => requestDownload(true));
dlCancelBtn.addEventListener('click', () => {
confirmModal.classList.add('hidden');
confirmModal.classList.remove('flex');
});

103
frontend/index.html Normal file
View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Media Hub</title>
<!-- Tailwind CSS (CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="style.css">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-[#121212] text-white font-['Inter'] min-h-screen flex flex-col items-center">
<!-- Header -->
<header class="w-full py-6 px-8 border-b border-gray-800 flex justify-between items-center bg-[#1a1a1a]">
<h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500">
AI Media Hub <span class="text-sm text-gray-400 font-normal">Multimodal Ingest</span>
</h1>
<div id="status-bar" class="text-sm px-4 py-2 rounded-full bg-gray-800 border border-gray-700 text-gray-300 w-1/3 flex justify-between items-center transition-all">
<span id="status-text">Ready</span>
<div class="h-2 w-2 rounded-full bg-green-500" id="ws-indicator"></div>
</div>
</header>
<main class="w-full max-w-7xl px-4 py-8 grid grid-cols-1 lg:grid-cols-3 gap-8 flex-grow">
<!-- Zone A: AI Smart Discovery -->
<section class="col-span-1 lg:col-span-2 bg-[#1e1e1e] border border-gray-800 rounded-xl p-6 shadow-2xl flex flex-col">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span>🎯</span> Zone A: AI Smart Discovery
</h2>
<div class="flex gap-2 mb-6">
<input type="text" id="search-input" placeholder="한글 검색어 입력 (예: 해변 풍경)..."
class="flex-grow bg-[#2a2a2a] border border-gray-700 rounded-lg px-4 py-3 focus:outline-none focus:border-blue-500 transition-colors">
<button id="search-btn" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors">
검색
</button>
</div>
<div id="discovery-results" class="grid grid-cols-2 sm:grid-cols-3 gap-4 flex-grow overflow-y-auto min-h-[400px]">
<!-- Results will be injected here -->
<div class="col-span-full h-full flex flex-col items-center justify-center text-gray-500">
<svg class="w-12 h-12 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"></path></svg>
검색어를 입력하여 이미지를 탐색하세요
</div>
</div>
</section>
<!-- Right Column: Zone B and C -->
<div class="flex flex-col gap-8">
<!-- Zone B: Smart Ingest (Drag & Drop) -->
<section class="bg-[#1e1e1e] border border-gray-800 rounded-xl p-6 shadow-2xl flex flex-col h-[300px]">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span>📥</span> Zone B: Smart Ingest
</h2>
<div id="dropzone" class="flex-grow border-2 border-dashed border-gray-600 hover:border-purple-500 rounded-xl flex flex-col items-center justify-center bg-[#252525] transition-all cursor-pointer group">
<svg class="w-10 h-10 text-gray-400 group-hover:text-purple-400 mb-2 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
<p class="text-sm text-gray-400 group-hover:text-gray-300">이곳에 미디어 파일을 드래그 & 드랍</p>
<input type="file" id="file-input" class="hidden">
</div>
</section>
<!-- Zone C: Direct Downloader -->
<section class="bg-[#1e1e1e] border border-gray-800 rounded-xl p-6 shadow-2xl flex-grow">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span>✂️</span> Zone C: Direct Downloader
</h2>
<div class="flex flex-col gap-4">
<div>
<label class="block text-xs text-gray-400 mb-1">미디어 URL</label>
<input type="text" id="dl-url" placeholder="https://youtube.com/..." class="w-full bg-[#2a2a2a] border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-purple-500">
</div>
<div class="flex gap-4">
<div class="flex-1">
<label class="block text-xs text-gray-400 mb-1">시작시간 (선택)</label>
<input type="text" id="dl-start" placeholder="hh:mm:ss" class="w-full bg-[#2a2a2a] border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-purple-500">
</div>
<div class="flex-1">
<label class="block text-xs text-gray-400 mb-1">종료시간 (선택)</label>
<input type="text" id="dl-end" placeholder="hh:mm:ss" class="w-full bg-[#2a2a2a] border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-purple-500">
</div>
</div>
<button id="dl-btn" class="w-full mt-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-medium py-3 rounded-lg transition-colors">
다운로드 시작
</button>
<!-- Confirm Modal (Hidden by default) -->
<div id="confirm-modal" class="hidden flex-col items-center mt-4 p-4 border border-yellow-600 bg-yellow-900/20 rounded-lg">
<p class="text-sm text-yellow-300 mb-3 text-center">이미 다운로드 이력이 있는 URL입니다. 그래도 진행하시겠습니까?</p>
<div class="flex gap-2">
<button id="dl-confirm-btn" class="bg-yellow-600 hover:bg-yellow-500 text-white text-xs px-4 py-1.5 rounded">강제 진행</button>
<button id="dl-cancel-btn" class="bg-gray-600 hover:bg-gray-500 text-white text-xs px-4 py-1.5 rounded">취소</button>
</div>
</div>
</div>
</section>
</div>
</main>
<script src="app.js"></script>
</body>
</html>

32
frontend/style.css Normal file
View File

@@ -0,0 +1,32 @@
/* Custom scrollbar to keep it dark and minimal */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Glassmorphism for floating status elements if needed later */
.glass {
background: rgba(30, 30, 30, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* Animate recommend badge */
@keyframes pop {
0% { transform: scale(0.9); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.recommend-card {
animation: pop 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}