This commit is contained in:
tokuzou0829
2026-06-11 03:43:59 +09:00
commit 17be865447
22 changed files with 124248 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from "astro/config";
export default defineConfig({});
+4760
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"
}
}
+56
View File
@@ -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>
+54
View File
@@ -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>
+152
View File
@@ -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}`;
});
+191
View File
@@ -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"}`;
}
+146
View File
@@ -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>`;
});
+462
View File
@@ -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;
}
}