init
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
export default defineConfig({});
|
||||
Generated
+4760
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "asmr-vector-browser",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev --host 127.0.0.1",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview --host 127.0.0.1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "ASMR vector search UI",
|
||||
"dependencies": {
|
||||
"astro": "^6.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ASMR Vector Browser</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="site-name" href="/">ASMR Vector Browser</a>
|
||||
<span id="datasetStatus" class="status-text">データ読み込み中...</span>
|
||||
</header>
|
||||
|
||||
<main class="page-shell">
|
||||
<section class="search-area" aria-label="作品検索">
|
||||
<h1>ASMRを探す</h1>
|
||||
<div class="search-line">
|
||||
<input id="searchInput" type="search" placeholder="耳かき、ささやき、声優名、RJ番号、聴きたい雰囲気" autocomplete="off" />
|
||||
<button id="naturalSearchButton" class="primary-button" type="button">自然文検索</button>
|
||||
<button id="clearNaturalSearchButton" class="plain-button" type="button" hidden>通常検索</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-line">
|
||||
<label>
|
||||
タグ
|
||||
<input id="tagInput" type="search" placeholder="耳かき, あまあま" autocomplete="off" />
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>年齢区分</legend>
|
||||
<label><input type="checkbox" class="ageFilter" value="adult" checked /> adult</label>
|
||||
<label><input type="checkbox" class="ageFilter" value="r15" checked /> r15</label>
|
||||
<label><input type="checkbox" class="ageFilter" value="general" checked /> general</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<p id="searchMode" class="helper-text">入力すると作品名・タグ・声優名で絞り込みます。</p>
|
||||
</section>
|
||||
|
||||
<section class="results-area" aria-live="polite">
|
||||
<div class="section-heading">
|
||||
<h2>検索結果</h2>
|
||||
<span id="searchCount" class="count-text"></span>
|
||||
</div>
|
||||
<div id="workList" class="work-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import "../scripts/search.js";
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import "../../styles/global.css";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MyDNA - ASMR Vector Browser</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="site-name" href="/">ASMR Vector Browser</a>
|
||||
<span id="datasetStatus" class="status-text">データ読み込み中...</span>
|
||||
</header>
|
||||
|
||||
<main class="page-shell detail-shell">
|
||||
<a class="back-link" href="/">検索へ戻る</a>
|
||||
|
||||
<section id="workDetail" class="work-detail" aria-live="polite">
|
||||
<p class="helper-text">作品を読み込み中...</p>
|
||||
</section>
|
||||
|
||||
<section class="results-area dna-area" aria-live="polite">
|
||||
<div class="section-heading">
|
||||
<h2>MyDNA</h2>
|
||||
<span id="similarCount" class="count-text">計算中</span>
|
||||
</div>
|
||||
|
||||
<div class="dna-controls">
|
||||
<label>
|
||||
表示件数
|
||||
<select id="resultLimit">
|
||||
<option value="24">24</option>
|
||||
<option value="48" selected>48</option>
|
||||
<option value="96">96</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inline-check">
|
||||
<input id="excludeSameGroup" type="checkbox" checked />
|
||||
同一翻訳グループを除外
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="similarList" class="work-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import "../../scripts/work.js";
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>`;
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #111114;
|
||||
--surface: #18191d;
|
||||
--surface-2: #202127;
|
||||
--text: #f4f0ea;
|
||||
--muted: #a9a6a0;
|
||||
--quiet: #777a81;
|
||||
--line: rgba(244, 240, 234, 0.14);
|
||||
--accent: #f28db4;
|
||||
--accent-2: #a7d8ff;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at 12% -12%, rgba(242, 141, 180, 0.16), transparent 26rem),
|
||||
linear-gradient(180deg, #15151a 0%, var(--bg) 28rem);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 1rem clamp(1rem, 4vw, 2rem);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.site-name {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-name:hover,
|
||||
.back-link:hover,
|
||||
.primary-link:hover {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.status-text,
|
||||
.count-text,
|
||||
.helper-text {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 1rem clamp(1rem, 4vw, 2rem) 3rem;
|
||||
}
|
||||
|
||||
.search-area {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-top: clamp(0.5rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
.search-area h1,
|
||||
.work-detail h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: clamp(2rem, 6vw, 4.2rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
|
||||
.search-line {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-line input {
|
||||
min-height: 3.7rem;
|
||||
border-radius: 999px;
|
||||
padding-inline: 1.2rem;
|
||||
font-size: clamp(1rem, 2vw, 1.25rem);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.7rem;
|
||||
padding: 0.72rem 0.8rem;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: rgba(167, 216, 255, 0.7);
|
||||
box-shadow: 0 0 0 3px rgba(167, 216, 255, 0.11);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(244, 240, 234, 0.38);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.plain-button,
|
||||
.primary-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 3rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.72rem 1rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border-color: rgba(242, 141, 180, 0.54);
|
||||
color: #181013;
|
||||
background: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.plain-button {
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.primary-link {
|
||||
width: fit-content;
|
||||
margin-top: 0.5rem;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.filter-line,
|
||||
.dna-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-line label,
|
||||
.dna-controls label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: min(18rem, 100%);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem 0.9rem;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
width: 100%;
|
||||
margin-bottom: 0.1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
fieldset label,
|
||||
.inline-check {
|
||||
display: inline-flex !important;
|
||||
min-width: auto !important;
|
||||
flex-direction: row;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
color: var(--text) !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: auto;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.helper-text {
|
||||
margin-bottom: 0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin-bottom: 0;
|
||||
font-size: clamp(1.25rem, 2.4vw, 1.75rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.work-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.work-row {
|
||||
display: grid;
|
||||
grid-template-columns: 7.25rem minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 1rem 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.work-row:hover {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.work-thumb {
|
||||
width: 7.25rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 0.35rem;
|
||||
object-fit: cover;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.work-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.work-body h3 {
|
||||
display: -webkit-box;
|
||||
margin-bottom: 0.35rem;
|
||||
overflow: hidden;
|
||||
font-size: clamp(0.98rem, 1.8vw, 1.08rem);
|
||||
line-height: 1.38;
|
||||
letter-spacing: -0.02em;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.work-meta,
|
||||
.detail-meta {
|
||||
margin-bottom: 0.45rem;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voice-line {
|
||||
margin-bottom: 0.45rem;
|
||||
overflow: hidden;
|
||||
color: var(--accent-2);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tag-line span {
|
||||
max-width: 11rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.14rem 0.45rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-side {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
justify-items: end;
|
||||
color: var(--quiet);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.source-id {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dna-score {
|
||||
color: var(--accent-2);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.detail-shell {
|
||||
display: grid;
|
||||
gap: 1.35rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
width: fit-content;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.work-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(13rem, 20rem) minmax(0, 1fr);
|
||||
gap: clamp(1rem, 3vw, 2rem);
|
||||
align-items: start;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
width: 100%;
|
||||
border-radius: 0.45rem;
|
||||
object-fit: cover;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.detail-voice {
|
||||
font-size: 0.95rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.detail-tags span {
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.dna-area {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dna-controls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dna-controls label:first-child {
|
||||
max-width: 10rem;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.site-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-line {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-line,
|
||||
.dna-controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.work-row {
|
||||
grid-template-columns: 6rem minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.work-thumb {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.work-side {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.work-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
max-width: 22rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.page-shell {
|
||||
padding-inline: 0.9rem;
|
||||
}
|
||||
|
||||
.work-row {
|
||||
grid-template-columns: 5.25rem minmax(0, 1fr);
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.work-thumb {
|
||||
width: 5.25rem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user