init
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
MAX_SEARCH_RESULTS,
|
||||
apiPath,
|
||||
fetchJson,
|
||||
matchesFilters,
|
||||
renderWorkList,
|
||||
selectedAges,
|
||||
splitFilters,
|
||||
updateDatasetStatus,
|
||||
} from "./shared.js";
|
||||
|
||||
const state = {
|
||||
naturalResults: null,
|
||||
requestId: 0,
|
||||
};
|
||||
|
||||
const elements = {
|
||||
datasetStatus: document.querySelector("#datasetStatus"),
|
||||
searchInput: document.querySelector("#searchInput"),
|
||||
naturalSearchButton: document.querySelector("#naturalSearchButton"),
|
||||
clearNaturalSearchButton: document.querySelector("#clearNaturalSearchButton"),
|
||||
searchMode: document.querySelector("#searchMode"),
|
||||
tagInput: document.querySelector("#tagInput"),
|
||||
ageFilters: document.querySelectorAll(".ageFilter"),
|
||||
workList: document.querySelector("#workList"),
|
||||
searchCount: document.querySelector("#searchCount"),
|
||||
};
|
||||
|
||||
function currentFilters() {
|
||||
return {
|
||||
ages: selectedAges(elements.ageFilters),
|
||||
tags: splitFilters(elements.tagInput.value),
|
||||
query: elements.searchInput.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function normalResults(requestId) {
|
||||
const filters = currentFilters();
|
||||
const payload = await fetchJson(apiPath("/api/works", {
|
||||
q: filters.query,
|
||||
limit: MAX_SEARCH_RESULTS,
|
||||
age: [...filters.ages],
|
||||
tag: filters.tags,
|
||||
}));
|
||||
if (requestId !== state.requestId) return null;
|
||||
return payload.results || [];
|
||||
}
|
||||
|
||||
function naturalResults() {
|
||||
if (!state.naturalResults) return null;
|
||||
const filters = currentFilters();
|
||||
return state.naturalResults
|
||||
.map((result) => ({ work: result.work, score: result.score }))
|
||||
.filter((entry) => entry.work && matchesFilters(entry.work, { ...filters, query: "" }));
|
||||
}
|
||||
|
||||
async function renderSearch() {
|
||||
const semanticEntries = naturalResults();
|
||||
if (semanticEntries) {
|
||||
renderWorkList(elements.workList, semanticEntries, { emptyText: "自然文検索に該当する作品がありません。" });
|
||||
elements.searchCount.textContent = `${semanticEntries.length}件`;
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = state.requestId + 1;
|
||||
state.requestId = requestId;
|
||||
elements.searchCount.textContent = "読み込み中";
|
||||
renderWorkList(elements.workList, [], { emptyText: "最近追加されたASMRを読み込み中..." });
|
||||
|
||||
try {
|
||||
const results = await normalResults(requestId);
|
||||
if (!results) return;
|
||||
renderWorkList(elements.workList, results, { emptyText: "該当する作品がありません。" });
|
||||
elements.searchCount.textContent = `${results.length}件`;
|
||||
elements.searchMode.textContent = elements.searchInput.value.trim()
|
||||
? "作品名・タグ・声優名で絞り込み中です。"
|
||||
: "最近追加されたASMRを表示しています。";
|
||||
} catch (error) {
|
||||
if (requestId !== state.requestId) return;
|
||||
elements.searchCount.textContent = "エラー";
|
||||
renderWorkList(elements.workList, [], { emptyText: `作品一覧を読み込めません: ${error.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
function clearNaturalSearch() {
|
||||
state.naturalResults = null;
|
||||
elements.clearNaturalSearchButton.hidden = true;
|
||||
elements.searchMode.textContent = "入力すると作品名・タグ・声優名で絞り込みます。";
|
||||
renderSearch();
|
||||
}
|
||||
|
||||
async function requestNaturalSearch() {
|
||||
const query = elements.searchInput.value.trim();
|
||||
if (!query) {
|
||||
elements.searchMode.textContent = "聴きたい雰囲気を入力してください。";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.naturalSearchButton.disabled = true;
|
||||
elements.searchMode.textContent = "自然文検索中...";
|
||||
try {
|
||||
const payload = await fetchJson("/api/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
limit: MAX_SEARCH_RESULTS,
|
||||
ages: [...selectedAges(elements.ageFilters)],
|
||||
requiredTags: splitFilters(elements.tagInput.value),
|
||||
}),
|
||||
});
|
||||
|
||||
state.naturalResults = payload.results || [];
|
||||
elements.clearNaturalSearchButton.hidden = false;
|
||||
elements.searchMode.textContent = `自然文検索: ${query}`;
|
||||
renderSearch();
|
||||
} catch (error) {
|
||||
state.naturalResults = null;
|
||||
elements.clearNaturalSearchButton.hidden = true;
|
||||
elements.searchMode.textContent = `自然文検索を使えません: ${error.message}`;
|
||||
renderSearch();
|
||||
} finally {
|
||||
elements.naturalSearchButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
elements.searchInput.addEventListener("input", () => {
|
||||
if (state.naturalResults) clearNaturalSearch();
|
||||
else renderSearch();
|
||||
});
|
||||
elements.searchInput.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
requestNaturalSearch();
|
||||
});
|
||||
elements.naturalSearchButton.addEventListener("click", requestNaturalSearch);
|
||||
elements.clearNaturalSearchButton.addEventListener("click", clearNaturalSearch);
|
||||
elements.tagInput.addEventListener("input", renderSearch);
|
||||
for (const input of elements.ageFilters) input.addEventListener("change", renderSearch);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const status = await fetchJson("/api/status");
|
||||
updateDatasetStatus(elements.datasetStatus, status);
|
||||
bindEvents();
|
||||
renderSearch();
|
||||
}
|
||||
|
||||
init().catch((error) => {
|
||||
elements.datasetStatus.textContent = `読み込み失敗: ${error.message}`;
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
export const MAX_SEARCH_RESULTS = 100;
|
||||
|
||||
export async function fetchJson(path, options = {}) {
|
||||
const response = await fetch(path, options);
|
||||
const text = await response.text();
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
// Astro dev server and static-only servers return HTML for missing APIs.
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `API の読み込みに失敗しました (${response.status})`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function apiPath(path, params = {}) {
|
||||
const url = new URL(path, location.origin);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (item !== undefined && item !== null && item !== "") url.searchParams.append(key, item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
export function normalize(value) {
|
||||
return String(value ?? "").toLowerCase().trim();
|
||||
}
|
||||
|
||||
export function selectedAges(inputs) {
|
||||
return new Set([...inputs].filter((input) => input.checked).map((input) => input.value));
|
||||
}
|
||||
|
||||
export function splitFilters(value) {
|
||||
return normalize(value)
|
||||
.split(/[\s,、]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function matchesFilters(work, { ages, tags, query }) {
|
||||
if (ages?.size && !ages.has(work.ageCategory || "unknown")) return false;
|
||||
|
||||
if (tags?.length) {
|
||||
const joinedTags = (work.tags || []).map(normalize).join(" ");
|
||||
if (!tags.every((tag) => joinedTags.includes(tag))) return false;
|
||||
}
|
||||
|
||||
if (query && !searchableText(work).includes(normalize(query))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function searchableText(work) {
|
||||
return [
|
||||
work.title,
|
||||
work.sourceId,
|
||||
work.circle,
|
||||
...(work.vas || []),
|
||||
...(work.tags || []),
|
||||
].map(normalize).join(" ");
|
||||
}
|
||||
|
||||
export function durationLabel(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "時間不明";
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}分`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours}時間${rest}分` : `${hours}時間`;
|
||||
}
|
||||
|
||||
export function ratingLabel(work) {
|
||||
if (!Number.isFinite(work.rateAverage)) return "評価なし";
|
||||
return `★${work.rateAverage.toFixed(2)} (${Number(work.rateCount || 0).toLocaleString()})`;
|
||||
}
|
||||
|
||||
export function voiceActorLabel(work, prefix = "CV") {
|
||||
const names = Array.isArray(work.vas)
|
||||
? work.vas.map((name) => String(name || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
return names.length ? `${prefix} ${names.join(" / ")}` : "";
|
||||
}
|
||||
|
||||
export function workUrl(work) {
|
||||
const id = work.sourceId || work.id || work.embeddingIndex;
|
||||
return `/work/?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
export function sourceUrl(work) {
|
||||
return `https://asmr.one/work/${work.sourceId}`;
|
||||
}
|
||||
|
||||
export function findWork(works, id) {
|
||||
const normalizedId = normalize(id);
|
||||
if (!normalizedId) return null;
|
||||
return works.find((work) => (
|
||||
normalize(work.sourceId) === normalizedId
|
||||
|| normalize(work.id) === normalizedId
|
||||
|| normalize(work.embeddingIndex) === normalizedId
|
||||
)) || null;
|
||||
}
|
||||
|
||||
export function renderWorkList(container, entries, options = {}) {
|
||||
container.replaceChildren();
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "helper-text";
|
||||
empty.textContent = options.emptyText || "該当する作品がありません。";
|
||||
container.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const entry of entries) fragment.append(renderWorkRow(entry));
|
||||
container.append(fragment);
|
||||
}
|
||||
|
||||
export function renderWorkRow(entry) {
|
||||
const work = entry.work || entry;
|
||||
const score = Number.isFinite(entry.score) ? entry.score : null;
|
||||
const row = document.createElement("a");
|
||||
row.className = "work-row";
|
||||
row.href = workUrl(work);
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.className = "work-thumb";
|
||||
image.src = work.thumbnailCoverUrl || work.mainCoverUrl || "";
|
||||
image.alt = work.title || "";
|
||||
image.loading = "lazy";
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "work-body";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = work.title || "タイトル不明";
|
||||
|
||||
const meta = document.createElement("p");
|
||||
meta.className = "work-meta";
|
||||
meta.textContent = [
|
||||
work.circle || "サークル不明",
|
||||
durationLabel(work.duration),
|
||||
ratingLabel(work),
|
||||
].join(" / ");
|
||||
|
||||
const voiceActors = voiceActorLabel(work);
|
||||
const voice = document.createElement("p");
|
||||
voice.className = "voice-line work-voice";
|
||||
voice.textContent = voiceActors;
|
||||
|
||||
const tagLine = document.createElement("div");
|
||||
tagLine.className = "tag-line";
|
||||
for (const tag of (work.tags || []).slice(0, 6)) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = tag;
|
||||
tagLine.append(span);
|
||||
}
|
||||
|
||||
body.append(title, meta);
|
||||
if (voiceActors) body.append(voice);
|
||||
if (tagLine.childElementCount) body.append(tagLine);
|
||||
|
||||
const side = document.createElement("div");
|
||||
side.className = "work-side";
|
||||
|
||||
const id = document.createElement("span");
|
||||
id.className = "source-id";
|
||||
id.textContent = work.sourceId || "";
|
||||
side.append(id);
|
||||
|
||||
if (score !== null) {
|
||||
const dna = document.createElement("span");
|
||||
dna.className = "dna-score";
|
||||
dna.textContent = `DNA ${Math.round(score * 100)}%`;
|
||||
side.append(dna);
|
||||
}
|
||||
|
||||
row.append(image, body, side);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function updateDatasetStatus(element, manifest, readyText = "検索準備完了") {
|
||||
element.textContent = `${readyText} / ${Number(manifest.count || 0).toLocaleString()}件 / ${manifest.model || "model unknown"}`;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
apiPath,
|
||||
durationLabel,
|
||||
fetchJson,
|
||||
ratingLabel,
|
||||
renderWorkList,
|
||||
sourceUrl,
|
||||
updateDatasetStatus,
|
||||
voiceActorLabel,
|
||||
} from "./shared.js";
|
||||
|
||||
const state = {
|
||||
work: null,
|
||||
};
|
||||
|
||||
const elements = {
|
||||
datasetStatus: document.querySelector("#datasetStatus"),
|
||||
workDetail: document.querySelector("#workDetail"),
|
||||
similarList: document.querySelector("#similarList"),
|
||||
similarCount: document.querySelector("#similarCount"),
|
||||
resultLimit: document.querySelector("#resultLimit"),
|
||||
excludeSameGroup: document.querySelector("#excludeSameGroup"),
|
||||
};
|
||||
|
||||
function renderDetail(work) {
|
||||
document.title = `${work.title || "作品"} - MyDNA`;
|
||||
elements.workDetail.replaceChildren();
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.className = "detail-cover";
|
||||
image.src = work.mainCoverUrl || work.thumbnailCoverUrl || "";
|
||||
image.alt = work.title || "";
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "detail-body";
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = work.title || "タイトル不明";
|
||||
|
||||
const meta = document.createElement("p");
|
||||
meta.className = "detail-meta";
|
||||
meta.textContent = [
|
||||
work.circle || "サークル不明",
|
||||
work.sourceId,
|
||||
durationLabel(work.duration),
|
||||
`DL ${Number(work.dlCount || 0).toLocaleString()}`,
|
||||
ratingLabel(work),
|
||||
].filter(Boolean).join(" / ");
|
||||
|
||||
const voiceActors = voiceActorLabel(work, "声優:");
|
||||
const voice = document.createElement("p");
|
||||
voice.className = "voice-line detail-voice";
|
||||
voice.textContent = voiceActors;
|
||||
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "tag-line detail-tags";
|
||||
for (const tag of work.tags || []) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = tag;
|
||||
tags.append(span);
|
||||
}
|
||||
|
||||
body.append(title, meta);
|
||||
if (voiceActors) body.append(voice);
|
||||
if (tags.childElementCount) body.append(tags);
|
||||
|
||||
const href = sourceUrl(work);
|
||||
if (href) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "primary-link";
|
||||
link.href = href;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.textContent = "作品ページを開く";
|
||||
body.append(link);
|
||||
}
|
||||
|
||||
elements.workDetail.append(image, body);
|
||||
}
|
||||
|
||||
async function requestSimilar() {
|
||||
if (!state.work) return;
|
||||
elements.similarCount.textContent = "計算中";
|
||||
renderWorkList(elements.similarList, [], { emptyText: "MyDNAを計算中..." });
|
||||
|
||||
try {
|
||||
const payload = await fetchJson("/api/similar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
index: state.work.embeddingIndex,
|
||||
limit: Number(elements.resultLimit.value),
|
||||
filters: {
|
||||
ages: ["adult", "r15", "general"],
|
||||
excludeSameGroup: elements.excludeSameGroup.checked,
|
||||
},
|
||||
}),
|
||||
});
|
||||
renderSimilar(payload.results || []);
|
||||
} catch (error) {
|
||||
elements.similarCount.textContent = "エラー";
|
||||
renderWorkList(elements.similarList, [], { emptyText: `MyDNAを使えません: ${error.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
function renderSimilar(results) {
|
||||
const entries = results.map((result) => ({
|
||||
work: result.work,
|
||||
score: result.score,
|
||||
})).filter((entry) => entry.work);
|
||||
|
||||
renderWorkList(elements.similarList, entries, { emptyText: "近い作品が見つかりませんでした。" });
|
||||
elements.similarCount.textContent = `${entries.length}件`;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
elements.resultLimit.addEventListener("change", requestSimilar);
|
||||
elements.excludeSameGroup.addEventListener("change", requestSimilar);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const id = new URLSearchParams(location.search).get("id");
|
||||
const [status, payload] = await Promise.all([
|
||||
fetchJson("/api/status"),
|
||||
fetchJson(apiPath("/api/work", { id })),
|
||||
]);
|
||||
updateDatasetStatus(elements.datasetStatus, status, "データ読み込み完了");
|
||||
|
||||
const work = payload.work;
|
||||
if (!work) {
|
||||
elements.workDetail.innerHTML = '<p class="helper-text">作品が見つかりませんでした。</p>';
|
||||
elements.similarCount.textContent = "0件";
|
||||
renderWorkList(elements.similarList, []);
|
||||
return;
|
||||
}
|
||||
|
||||
state.work = work;
|
||||
renderDetail(work);
|
||||
bindEvents();
|
||||
requestSimilar();
|
||||
}
|
||||
|
||||
init().catch((error) => {
|
||||
elements.datasetStatus.textContent = `読み込み失敗: ${error.message}`;
|
||||
elements.workDetail.innerHTML = `<p class="helper-text">${error.message}</p>`;
|
||||
});
|
||||
Reference in New Issue
Block a user