Divan v2: Astro site with Divan's own design (proof of concept)
- web/: server-rendered Astro over the Node API: home (poets by Hijri century), poet (intro + sections), category (numbered contents), poem (couplets, prev/next, source), search (paged); Naskh default with Nastaliq toggle, light/dark, Urdu digits, RTL, mobile - api: section counts include nested categories - README: how to run v2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c450ec010a
commit
60f0791405
17
README.md
17
README.md
@ -17,6 +17,23 @@ Divan is a standalone project built on a fork of [GanjoorService](https://github
|
||||
- **Locale:** `ur-PK`.
|
||||
- **Search without full-text:** the SQL Server Linux image has no full-text search, so poem, similar-poem and comment search use `LIKE` patterns (`LanguageUtils.SearchLikePatterns`). The normaliser handles Urdu letter variants (Arabic ي/ك/ه → ی/ک/ہ, ۂ/ۓ), the Urdu full stop and Urdu diacritics. After changing normalisation rules, rebuild stored search text with `POST /api/divan/regenplaintext/0` (admin).
|
||||
|
||||
## Divan v2 (Node.js + PostgreSQL + Astro), in progress
|
||||
|
||||
The next version replaces the .NET/SQL Server stack with a lighter one. It lives next to the current code until it reaches parity.
|
||||
|
||||
- `db/schema.sql`: PostgreSQL schema (poets, categories, poems, verses; trigram index for Urdu substring search)
|
||||
- `api/`: Node.js API (Fastify + pg, TypeScript run natively by Node 24+, no build step)
|
||||
- `web/`: the new site (Astro, server-rendered, Divan's own design; Naskh default, Nastaliq option, light/dark)
|
||||
|
||||
```sh
|
||||
docker run -d --name divan-pg -e POSTGRES_USER=divan -e POSTGRES_PASSWORD=divan_local -e POSTGRES_DB=divan \
|
||||
-p 5433:5432 -v divan-pg:/var/lib/postgresql/data postgres:17-alpine
|
||||
cd api && npm install && npm run import -- ../../divan-data # or the CDN URL; ~40 s for 11k poems
|
||||
npm start # API on :4100 (DATABASE_URL to override)
|
||||
cd ../web && npm install && npm run build && npm start # site on :4200 (API_URL to override)
|
||||
npm test --prefix ../api # Urdu normaliser tests
|
||||
```
|
||||
|
||||
## Static reader (no server)
|
||||
|
||||
`reader/index.html` is a single-file reader: poets, intros, books and poems, with Nastaliq/Naskh switching. It reads the [divan-data](https://github.com/anas-rashid/divan-data) static API directly in the browser, so it needs no API, database or build step.
|
||||
|
||||
@ -59,9 +59,15 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
|
||||
const [poet, crumbs, children, poems] = await Promise.all([
|
||||
pool.query('SELECT * FROM poets WHERE id = $1', [cat.poet_id]),
|
||||
ancestors(cat.id),
|
||||
// child categories with the number of poems in them and all their sub-categories
|
||||
pool.query(
|
||||
`SELECT c.url, c.title, (SELECT count(*) FROM poems p WHERE p.category_id = c.id)::int AS poems
|
||||
FROM categories c WHERE c.parent_id = $1 ORDER BY c.position, c.id`,
|
||||
`WITH RECURSIVE tree AS (
|
||||
SELECT id AS root, id FROM categories WHERE parent_id = $1
|
||||
UNION ALL
|
||||
SELECT tree.root, c.id FROM categories c JOIN tree ON c.parent_id = tree.id)
|
||||
SELECT c.url, c.title, count(p.id)::int AS poems
|
||||
FROM categories c JOIN tree ON tree.root = c.id LEFT JOIN poems p ON p.category_id = tree.id
|
||||
GROUP BY c.id ORDER BY c.position, c.id`,
|
||||
[cat.id],
|
||||
),
|
||||
pool.query('SELECT url, title FROM poems WHERE category_id = $1 ORDER BY position, id', [cat.id]),
|
||||
|
||||
3
web/.gitignore
vendored
Normal file
3
web/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
10
web/astro.config.mjs
Normal file
10
web/astro.config.mjs
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
|
||||
// Server-rendered pages (content comes from the Divan API at request time)
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
server: { port: 4200 },
|
||||
i18n: undefined,
|
||||
});
|
||||
3970
web/package-lock.json
generated
Normal file
3970
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
web/package.json
Normal file
15
web/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "divan-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Divan website (Astro, server-rendered) over the Divan API",
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 4200",
|
||||
"build": "astro build",
|
||||
"start": "node dist/server/entry.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.7",
|
||||
"astro": "^7.3.8"
|
||||
}
|
||||
}
|
||||
1
web/public/favicon.svg
Normal file
1
web/public/favicon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#8b2e1f"/><text x="32" y="44" font-size="34" text-anchor="middle" fill="#f8f4ea" font-family="serif">د</text></svg>
|
||||
|
After Width: | Height: | Size: 220 B |
64
web/src/layouts/Base.astro
Normal file
64
web/src/layouts/Base.astro
Normal file
@ -0,0 +1,64 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
interface Props { title?: string; description?: string; q?: string }
|
||||
const { title, description = 'اردو کی کلاسیکی شاعری اور نثر', q = '' } = Astro.props;
|
||||
const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو کی کلاسیکی شاعری اور نثر';
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ur-PK" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Naskh+Arabic:wght@400..700&family=Noto+Nastaliq+Urdu:wght@400..700&display=swap" rel="stylesheet" />
|
||||
<script is:inline>
|
||||
// font + theme choice applied before paint (Naskh and system theme by default)
|
||||
try {
|
||||
if (localStorage.getItem('divan-font') === 'nastaliq') document.documentElement.classList.add('nastaliq');
|
||||
const t = localStorage.getItem('divan-theme');
|
||||
if (t) document.documentElement.dataset.theme = t;
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="wrap">
|
||||
<a class="wordmark" href="/">دیوان</a>
|
||||
<form class="search-form" action="/search" method="get" role="search">
|
||||
<input type="search" name="q" value={q} placeholder="شعر، لفظ یا عبارت تلاش کریں" aria-label="تلاش" />
|
||||
</form>
|
||||
<div class="toggles">
|
||||
<button type="button" id="font-toggle" aria-label="خط بدلیں">نسخ</button>
|
||||
<button type="button" id="theme-toggle" aria-label="روشن یا تاریک">◐</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="wrap"><slot /></main>
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
|
||||
<a href="https://github.com/anas-rashid/divan-data">ڈیٹا</a> · <a href="https://github.com/anas-rashid/divan">کوڈ</a>
|
||||
</div>
|
||||
</footer>
|
||||
<script is:inline>
|
||||
const root = document.documentElement;
|
||||
const fontBtn = document.getElementById('font-toggle');
|
||||
const label = () => (fontBtn.textContent = root.classList.contains('nastaliq') ? 'نستعلیق' : 'نسخ');
|
||||
label();
|
||||
fontBtn.onclick = () => {
|
||||
root.classList.toggle('nastaliq');
|
||||
try { localStorage.setItem('divan-font', root.classList.contains('nastaliq') ? 'nastaliq' : 'naskh'); } catch (e) {}
|
||||
label();
|
||||
};
|
||||
document.getElementById('theme-toggle').onclick = () => {
|
||||
const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
root.dataset.theme = dark ? 'light' : 'dark';
|
||||
try { localStorage.setItem('divan-theme', root.dataset.theme); } catch (e) {}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
9
web/src/lib/api.ts
Normal file
9
web/src/lib/api.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// Divan API client (server-side; API_URL defaults to the local API)
|
||||
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
|
||||
|
||||
export async function api<T = any>(path: string): Promise<T | null> {
|
||||
const res = await fetch(API + path);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`API ${res.status} for ${path}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
12
web/src/lib/urdu.ts
Normal file
12
web/src/lib/urdu.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// Urdu display helpers
|
||||
const DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
export const ud = (n: number | string | null | undefined) =>
|
||||
n == null ? '' : String(n).replace(/[0-9]/g, (d) => DIGITS[+d]);
|
||||
|
||||
const ORDINALS = ['', 'پہلی', 'دوسری', 'تیسری', 'چوتھی', 'پانچویں', 'چھٹی', 'ساتویں', 'آٹھویں', 'نویں', 'دسویں',
|
||||
'گیارہویں', 'بارہویں', 'تیرہویں', 'چودہویں', 'پندرہویں'];
|
||||
export const centuryOf = (yearAh: number | null) => (yearAh ? Math.floor(yearAh / 100) + 1 : null);
|
||||
export const centuryName = (c: number | null) => (c ? `${ORDINALS[c] ?? ud(c)} صدی ہجری` : 'دیگر شعرا');
|
||||
|
||||
export const years = (birth: number | null, death: number | null) =>
|
||||
birth || death ? `${birth ? ud(birth) : '؟'} – ${death ? ud(death) : '؟'} ھ` : '';
|
||||
73
web/src/pages/[...path].astro
Normal file
73
web/src/pages/[...path].astro
Normal file
@ -0,0 +1,73 @@
|
||||
---
|
||||
// Poet (/p238), category (/p238/shaeri/c641) and poem (/p238/shaeri/c641/sh6556) pages
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { api } from '../lib/api';
|
||||
import { ud, years } from '../lib/urdu';
|
||||
|
||||
const url = '/' + (Astro.params.path ?? '');
|
||||
const page = await api(`/api/page?url=${encodeURIComponent(url)}`);
|
||||
if (!page) return new Response(null, { status: 404, statusText: 'Not found' });
|
||||
|
||||
// group verses into couplets / single lines / paragraphs for rendering
|
||||
type Verse = { position: string; couplet: number; text: string };
|
||||
const blocks: { kind: 'couplet' | 'single' | 'para'; lines: string[] }[] = [];
|
||||
if (page.type === 'poem') {
|
||||
for (const v of page.verses as Verse[]) {
|
||||
if (v.position === 'Right') blocks.push({ kind: 'couplet', lines: [v.text] });
|
||||
else if (v.position === 'Left' && blocks.at(-1)?.kind === 'couplet' && blocks.at(-1)!.lines.length === 1) blocks.at(-1)!.lines.push(v.text);
|
||||
else if (v.position === 'Paragraph') blocks.push({ kind: 'para', lines: [v.text] });
|
||||
else blocks.push({ kind: 'single', lines: [v.text] });
|
||||
}
|
||||
}
|
||||
const crumbs = page.breadcrumbs ?? [];
|
||||
const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
|
||||
---
|
||||
<Base title={page.type === 'poem' ? `${page.poem.title} · ${page.poet.nickname}` : title} description={page.type === 'poet' ? page.poet.description?.slice(0, 160) : undefined}>
|
||||
{page.type !== 'poet' && (
|
||||
<nav class="crumbs">
|
||||
{crumbs.map((c: any, i: number) => (
|
||||
i < crumbs.length - 1 || page.type === 'poem' ? <span><a href={c.url}>{c.title}</a></span> : <span>{c.title}</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{page.type === 'poet' && (
|
||||
<>
|
||||
<h1>{page.poet.name}</h1>
|
||||
<p class="muted">{years(page.poet.birth_year_ah, page.poet.death_year_ah)}</p>
|
||||
{page.poet.description && <div class="intro">{page.poet.description}</div>}
|
||||
</>
|
||||
)}
|
||||
{page.type === 'category' && <h1>{page.category.title}</h1>}
|
||||
|
||||
{page.type !== 'poem' && page.children.length > 0 && (
|
||||
<div class="cards">
|
||||
{page.children.map((c: any) => (
|
||||
<a class="card" href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{page.type !== 'poem' && page.poems.length > 0 && (
|
||||
<ol class="toc">{page.poems.map((p: any) => <li><a href={p.url}>{p.title}</a></li>)}</ol>
|
||||
)}
|
||||
|
||||
{page.type === 'poem' && (
|
||||
<>
|
||||
<h1>{page.poem.title}</h1>
|
||||
<article class="poem">
|
||||
{blocks.map((b) =>
|
||||
b.kind === 'couplet' ? <div class="couplet">{b.lines.map((l) => <p>{l}</p>)}</div>
|
||||
: b.kind === 'para' ? <p class="para">{b.lines[0]}</p>
|
||||
: <p class="single">{b.lines[0]}</p>
|
||||
)}
|
||||
</article>
|
||||
<nav class="poem-nav">
|
||||
{page.next ? <a href={page.next}>اگلا ›</a> : <span />}
|
||||
{page.prev ? <a href={page.prev}>‹ پچھلا</a> : <span />}
|
||||
</nav>
|
||||
{page.poem.source_url && (
|
||||
<p class="source">ماخذ: <a href={page.poem.source_url}>ویکی ماخذ</a> · {page.poet && <a href={page.poet.url}>{page.poet.nickname}</a>}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Base>
|
||||
39
web/src/pages/index.astro
Normal file
39
web/src/pages/index.astro
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { api } from '../lib/api';
|
||||
import { centuryOf, centuryName, years, ud } from '../lib/urdu';
|
||||
|
||||
type Poet = { id: number; url: string; nickname: string; birth_year_ah: number | null; death_year_ah: number | null };
|
||||
const poets = (await api<Poet[]>('/api/poets')) ?? [];
|
||||
|
||||
// group by Hijri century of birth (poets without a known year last)
|
||||
const groups = new Map<number | null, Poet[]>();
|
||||
for (const p of poets) {
|
||||
const c = centuryOf(p.birth_year_ah);
|
||||
if (!groups.has(c)) groups.set(c, []);
|
||||
groups.get(c)!.push(p);
|
||||
}
|
||||
const ordered = [...groups.entries()].sort(([a], [b]) => (a ?? 99) - (b ?? 99));
|
||||
---
|
||||
<Base>
|
||||
<section class="hero">
|
||||
<h1>اردو کی کلاسیکی شاعری اور نثر</h1>
|
||||
<p>{ud(poets.length)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
|
||||
</section>
|
||||
|
||||
{poets.length === 0 && <p class="muted">مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔</p>}
|
||||
|
||||
{ordered.map(([century, list]) => (
|
||||
<section class="century">
|
||||
<h2>{centuryName(century)}</h2>
|
||||
<div class="poets">
|
||||
{list.map((p) => (
|
||||
<a class="poet-card" href={p.url}>
|
||||
{p.nickname}
|
||||
<small>{years(p.birth_year_ah, p.death_year_ah)}</small>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</Base>
|
||||
33
web/src/pages/search.astro
Normal file
33
web/src/pages/search.astro
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { api } from '../lib/api';
|
||||
import { ud } from '../lib/urdu';
|
||||
|
||||
const q = Astro.url.searchParams.get('q')?.trim() ?? '';
|
||||
const pageNo = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1);
|
||||
const res = q ? await api(`/api/search?q=${encodeURIComponent(q)}&page=${pageNo}`) : null;
|
||||
const pages = res ? Math.ceil(res.total / res.pageSize) : 0;
|
||||
const link = (n: number) => `/search?q=${encodeURIComponent(q)}&page=${n}`;
|
||||
---
|
||||
<Base title={q ? `تلاش: ${q}` : 'تلاش'} q={q}>
|
||||
{!q && <p class="muted">اوپر کے خانے میں شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔</p>}
|
||||
{res && (
|
||||
<>
|
||||
<h1>تلاش: {q}</h1>
|
||||
<p class="muted">{res.total ? `${ud(res.total)} نتائج` : 'کوئی نتیجہ نہیں ملا۔ کم الفاظ یا کلیدی لفظ آزمائیں۔'}</p>
|
||||
{res.results.map((r: any) => (
|
||||
<div class="result">
|
||||
<a href={r.url}>{r.title}</a> · <a class="muted" href={r.poet_url}>{r.poet}</a>
|
||||
{r.first_line && <div class="first-line">{r.first_line}</div>}
|
||||
</div>
|
||||
))}
|
||||
{pages > 1 && (
|
||||
<nav class="pager">
|
||||
{pageNo > 1 && <a href={link(pageNo - 1)}>‹ پچھلا</a>}
|
||||
<span class="muted">صفحہ {ud(pageNo)} از {ud(pages)}</span>
|
||||
{pageNo < pages && <a href={link(pageNo + 1)}>اگلا ›</a>}
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Base>
|
||||
122
web/src/styles/global.css
Normal file
122
web/src/styles/global.css
Normal file
@ -0,0 +1,122 @@
|
||||
/* Divan design system: reading-first, RTL, Naskh default (Nastaliq option), light/dark. */
|
||||
:root {
|
||||
--bg: #f8f4ea;
|
||||
--surface: #fffdf7;
|
||||
--ink: #2a241c;
|
||||
--muted: #776b5a;
|
||||
--line: #e7dfcd;
|
||||
--accent: #8b2e1f; /* deep vermilion, like a manuscript rubric */
|
||||
--accent-soft: #f3e3dc;
|
||||
--gold: #b08a3e;
|
||||
--font: 'Noto Naskh Arabic', 'Noto Nastaliq Urdu', serif;
|
||||
--verse-lh: 2.1;
|
||||
--radius: 14px;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--bg: #15120e; --surface: #1d1913; --ink: #ece3d2; --muted: #a39680; --line: #332c21;
|
||||
--accent: #e08a6d; --accent-soft: #2c1d17; --gold: #d2ad62; color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #15120e; --surface: #1d1913; --ink: #ece3d2; --muted: #a39680; --line: #332c21;
|
||||
--accent: #e08a6d; --accent-soft: #2c1d17; --gold: #d2ad62; color-scheme: dark;
|
||||
}
|
||||
:root.nastaliq { --font: 'Noto Nastaliq Urdu', 'Noto Naskh Arabic', serif; --verse-lh: 2.7; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { font-size: 17px; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--ink);
|
||||
font-family: var(--font); line-height: 1.9;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
.wrap { max-width: 980px; margin: 0 auto; padding: 0 16px; }
|
||||
|
||||
/* header */
|
||||
.site-header { border-bottom: 1px solid var(--line); background: var(--surface); position: sticky; top: 0; z-index: 5; }
|
||||
.site-header .wrap { display: flex; align-items: center; gap: 16px; min-height: 64px; flex-wrap: wrap; }
|
||||
.wordmark { font-family: 'Noto Nastaliq Urdu', serif; font-size: 1.9rem; line-height: 1.4; color: var(--ink); font-weight: 700; }
|
||||
.wordmark:hover { text-decoration: none; color: var(--accent); }
|
||||
.search-form { flex: 1; min-width: 200px; display: flex; }
|
||||
.search-form input {
|
||||
flex: 1; font: inherit; padding: 6px 16px; border-radius: 999px; border: 1px solid var(--line);
|
||||
background: var(--bg); color: var(--ink);
|
||||
}
|
||||
.search-form input:focus { outline: 2px solid var(--gold); outline-offset: 1px; }
|
||||
.toggles { display: flex; gap: 8px; }
|
||||
.toggles button {
|
||||
font: inherit; font-size: .85rem; border: 1px solid var(--line); background: var(--bg); color: var(--ink);
|
||||
border-radius: 999px; padding: 2px 14px; cursor: pointer;
|
||||
}
|
||||
.toggles button:hover { border-color: var(--gold); }
|
||||
|
||||
main { padding: 24px 0 64px; }
|
||||
h1, h2, h3 { line-height: 1.6; margin: .2em 0 .4em; font-weight: 700; }
|
||||
h1 { font-size: 1.9rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.crumbs { font-size: .9rem; color: var(--muted); margin-bottom: 8px; }
|
||||
.crumbs a { color: var(--muted); }
|
||||
.crumbs span + span::before { content: '‹'; margin: 0 8px; color: var(--line); }
|
||||
|
||||
/* home */
|
||||
.hero { text-align: center; padding: 24px 0 8px; }
|
||||
.hero p { color: var(--muted); margin: 0; }
|
||||
.century { margin-top: 32px; }
|
||||
.century h2 {
|
||||
font-size: 1.15rem; color: var(--gold); display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.century h2::after { content: ''; flex: 1; height: 1px; background: var(--line); }
|
||||
.poets { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; }
|
||||
.poet-card {
|
||||
display: block; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 8px 14px; color: var(--ink);
|
||||
}
|
||||
.poet-card:hover { border-color: var(--accent); text-decoration: none; }
|
||||
.poet-card small { display: block; color: var(--muted); font-size: .8rem; }
|
||||
|
||||
/* poet / category */
|
||||
.intro {
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 12px 20px; white-space: pre-line; text-align: justify;
|
||||
}
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; margin: 16px 0; }
|
||||
.card {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
background: var(--accent-soft); border-radius: var(--radius); padding: 10px 16px; color: var(--ink);
|
||||
}
|
||||
.card:hover { text-decoration: none; outline: 1px solid var(--accent); }
|
||||
.card small { color: var(--muted); }
|
||||
.toc { list-style: none; padding: 0; margin: 16px 0; counter-reset: poem; }
|
||||
.toc li { counter-increment: poem; border-bottom: 1px dashed var(--line); }
|
||||
.toc li a { display: flex; gap: 12px; padding: 6px 4px; color: var(--ink); }
|
||||
.toc li a::before { content: counter(poem, arabic-indic); color: var(--gold); min-width: 2.5em; }
|
||||
.toc li a:hover { color: var(--accent); text-decoration: none; }
|
||||
|
||||
/* poem */
|
||||
.poem {
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 24px clamp(16px, 4vw, 48px); margin: 16px 0;
|
||||
}
|
||||
.couplet { display: grid; grid-template-columns: 1fr 1fr; gap: 0 48px; margin: 0 0 14px; line-height: var(--verse-lh); font-size: 1.12rem; }
|
||||
.couplet p { margin: 0; }
|
||||
.couplet p:last-child { text-align: left; }
|
||||
.single { text-align: center; margin: 0 0 6px; line-height: var(--verse-lh); font-size: 1.12rem; }
|
||||
.para { text-align: justify; margin: 0 0 14px; line-height: var(--verse-lh); }
|
||||
@media (max-width: 640px) {
|
||||
.couplet { grid-template-columns: 1fr; }
|
||||
.couplet p:last-child { text-align: right; padding-right: 2em; }
|
||||
}
|
||||
.poem-nav { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.poem-nav a { border: 1px solid var(--line); border-radius: 999px; padding: 2px 16px; }
|
||||
.source { font-size: .85rem; color: var(--muted); }
|
||||
|
||||
/* search */
|
||||
.result { border-bottom: 1px solid var(--line); padding: 10px 0; }
|
||||
.result .first-line { color: var(--muted); }
|
||||
.pager { display: flex; gap: 12px; justify-content: center; margin-top: 24px; }
|
||||
|
||||
.site-footer { border-top: 1px solid var(--line); padding: 20px 0; font-size: .85rem; color: var(--muted); text-align: center; }
|
||||
Loading…
Reference in New Issue
Block a user