Initial AI media hub implementation
Some checks failed
build-push / docker (push) Has been cancelled

This commit is contained in:
AI Assistant
2026-03-12 15:01:18 +09:00
parent b162536254
commit d7506c041a
19 changed files with 1379 additions and 0 deletions

158
frontend/app.js Normal file
View File

@@ -0,0 +1,158 @@
const statusBar = document.getElementById("statusBar");
const statusLabel = document.getElementById("statusLabel");
const searchForm = document.getElementById("searchForm");
const searchQuery = document.getElementById("searchQuery");
const searchResults = document.getElementById("searchResults");
const searchWarning = document.getElementById("searchWarning");
const dropzone = document.getElementById("dropzone");
const fileInput = document.getElementById("fileInput");
const uploadResult = document.getElementById("uploadResult");
const downloadForm = document.getElementById("downloadForm");
const downloadUrl = document.getElementById("downloadUrl");
const startTime = document.getElementById("startTime");
const endTime = document.getElementById("endTime");
const downloadResult = document.getElementById("downloadResult");
const cardTemplate = document.getElementById("searchCardTemplate");
function setStatus(label, progress) {
statusLabel.textContent = label;
statusBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
}
function connectWS() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
socket.addEventListener("message", (event) => {
const payload = JSON.parse(event.data);
if (payload.event !== "progress") {
return;
}
const data = payload.data;
setStatus(`${data.type || "task"}: ${data.status}`, Number(data.progress ?? 0));
if (data.type === "upload" && data.status === "completed") {
uploadResult.textContent = `${data.filename} saved successfully`;
}
if (data.type === "download" && data.status === "completed") {
downloadResult.textContent = data.output || "download completed";
}
if (data.status === "error") {
downloadResult.textContent = data.message || "task failed";
}
});
socket.addEventListener("close", () => {
setTimeout(connectWS, 1000);
});
}
async function api(path, options = {}) {
const response = await fetch(path, options);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error || "request failed");
error.status = response.status;
error.data = data;
throw error;
}
return data;
}
function renderResults(results) {
searchResults.innerHTML = "";
for (const item of results) {
const node = cardTemplate.content.firstElementChild.cloneNode(true);
node.href = item.link;
node.querySelector("img").src = item.thumbnailUrl;
node.querySelector("img").alt = item.title;
node.querySelector("h3").textContent = item.title;
node.querySelector("p").textContent = item.reason;
node.querySelector(".source-badge").textContent = item.source;
searchResults.appendChild(node);
}
}
searchForm.addEventListener("submit", async (event) => {
event.preventDefault();
setStatus("searching", 20);
searchWarning.classList.add("hidden");
try {
const data = await api("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: searchQuery.value }),
});
renderResults(data.results || []);
if (data.warning) {
searchWarning.textContent = data.warning;
searchWarning.classList.remove("hidden");
}
setStatus("search complete", 100);
} catch (error) {
searchWarning.textContent = error.message;
searchWarning.classList.remove("hidden");
setStatus("search failed", 100);
}
});
async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
uploadResult.textContent = "uploading...";
await api("/api/upload", { method: "POST", body: formData });
}
dropzone.addEventListener("dragover", (event) => {
event.preventDefault();
dropzone.classList.add("border-white/60", "bg-white/[0.08]");
});
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("border-white/60", "bg-white/[0.08]");
});
dropzone.addEventListener("drop", async (event) => {
event.preventDefault();
dropzone.classList.remove("border-white/60", "bg-white/[0.08]");
const file = event.dataTransfer.files[0];
if (file) {
await uploadFile(file);
}
});
fileInput.addEventListener("change", async () => {
const [file] = fileInput.files;
if (file) {
await uploadFile(file);
}
});
downloadForm.addEventListener("submit", async (event) => {
event.preventDefault();
downloadResult.textContent = "checking duplicate history...";
try {
const dup = await api(`/api/history/check?url=${encodeURIComponent(downloadUrl.value)}`);
let force = false;
if (dup.exists) {
force = window.confirm("동일 URL 다운로드 이력이 있습니다. 계속 진행할까요?");
if (!force) {
downloadResult.textContent = "cancelled";
return;
}
}
const data = await api("/api/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: downloadUrl.value,
start: startTime.value,
end: endTime.value,
force,
}),
});
downloadResult.textContent = data.message;
} catch (error) {
downloadResult.textContent = error.message;
}
});
connectWS();
setStatus("idle", 0);

91
frontend/index.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="ko" class="h-full bg-zinc-950">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Media Hub</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/style.css" />
</head>
<body class="min-h-full bg-zinc-950 text-zinc-100 selection:bg-white selection:text-black">
<main class="mx-auto flex min-h-screen max-w-7xl flex-col gap-6 px-4 py-6 lg:px-8">
<header class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs uppercase tracking-[0.4em] text-zinc-500">AI Media Asset Ingest Hub</p>
<h1 class="mt-3 text-3xl font-semibold tracking-tight text-white">Multimodal Discovery, Drag Upload, Direct Clip Ingest</h1>
</div>
<div class="w-full max-w-md">
<div class="mb-2 flex items-center justify-between text-xs uppercase tracking-[0.3em] text-zinc-500">
<span>Realtime Status</span>
<span id="statusLabel">Idle</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-white/10">
<div id="statusBar" class="h-full w-0 rounded-full bg-white transition-all duration-300"></div>
</div>
</div>
</div>
</header>
<section class="grid gap-6 lg:grid-cols-[1.2fr_0.9fr]">
<article class="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Zone A</p>
<h2 class="text-xl font-semibold text-white">AI Smart Discovery</h2>
</div>
</div>
<form id="searchForm" class="flex flex-col gap-3 md:flex-row">
<input id="searchQuery" type="text" placeholder="한글 검색어를 입력하세요" class="flex-1 rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white outline-none ring-0 placeholder:text-zinc-500" />
<button class="rounded-2xl border border-white bg-white px-5 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">AI Search</button>
</form>
<div id="searchWarning" class="mt-3 hidden rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200"></div>
<div id="searchResults" class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-3"></div>
</article>
<div class="grid gap-6">
<article class="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Zone B</p>
<h2 class="text-xl font-semibold text-white">Smart Ingest Dropzone</h2>
<label id="dropzone" class="mt-4 flex min-h-64 cursor-pointer flex-col items-center justify-center rounded-3xl border border-dashed border-white/20 bg-black/30 p-6 text-center transition hover:border-white/50 hover:bg-white/[0.05]">
<input id="fileInput" type="file" class="hidden" />
<span class="text-lg font-medium text-white">Drop file here</span>
<span class="mt-2 text-sm text-zinc-400">or click to upload into /app/downloads</span>
</label>
<p id="uploadResult" class="mt-3 text-sm text-zinc-400"></p>
</article>
<article class="rounded-3xl border border-white/10 bg-white/[0.03] p-5">
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Zone C</p>
<h2 class="text-xl font-semibold text-white">Direct Downloader & Crop</h2>
<form id="downloadForm" class="mt-4 space-y-3">
<input id="downloadUrl" type="url" placeholder="https://..." class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white placeholder:text-zinc-500" />
<div class="grid grid-cols-2 gap-3">
<input id="startTime" type="text" value="00:00:00" class="rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
<input id="endTime" type="text" value="00:00:10" class="rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white" />
</div>
<button class="w-full rounded-2xl border border-white px-5 py-3 text-sm font-medium text-white transition hover:bg-white hover:text-black">Queue Clip Download</button>
</form>
<p id="downloadResult" class="mt-3 text-sm text-zinc-400"></p>
</article>
</div>
</section>
</main>
<template id="searchCardTemplate">
<a target="_blank" rel="noreferrer" class="group overflow-hidden rounded-3xl border border-white/10 bg-black/30 transition hover:border-white/30">
<div class="relative aspect-video overflow-hidden bg-zinc-900">
<img class="h-full w-full object-cover transition duration-500 group-hover:scale-105" alt="" />
<div class="absolute left-3 top-3 rounded-full border border-white/20 bg-black/60 px-3 py-1 text-[11px] uppercase tracking-[0.25em] text-white">AI Recommended</div>
<div class="source-badge absolute bottom-3 left-3 rounded-full bg-white px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-black"></div>
</div>
<div class="space-y-2 p-4">
<h3 class="line-clamp-2 text-sm font-medium text-white"></h3>
<p class="line-clamp-3 text-sm text-zinc-400"></p>
</div>
</a>
</template>
<script src="/app.js" defer></script>
</body>
</html>

27
frontend/style.css Normal file
View File

@@ -0,0 +1,27 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap");
:root {
color-scheme: dark;
}
body {
font-family: "Space Grotesk", sans-serif;
background-image:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.1), transparent 30%),
radial-gradient(circle at bottom right, rgba(255, 255, 255, 0.08), transparent 28%);
}
.line-clamp-2,
.line-clamp-3 {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
-webkit-line-clamp: 2;
}
.line-clamp-3 {
-webkit-line-clamp: 3;
}