Initial commit for AI Media Hub
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
198
frontend/app.js
Normal file
198
frontend/app.js
Normal 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
103
frontend/index.html
Normal 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
32
frontend/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user