|
|
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Subject Heading Generator</title> |
| <link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" /> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| :root { |
| --bg: #f5f2eb; |
| --surface: #faf8f4; |
| --border: #d6d0c4; |
| --border-light: #e8e3d9; |
| --text: #1a1714; |
| --text-muted: #6b6560; |
| --text-faint: #9e9890; |
| --accent: #2c5f4a; |
| --accent-light: #e8f0ec; |
| --accent-mid: #3d7a61; |
| --danger: #8b2020; |
| --mono: 'DM Mono', monospace; |
| --serif: 'Lora', Georgia, serif; |
| } |
| |
| body { |
| font-family: var(--serif); |
| background: var(--bg); |
| color: var(--text); |
| min-height: 100vh; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| padding: 3rem 1.5rem 4rem; |
| } |
| |
| header { |
| text-align: center; |
| margin-bottom: 3rem; |
| } |
| |
| header::before { |
| content: ''; |
| display: block; |
| width: 40px; |
| height: 2px; |
| background: var(--accent); |
| margin: 0 auto 1.5rem; |
| } |
| |
| h1 { |
| font-size: clamp(1.6rem, 4vw, 2.2rem); |
| font-weight: 400; |
| letter-spacing: -0.02em; |
| line-height: 1.2; |
| margin-bottom: 0.5rem; |
| } |
| |
| h1 em { font-style: italic; color: var(--accent); } |
| |
| .subtitle { |
| font-size: 0.95rem; |
| color: var(--text-muted); |
| font-style: italic; |
| } |
| |
| .card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 4px; |
| padding: 2rem; |
| width: 100%; |
| max-width: 680px; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.04); |
| } |
| |
| .key-section { |
| margin-bottom: 1.5rem; |
| border: 1px dashed var(--border); |
| border-radius: 3px; |
| overflow: hidden; |
| } |
| |
| .key-toggle { |
| width: 100%; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 0.6rem 0.9rem; |
| background: #f0ede6; |
| border: none; |
| cursor: pointer; |
| font-family: var(--mono); |
| font-size: 0.75rem; |
| color: var(--text-muted); |
| text-align: left; |
| } |
| |
| .key-toggle:hover { background: #ece9e1; } |
| .key-toggle .arrow { font-size: 0.65rem; transition: transform 0.2s; color: var(--text-faint); } |
| .key-toggle.open .arrow { transform: rotate(180deg); } |
| |
| .key-body { |
| display: none; |
| padding: 0.9rem; |
| background: #f7f5f0; |
| border-top: 1px dashed var(--border); |
| } |
| |
| .key-body.open { display: block; } |
| |
| .key-hint { |
| font-family: var(--mono); |
| font-size: 0.72rem; |
| color: var(--text-faint); |
| line-height: 1.6; |
| } |
| |
| .key-hint a { color: var(--accent); } |
| |
| .key-row { |
| display: flex; |
| gap: 0.5rem; |
| align-items: center; |
| margin-top: 0.5rem; |
| } |
| |
| .key-input { |
| flex: 1; |
| height: 34px; |
| font-family: var(--mono); |
| font-size: 0.8rem; |
| padding: 0 0.75rem; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 3px; |
| color: var(--text); |
| outline: none; |
| letter-spacing: 0.05em; |
| } |
| |
| .key-input:focus { border-color: var(--accent); } |
| |
| .key-save-btn { |
| font-family: var(--mono); |
| font-size: 0.75rem; |
| height: 34px; |
| padding: 0 0.9rem; |
| background: var(--accent); |
| color: #fff; |
| border: none; |
| border-radius: 3px; |
| cursor: pointer; |
| white-space: nowrap; |
| } |
| |
| .key-save-btn:hover { background: var(--accent-mid); } |
| |
| .key-status { |
| font-family: var(--mono); |
| font-size: 0.72rem; |
| margin-top: 0.5rem; |
| min-height: 1rem; |
| } |
| |
| .key-status.ok { color: var(--accent); } |
| .key-status.err { color: var(--danger); } |
| |
| .field-label { |
| font-family: var(--mono); |
| font-size: 0.7rem; |
| letter-spacing: 0.1em; |
| text-transform: uppercase; |
| color: var(--text-faint); |
| margin-bottom: 0.5rem; |
| display: block; |
| } |
| |
| textarea { |
| width: 100%; |
| min-height: 120px; |
| resize: vertical; |
| font-family: var(--mono); |
| font-size: 0.85rem; |
| line-height: 1.6; |
| padding: 0.75rem 1rem; |
| background: var(--bg); |
| border: 1px solid var(--border); |
| border-radius: 3px; |
| color: var(--text); |
| outline: none; |
| transition: border-color 0.15s; |
| } |
| |
| textarea:focus { border-color: var(--accent); } |
| textarea::placeholder { color: var(--text-faint); font-style: italic; } |
| |
| .controls { |
| display: flex; |
| gap: 0.75rem; |
| margin-top: 1rem; |
| align-items: center; |
| flex-wrap: wrap; |
| } |
| |
| select { |
| font-family: var(--mono); |
| font-size: 0.8rem; |
| padding: 0 0.9rem; |
| height: 38px; |
| background: var(--bg); |
| border: 1px solid var(--border); |
| border-radius: 3px; |
| color: var(--text); |
| outline: none; |
| cursor: pointer; |
| appearance: none; |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b6560'/%3E%3C/svg%3E"); |
| background-repeat: no-repeat; |
| background-position: right 10px center; |
| padding-right: 28px; |
| } |
| |
| select:focus { border-color: var(--accent); } |
| |
| button#gen-btn { |
| font-family: var(--mono); |
| font-size: 0.8rem; |
| letter-spacing: 0.05em; |
| padding: 0 1.4rem; |
| height: 38px; |
| background: var(--accent); |
| color: #fff; |
| border: none; |
| border-radius: 3px; |
| cursor: pointer; |
| transition: background 0.15s, opacity 0.15s; |
| flex-shrink: 0; |
| } |
| |
| button#gen-btn:hover { background: var(--accent-mid); } |
| button#gen-btn:disabled { opacity: 0.55; cursor: default; } |
| |
| .error-msg { |
| display: none; |
| font-family: var(--mono); |
| font-size: 0.8rem; |
| color: var(--danger); |
| margin-top: 0.75rem; |
| padding: 0.6rem 0.9rem; |
| background: #fcf0f0; |
| border: 1px solid #e8c0c0; |
| border-radius: 3px; |
| } |
| |
| .divider { |
| border: none; |
| border-top: 1px solid var(--border-light); |
| margin: 1.75rem 0; |
| } |
| |
| .result-area { display: none; } |
| .result-area.visible { display: block; } |
| |
| .result-label { |
| font-family: var(--mono); |
| font-size: 0.7rem; |
| letter-spacing: 0.1em; |
| text-transform: uppercase; |
| color: var(--text-faint); |
| margin-bottom: 0.75rem; |
| } |
| |
| .headings-list { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| } |
| |
| .heading-item { |
| background: var(--bg); |
| border: 1px solid var(--border-light); |
| border-left: 3px solid var(--accent); |
| border-radius: 0 3px 3px 0; |
| padding: 0.7rem 1rem; |
| display: flex; |
| justify-content: space-between; |
| align-items: flex-start; |
| gap: 12px; |
| } |
| |
| .heading-text { |
| font-family: var(--mono); |
| font-size: 0.85rem; |
| color: var(--text); |
| line-height: 1.5; |
| flex: 1; |
| } |
| |
| .heading-type { |
| font-family: var(--mono); |
| font-size: 0.7rem; |
| color: var(--text-faint); |
| margin-top: 3px; |
| } |
| |
| .copy-small { |
| font-family: var(--mono); |
| font-size: 0.7rem; |
| height: 24px; |
| padding: 0 8px; |
| background: transparent; |
| border: 1px solid var(--border); |
| border-radius: 2px; |
| color: var(--text-muted); |
| cursor: pointer; |
| flex-shrink: 0; |
| transition: border-color 0.15s, color 0.15s; |
| } |
| |
| .copy-small:hover { border-color: var(--accent); color: var(--accent); } |
| |
| .notes { |
| margin-top: 1rem; |
| font-size: 0.85rem; |
| font-style: italic; |
| color: var(--text-muted); |
| line-height: 1.6; |
| padding-left: 0.75rem; |
| border-left: 1px solid var(--border); |
| } |
| |
| .notes::before { |
| content: 'Note: '; |
| font-family: var(--mono); |
| font-size: 0.72rem; |
| text-transform: uppercase; |
| letter-spacing: 0.07em; |
| font-style: normal; |
| color: var(--text-faint); |
| } |
| |
| .copy-all-btn { |
| font-family: var(--mono); |
| font-size: 0.72rem; |
| letter-spacing: 0.05em; |
| padding: 4px 10px; |
| background: transparent; |
| border: 1px solid var(--border); |
| border-radius: 2px; |
| color: var(--text-muted); |
| cursor: pointer; |
| margin-top: 1rem; |
| transition: border-color 0.15s, color 0.15s; |
| } |
| |
| .copy-all-btn:hover { border-color: var(--accent); color: var(--accent); } |
| |
| .spinner { |
| display: inline-block; |
| width: 12px; |
| height: 12px; |
| border: 2px solid rgba(255,255,255,0.4); |
| border-top-color: #fff; |
| border-radius: 50%; |
| animation: spin 0.6s linear infinite; |
| vertical-align: middle; |
| margin-right: 6px; |
| } |
| |
| @keyframes spin { to { transform: rotate(360deg); } } |
| |
| footer { |
| margin-top: 2.5rem; |
| text-align: center; |
| font-size: 0.8rem; |
| font-family: var(--mono); |
| color: var(--text-faint); |
| } |
| </style> |
| </head> |
| <body> |
|
|
| <header> |
| <h1>Subject Heading <em>Generator</em></h1> |
| <p class="subtitle">Paste a book title or description — get catalog-ready subject headings.</p> |
| </header> |
|
|
| <div class="card"> |
|
|
| <div class="key-section"> |
| <button class="key-toggle" id="key-toggle" onclick="toggleKey()"> |
| <span id="key-toggle-label">🔑 API key — click to set</span> |
| <span class="arrow">▼</span> |
| </button> |
| <div class="key-body" id="key-body"> |
| <p class="key-hint">Your key is stored only in your browser's localStorage — never hardcoded or shared. Get one at <a href="https://console.anthropic.com" target="_blank">console.anthropic.com</a>.</p> |
| <div class="key-row"> |
| <input class="key-input" type="password" id="key-input" placeholder="sk-ant-..." autocomplete="off" /> |
| <button class="key-save-btn" onclick="saveKey()">Save</button> |
| </div> |
| <p class="key-status" id="key-status"></p> |
| </div> |
| </div> |
|
|
| <label class="field-label" for="desc-input">Book title / description</label> |
| <textarea |
| id="desc-input" |
| placeholder="e.g. A novel about a young woman navigating grief and identity after the death of her mother in rural Appalachia..." |
| ></textarea> |
|
|
| <div class="controls"> |
| <select id="scheme-select"> |
| <option value="Library of Congress Subject Headings (LCSH)">LCSH</option> |
| <option value="Sears List of Subject Headings">Sears</option> |
| <option value="both LCSH and Sears">Both</option> |
| </select> |
| <button id="gen-btn" onclick="generate()">Generate headings</button> |
| </div> |
|
|
| <div class="error-msg" id="error-msg"></div> |
|
|
| <div class="result-area" id="result-area"> |
| <hr class="divider" /> |
| <p class="result-label" id="result-label">Suggested headings</p> |
| <div class="headings-list" id="headings-list"></div> |
| <p class="notes" id="result-notes" style="display:none;"></p> |
| <button class="copy-all-btn" onclick="copyAll()">Copy all headings</button> |
| </div> |
|
|
| </div> |
|
|
| <footer>powered by Claude · anthropic</footer> |
|
|
| <script> |
| function getKey() { return localStorage.getItem('shg_api_key') || ''; } |
| |
| function toggleKey() { |
| const body = document.getElementById('key-body'); |
| const toggle = document.getElementById('key-toggle'); |
| const isOpen = body.classList.toggle('open'); |
| toggle.classList.toggle('open', isOpen); |
| if (isOpen) { |
| const saved = getKey(); |
| if (saved) document.getElementById('key-input').value = saved; |
| } |
| } |
| |
| function saveKey() { |
| const val = document.getElementById('key-input').value.trim(); |
| const status = document.getElementById('key-status'); |
| const label = document.getElementById('key-toggle-label'); |
| if (!val.startsWith('sk-')) { |
| status.textContent = "That doesn't look like a valid key (should start with sk-)."; |
| status.className = 'key-status err'; |
| return; |
| } |
| localStorage.setItem('shg_api_key', val); |
| status.textContent = 'Key saved.'; |
| status.className = 'key-status ok'; |
| label.textContent = '\uD83D\uDD13 API key \u2014 set \u2713'; |
| setTimeout(() => { |
| document.getElementById('key-body').classList.remove('open'); |
| document.getElementById('key-toggle').classList.remove('open'); |
| }, 800); |
| } |
| |
| window.addEventListener('DOMContentLoaded', () => { |
| if (getKey()) { |
| document.getElementById('key-toggle-label').textContent = '\uD83D\uDD13 API key \u2014 set \u2713'; |
| } |
| }); |
| |
| async function generate() { |
| const apiKey = getKey(); |
| const input = document.getElementById('desc-input').value.trim(); |
| const scheme = document.getElementById('scheme-select').value; |
| const btn = document.getElementById('gen-btn'); |
| const errorEl = document.getElementById('error-msg'); |
| const resultArea = document.getElementById('result-area'); |
| |
| errorEl.style.display = 'none'; |
| resultArea.classList.remove('visible'); |
| |
| if (!apiKey) { |
| errorEl.textContent = 'Please set your Anthropic API key first (click the key section above).'; |
| errorEl.style.display = 'block'; |
| return; |
| } |
| if (!input) { |
| errorEl.textContent = 'Please enter a book title or description.'; |
| errorEl.style.display = 'block'; |
| return; |
| } |
| |
| btn.disabled = true; |
| btn.innerHTML = '<span class="spinner"></span>Generating\u2026'; |
| |
| try { |
| const response = await fetch('https://api.anthropic.com/v1/messages', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'x-api-key': apiKey, |
| 'anthropic-version': '2023-06-01', |
| 'anthropic-dangerous-direct-browser-access': 'true' |
| }, |
| body: JSON.stringify({ |
| model: 'claude-haiku-4-5-20251001', |
| max_tokens: 1000, |
| system: `You are an expert library cataloger with deep knowledge of ${scheme}. Given a book title or description, suggest appropriate subject headings. Respond ONLY with a JSON object — no markdown, no backticks, no preamble. Format: {"headings": [{"heading": "the subject heading string", "type": "main heading / subdivision / genre form / etc"}], "notes": "any brief cataloging notes or empty string"}`, |
| messages: [ |
| { role: 'user', content: `Suggest ${scheme} subject headings for this book:\n\n${input}` } |
| ] |
| }) |
| }); |
| |
| if (!response.ok) throw new Error(`API error: ${response.status}`); |
| |
| const data = await response.json(); |
| const text = data.content.map(i => i.text || '').join(''); |
| const parsed = JSON.parse(text.replace(/```json|```/g, '').trim()); |
| |
| const list = document.getElementById('headings-list'); |
| list.innerHTML = ''; |
| parsed.headings.forEach(h => { |
| const item = document.createElement('div'); |
| item.className = 'heading-item'; |
| const safeHeading = h.heading.replace(/'/g, "\\'"); |
| item.innerHTML = ` |
| <div style="flex:1;"> |
| <div class="heading-text">${h.heading}</div> |
| <div class="heading-type">${h.type}</div> |
| </div> |
| <button class="copy-small" onclick="copyOne(this, '${safeHeading}')">Copy</button> |
| `; |
| list.appendChild(item); |
| }); |
| |
| const notesEl = document.getElementById('result-notes'); |
| if (parsed.notes) { notesEl.textContent = parsed.notes; notesEl.style.display = 'block'; } |
| else { notesEl.style.display = 'none'; } |
| |
| document.getElementById('result-label').textContent = `Suggested ${scheme} headings`; |
| resultArea.classList.add('visible'); |
| |
| } catch (err) { |
| console.error(err); |
| errorEl.textContent = 'Something went wrong. Check your API key and try again.'; |
| errorEl.style.display = 'block'; |
| } |
| |
| btn.disabled = false; |
| btn.textContent = 'Generate headings'; |
| } |
| |
| function copyOne(btn, text) { |
| navigator.clipboard.writeText(text).then(() => { |
| btn.textContent = 'Copied!'; |
| setTimeout(() => btn.textContent = 'Copy', 1500); |
| }); |
| } |
| |
| function copyAll() { |
| const items = document.querySelectorAll('.heading-text'); |
| const text = Array.from(items).map(i => i.textContent).join('\n'); |
| navigator.clipboard.writeText(text).then(() => { |
| const btn = document.querySelector('.copy-all-btn'); |
| btn.textContent = 'Copied!'; |
| setTimeout(() => btn.textContent = 'Copy all headings', 1500); |
| }); |
| } |
| </script> |
| </body> |
| </html> |
|
|