Add Russian translations and pronunciation button
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Russian fields on Mineral model (name_ru, description_ru, history_ru, etc.) - scrape_minerals_ru management command fetches from Russian Wikipedia via langlinks - EN/RU toggle in header, saved to localStorage - Speaker button next to mineral name uses Web Speech API - Section headers and labels translated - Russian Wikipedia link in footer when in RU mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,9 @@ from .models import Mineral
|
|||||||
|
|
||||||
@admin.register(Mineral)
|
@admin.register(Mineral)
|
||||||
class MineralAdmin(admin.ModelAdmin):
|
class MineralAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'formula', 'day_of_year', 'color_hex', 'category')
|
list_display = ('name', 'name_ru', 'formula', 'day_of_year', 'color_hex', 'category')
|
||||||
list_filter = ('category', 'crystal_system')
|
list_filter = ('category', 'crystal_system')
|
||||||
search_fields = ('name', 'formula')
|
search_fields = ('name', 'name_ru', 'formula')
|
||||||
list_editable = ('day_of_year', 'color_hex')
|
list_editable = ('day_of_year', 'color_hex')
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
@@ -23,6 +23,10 @@ class MineralAdmin(admin.ModelAdmin):
|
|||||||
('Text', {
|
('Text', {
|
||||||
'fields': ('description', 'history'),
|
'fields': ('description', 'history'),
|
||||||
}),
|
}),
|
||||||
|
('Russian', {
|
||||||
|
'fields': ('name_ru', 'color_description_ru',
|
||||||
|
'description_ru', 'history_ru', 'wikipedia_url_ru'),
|
||||||
|
}),
|
||||||
('Links', {
|
('Links', {
|
||||||
'fields': ('wikipedia_url',),
|
'fields': ('wikipedia_url',),
|
||||||
}),
|
}),
|
||||||
|
|||||||
244
dailystone/management/commands/scrape_minerals_ru.py
Normal file
244
dailystone/management/commands/scrape_minerals_ru.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""
|
||||||
|
Scrape Russian Wikipedia translations for existing minerals.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py scrape_minerals_ru
|
||||||
|
python manage.py scrape_minerals_ru --limit 10
|
||||||
|
python manage.py scrape_minerals_ru --skip-existing
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from dailystone.models import Mineral
|
||||||
|
|
||||||
|
SESSION = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
global SESSION
|
||||||
|
if SESSION is None:
|
||||||
|
SESSION = requests.Session()
|
||||||
|
SESSION.headers.update({
|
||||||
|
'User-Agent': 'DailyStoneBot/1.0 (k-boris.tech; educational mineral wiki)'
|
||||||
|
})
|
||||||
|
return SESSION
|
||||||
|
|
||||||
|
|
||||||
|
def _request_with_backoff(session, url, params, timeout=30, max_retries=5):
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
resp = session.get(url, params=params, timeout=timeout)
|
||||||
|
if resp.status_code == 429:
|
||||||
|
retry_after = resp.headers.get('Retry-After')
|
||||||
|
if retry_after and retry_after.isdigit():
|
||||||
|
wait = min(int(retry_after) + 1, 120)
|
||||||
|
else:
|
||||||
|
wait = 10 * (2 ** attempt)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(text):
|
||||||
|
text = re.sub(r'\[[\d,\s]+\]', '', text)
|
||||||
|
text = re.sub(r'\[citation needed\]', '', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'\[уточнить\]', '', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'\s+', ' ', text)
|
||||||
|
text = re.sub(r'\s+([.,;:!?)])', r'\1', text)
|
||||||
|
text = re.sub(r'(\()\s+', r'\1', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_russian_title(english_title):
|
||||||
|
"""Get the Russian Wikipedia article title via langlinks API."""
|
||||||
|
session = get_session()
|
||||||
|
resp = _request_with_backoff(session, 'https://en.wikipedia.org/w/api.php', params={
|
||||||
|
'action': 'query',
|
||||||
|
'titles': english_title,
|
||||||
|
'prop': 'langlinks',
|
||||||
|
'lllang': 'ru',
|
||||||
|
'redirects': 1,
|
||||||
|
'format': 'json',
|
||||||
|
})
|
||||||
|
data = resp.json()
|
||||||
|
pages = data.get('query', {}).get('pages', {})
|
||||||
|
for page_data in pages.values():
|
||||||
|
langlinks = page_data.get('langlinks', [])
|
||||||
|
if langlinks:
|
||||||
|
return langlinks[0]['*']
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_russian_page(title):
|
||||||
|
"""Fetch parsed Russian Wikipedia page."""
|
||||||
|
session = get_session()
|
||||||
|
resp = _request_with_backoff(session, 'https://ru.wikipedia.org/w/api.php', params={
|
||||||
|
'action': 'parse',
|
||||||
|
'page': title,
|
||||||
|
'prop': 'text',
|
||||||
|
'format': 'json',
|
||||||
|
'redirects': 1,
|
||||||
|
})
|
||||||
|
data = resp.json()
|
||||||
|
if 'error' in data:
|
||||||
|
return None
|
||||||
|
return data['parse']
|
||||||
|
|
||||||
|
|
||||||
|
def _find_heading_wrapper(tag):
|
||||||
|
parent = tag.parent
|
||||||
|
if parent and parent.name == 'div' and 'mw-heading' in (parent.get('class') or []):
|
||||||
|
return parent
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_section_paragraphs(start_element, max_paras=2):
|
||||||
|
parts = []
|
||||||
|
heading_classes = {'mw-heading', 'mw-heading2', 'mw-heading3'}
|
||||||
|
sibling = start_element.find_next_sibling()
|
||||||
|
while sibling:
|
||||||
|
if sibling.name in ['h2', 'h3']:
|
||||||
|
break
|
||||||
|
if sibling.name == 'div' and heading_classes & set(sibling.get('class') or []):
|
||||||
|
break
|
||||||
|
if sibling.name == 'p':
|
||||||
|
text = sibling.get_text(' ', strip=True)
|
||||||
|
if len(text) > 30:
|
||||||
|
parts.append(_clean_text(text))
|
||||||
|
if len(parts) >= max_paras:
|
||||||
|
break
|
||||||
|
sibling = sibling.find_next_sibling()
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def extract_description(soup):
|
||||||
|
paragraphs = []
|
||||||
|
for p in soup.find_all('p'):
|
||||||
|
text = p.get_text(' ', strip=True)
|
||||||
|
if len(text) > 50:
|
||||||
|
paragraphs.append(_clean_text(text))
|
||||||
|
if len(paragraphs) >= 3:
|
||||||
|
break
|
||||||
|
return '\n\n'.join(paragraphs)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_history(soup):
|
||||||
|
history_headers = [
|
||||||
|
'история', 'этимология', 'открытие', 'происхождение названия',
|
||||||
|
'название', 'нахождение', 'месторождения',
|
||||||
|
]
|
||||||
|
for header_tag in soup.find_all(['h2', 'h3']):
|
||||||
|
header_text = header_tag.get_text(strip=True).lower()
|
||||||
|
header_text = re.sub(r'\[править[^\]]*\]', '', header_text).strip()
|
||||||
|
if any(h in header_text for h in history_headers):
|
||||||
|
wrapper = _find_heading_wrapper(header_tag)
|
||||||
|
parts = _collect_section_paragraphs(wrapper)
|
||||||
|
if parts:
|
||||||
|
return '\n\n'.join(parts)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_infobox_color(soup):
|
||||||
|
"""Try to extract color description from Russian infobox."""
|
||||||
|
table = soup.find('table', class_='infobox')
|
||||||
|
if not table:
|
||||||
|
return ''
|
||||||
|
for row in table.find_all('tr'):
|
||||||
|
th = row.find('th')
|
||||||
|
td = row.find('td')
|
||||||
|
if th and td:
|
||||||
|
key = th.get_text(strip=True).lower()
|
||||||
|
if 'цвет' in key or 'окраска' in key:
|
||||||
|
return td.get_text(' ', strip=True)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Scrape Russian Wikipedia translations for existing minerals'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--limit', type=int, default=0)
|
||||||
|
parser.add_argument('--skip-existing', action='store_true',
|
||||||
|
help='Skip minerals that already have Russian name')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
limit = options['limit']
|
||||||
|
skip_existing = options['skip_existing']
|
||||||
|
|
||||||
|
minerals = Mineral.objects.all()
|
||||||
|
if limit:
|
||||||
|
minerals = minerals[:limit]
|
||||||
|
|
||||||
|
total = minerals.count()
|
||||||
|
self.stdout.write(f'Processing {total} minerals...\n')
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for i, mineral in enumerate(minerals, 1):
|
||||||
|
if skip_existing and mineral.name_ru:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(f'[{i}/{total}] {mineral.name}... ', ending='')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract English Wikipedia title from URL or use name
|
||||||
|
if mineral.wikipedia_url:
|
||||||
|
en_title = mineral.wikipedia_url.split('/wiki/')[-1]
|
||||||
|
en_title = requests.utils.unquote(en_title)
|
||||||
|
else:
|
||||||
|
en_title = mineral.name
|
||||||
|
|
||||||
|
# Find Russian article
|
||||||
|
ru_title = get_russian_title(en_title)
|
||||||
|
if not ru_title:
|
||||||
|
self.stdout.write('no Russian article')
|
||||||
|
failed += 1
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fetch Russian page
|
||||||
|
parse_data = get_russian_page(ru_title)
|
||||||
|
if not parse_data:
|
||||||
|
self.stdout.write(f'failed to fetch {ru_title}')
|
||||||
|
failed += 1
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
html = parse_data['text']['*']
|
||||||
|
soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
|
||||||
|
# Remove reference sections, navboxes, etc.
|
||||||
|
for tag in soup.find_all(['table', 'div'], class_=['navbox', 'metadata']):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
mineral.name_ru = ru_title
|
||||||
|
mineral.description_ru = extract_description(soup)
|
||||||
|
mineral.history_ru = extract_history(soup)
|
||||||
|
mineral.wikipedia_url_ru = f'https://ru.wikipedia.org/wiki/{requests.utils.quote(ru_title)}'
|
||||||
|
|
||||||
|
color = extract_infobox_color(soup)
|
||||||
|
if color:
|
||||||
|
mineral.color_description_ru = color[:300]
|
||||||
|
|
||||||
|
mineral.save()
|
||||||
|
success += 1
|
||||||
|
self.stdout.write(f'{ru_title}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(f'ERROR: {e}')
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f'\nDone: {success} translated, {skipped} skipped, {failed} failed'
|
||||||
|
)
|
||||||
38
dailystone/migrations/0002_add_russian_fields.py
Normal file
38
dailystone/migrations/0002_add_russian_fields.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-03-30 19:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dailystone', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mineral',
|
||||||
|
name='color_description_ru',
|
||||||
|
field=models.CharField(blank=True, max_length=300),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mineral',
|
||||||
|
name='description_ru',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mineral',
|
||||||
|
name='history_ru',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mineral',
|
||||||
|
name='name_ru',
|
||||||
|
field=models.CharField(blank=True, max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mineral',
|
||||||
|
name='wikipedia_url_ru',
|
||||||
|
field=models.URLField(blank=True, max_length=500),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -18,6 +18,13 @@ class Mineral(models.Model):
|
|||||||
wikipedia_url = models.URLField(max_length=500, blank=True)
|
wikipedia_url = models.URLField(max_length=500, blank=True)
|
||||||
day_of_year = models.IntegerField(unique=True, null=True, blank=True)
|
day_of_year = models.IntegerField(unique=True, null=True, blank=True)
|
||||||
|
|
||||||
|
# Russian translations
|
||||||
|
name_ru = models.CharField(max_length=200, blank=True)
|
||||||
|
description_ru = models.TextField(blank=True)
|
||||||
|
history_ru = models.TextField(blank=True)
|
||||||
|
color_description_ru = models.CharField(max_length=300, blank=True)
|
||||||
|
wikipedia_url_ru = models.URLField(max_length=500, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['day_of_year']
|
ordering = ['day_of_year']
|
||||||
|
|
||||||
|
|||||||
@@ -69,13 +69,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-toggle {
|
.header-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
font-family: 'Georgia', serif;
|
font-family: 'Georgia', serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
color: var(--stone-text);
|
color: var(--stone-text);
|
||||||
background: var(--stone-muted);
|
background: var(--stone-muted);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -85,15 +87,55 @@
|
|||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-toggle:hover {
|
.header-btn:hover {
|
||||||
background: color-mix(in srgb, var(--stone-color) 20%, #f5f5f0);
|
background: color-mix(in srgb, var(--stone-color) 20%, #f5f5f0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-toggle svg {
|
.header-btn svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-btn.active {
|
||||||
|
background: var(--stone-color);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--stone-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-btn {
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle button {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--stone-muted);
|
||||||
|
color: var(--stone-text);
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle button:first-child {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle button.active {
|
||||||
|
background: var(--stone-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
display: none;
|
display: none;
|
||||||
margin: -0.5rem 0 1.5rem;
|
margin: -0.5rem 0 1.5rem;
|
||||||
@@ -139,43 +181,51 @@
|
|||||||
background: color-mix(in srgb, var(--stone-color) 20%, #f5f5f0);
|
background: color-mix(in srgb, var(--stone-color) 20%, #f5f5f0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.random-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.4rem 0.65rem;
|
|
||||||
font-family: 'Georgia', serif;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--stone-text);
|
|
||||||
background: var(--stone-muted);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.random-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--stone-color) 20%, #f5f5f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.random-btn svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header .date {
|
.page-header .date {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mineral-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.5rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mineral-name {
|
.mineral-name {
|
||||||
font-size: 2.4rem;
|
font-size: 2.4rem;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: var(--stone-text);
|
color: var(--stone-text);
|
||||||
margin: 0.5rem 0 0.25rem;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.speak-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speak-btn:hover {
|
||||||
|
color: var(--stone-text);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speak-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.formula {
|
.formula {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-family: 'Georgia', serif;
|
font-family: 'Georgia', serif;
|
||||||
@@ -191,6 +241,16 @@
|
|||||||
top: 0.3em;
|
top: 0.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Language content */
|
||||||
|
.lang-ru { display: none; }
|
||||||
|
|
||||||
|
.mineral-name-ru {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: -0.15rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Photo gallery */
|
/* Photo gallery */
|
||||||
.gallery {
|
.gallery {
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
@@ -397,12 +457,18 @@
|
|||||||
|
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a href="#" class="search-toggle" title="Search minerals" onclick="document.getElementById('searchBar').classList.toggle('open');document.getElementById('searchInput').focus();return false;">
|
<a href="#" class="header-btn" title="Search minerals" onclick="document.getElementById('searchBar').classList.toggle('open');document.getElementById('searchInput').focus();return false;">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'dailystone:random_stone' %}" class="random-btn" title="Random mineral">
|
{% if mineral.name_ru %}
|
||||||
|
<div class="lang-toggle">
|
||||||
|
<button onclick="setLang('en')" data-lang="en" class="active">EN</button>
|
||||||
|
<button onclick="setLang('ru')" data-lang="ru">RU</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'dailystone:random_stone' %}" class="header-btn random-btn" title="Random mineral">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="16 3 21 3 21 8"></polyline><line x1="4" y1="20" x2="21" y2="3"></line>
|
<polyline points="16 3 21 3 21 8"></polyline><line x1="4" y1="20" x2="21" y2="3"></line>
|
||||||
<polyline points="21 16 21 21 16 21"></polyline><line x1="15" y1="15" x2="21" y2="21"></line>
|
<polyline points="21 16 21 21 16 21"></polyline><line x1="15" y1="15" x2="21" y2="21"></line>
|
||||||
@@ -412,7 +478,25 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="label">{% if is_random %}Random Stone{% else %}Daily Stone{% endif %}</div>
|
<div class="label">{% if is_random %}Random Stone{% else %}Daily Stone{% endif %}</div>
|
||||||
<h1 class="mineral-name">{{ mineral.name }}</h1>
|
<div class="mineral-name-row">
|
||||||
|
<h1 class="mineral-name">
|
||||||
|
<span class="lang-en">{{ mineral.name }}</span>
|
||||||
|
{% if mineral.name_ru %}<span class="lang-ru">{{ mineral.name_ru }}</span>{% endif %}
|
||||||
|
</h1>
|
||||||
|
<button class="speak-btn" onclick="speak('{{ mineral.name }}')" title="Pronounce name">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||||
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% if mineral.name_ru %}
|
||||||
|
<div class="mineral-name-ru">
|
||||||
|
<span class="lang-en">{{ mineral.name_ru }}</span>
|
||||||
|
<span class="lang-ru">{{ mineral.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if mineral.formula %}<div class="formula">{{ mineral.formula|chem_formula }}</div>{% endif %}
|
{% if mineral.formula %}<div class="formula">{{ mineral.formula|chem_formula }}</div>{% endif %}
|
||||||
<div class="date">{{ today|date:"F j, Y" }}</div>
|
<div class="date">{{ today|date:"F j, Y" }}</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -449,52 +533,57 @@
|
|||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
<div class="color-swatch"></div>
|
<div class="color-swatch"></div>
|
||||||
<div class="color-info">
|
<div class="color-info">
|
||||||
|
<span class="lang-en">
|
||||||
{% if mineral.color_description %}
|
{% if mineral.color_description %}
|
||||||
{{ mineral.color_description }}
|
{{ mineral.color_description }}
|
||||||
{% else %}
|
{% else %}
|
||||||
Typical color
|
Typical color
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if mineral.color_description_ru %}
|
||||||
|
<span class="lang-ru">{{ mineral.color_description_ru }}</span>
|
||||||
|
{% endif %}
|
||||||
<br><span class="hex">{{ mineral.color_hex }}</span>
|
<br><span class="hex">{{ mineral.color_hex }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Properties -->
|
<!-- Properties -->
|
||||||
<div class="properties">
|
<div class="properties">
|
||||||
<h2>Properties</h2>
|
<h2><span class="lang-en">Properties</span><span class="lang-ru">Свойства</span></h2>
|
||||||
<div class="prop-grid">
|
<div class="prop-grid">
|
||||||
{% if mineral.category %}
|
{% if mineral.category %}
|
||||||
<div class="prop-item">
|
<div class="prop-item">
|
||||||
<span class="prop-label">Category</span>
|
<span class="prop-label"><span class="lang-en">Category</span><span class="lang-ru">Категория</span></span>
|
||||||
<span class="prop-value">{{ mineral.category }}</span>
|
<span class="prop-value">{{ mineral.category }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if mineral.crystal_system %}
|
{% if mineral.crystal_system %}
|
||||||
<div class="prop-item">
|
<div class="prop-item">
|
||||||
<span class="prop-label">Crystal System</span>
|
<span class="prop-label"><span class="lang-en">Crystal System</span><span class="lang-ru">Сингония</span></span>
|
||||||
<span class="prop-value">{{ mineral.crystal_system }}</span>
|
<span class="prop-value">{{ mineral.crystal_system }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if mineral.mohs_hardness %}
|
{% if mineral.mohs_hardness %}
|
||||||
<div class="prop-item">
|
<div class="prop-item">
|
||||||
<span class="prop-label">Hardness (Mohs)</span>
|
<span class="prop-label"><span class="lang-en">Hardness (Mohs)</span><span class="lang-ru">Твёрдость (Моос)</span></span>
|
||||||
<span class="prop-value">{{ mineral.mohs_hardness }}</span>
|
<span class="prop-value">{{ mineral.mohs_hardness }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if mineral.luster %}
|
{% if mineral.luster %}
|
||||||
<div class="prop-item">
|
<div class="prop-item">
|
||||||
<span class="prop-label">Luster</span>
|
<span class="prop-label"><span class="lang-en">Luster</span><span class="lang-ru">Блеск</span></span>
|
||||||
<span class="prop-value">{{ mineral.luster }}</span>
|
<span class="prop-value">{{ mineral.luster }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if mineral.streak %}
|
{% if mineral.streak %}
|
||||||
<div class="prop-item">
|
<div class="prop-item">
|
||||||
<span class="prop-label">Streak</span>
|
<span class="prop-label"><span class="lang-en">Streak</span><span class="lang-ru">Черта</span></span>
|
||||||
<span class="prop-value">{{ mineral.streak }}</span>
|
<span class="prop-value">{{ mineral.streak }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if mineral.specific_gravity %}
|
{% if mineral.specific_gravity %}
|
||||||
<div class="prop-item">
|
<div class="prop-item">
|
||||||
<span class="prop-label">Specific Gravity</span>
|
<span class="prop-label"><span class="lang-en">Specific Gravity</span><span class="lang-ru">Плотность</span></span>
|
||||||
<span class="prop-value">{{ mineral.specific_gravity }}</span>
|
<span class="prop-value">{{ mineral.specific_gravity }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -504,32 +593,66 @@
|
|||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
{% if mineral.description %}
|
{% if mineral.description %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>About</h2>
|
<h2><span class="lang-en">About</span><span class="lang-ru">Описание</span></h2>
|
||||||
|
<div class="lang-en">
|
||||||
{% for para in mineral.description.splitlines %}
|
{% for para in mineral.description.splitlines %}
|
||||||
{% if para %}<p>{{ para }}</p>{% endif %}
|
{% if para %}<p>{{ para }}</p>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if mineral.description_ru %}
|
||||||
|
<div class="lang-ru">
|
||||||
|
{% for para in mineral.description_ru.splitlines %}
|
||||||
|
{% if para %}<p>{{ para }}</p>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- History -->
|
<!-- History -->
|
||||||
{% if mineral.history %}
|
{% if mineral.history %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>History & Etymology</h2>
|
<h2><span class="lang-en">History & Etymology</span><span class="lang-ru">История и этимология</span></h2>
|
||||||
|
<div class="lang-en">
|
||||||
{% for para in mineral.history.splitlines %}
|
{% for para in mineral.history.splitlines %}
|
||||||
{% if para %}<p>{{ para }}</p>{% endif %}
|
{% if para %}<p>{{ para }}</p>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if mineral.history_ru %}
|
||||||
|
<div class="lang-ru">
|
||||||
|
{% for para in mineral.history_ru.splitlines %}
|
||||||
|
{% if para %}<p>{{ para }}</p>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="page-footer">
|
<footer class="page-footer">
|
||||||
|
<span class="lang-en">
|
||||||
{% if mineral.wikipedia_url %}
|
{% if mineral.wikipedia_url %}
|
||||||
<a href="{{ mineral.wikipedia_url }}" target="_blank" rel="noopener">
|
<a href="{{ mineral.wikipedia_url }}" target="_blank" rel="noopener">
|
||||||
Read more on Wikipedia →
|
Read more on Wikipedia →
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="lang-ru">
|
||||||
|
{% if mineral.wikipedia_url_ru %}
|
||||||
|
<a href="{{ mineral.wikipedia_url_ru }}" target="_blank" rel="noopener">
|
||||||
|
Читать на Википедии →
|
||||||
|
</a>
|
||||||
|
{% elif mineral.wikipedia_url %}
|
||||||
|
<a href="{{ mineral.wikipedia_url }}" target="_blank" rel="noopener">
|
||||||
|
Read more on Wikipedia →
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
{% if total_minerals %}
|
{% if total_minerals %}
|
||||||
<span class="mineral-count">{{ total_minerals }} minerals in collection</span>
|
<span class="mineral-count">
|
||||||
|
<span class="lang-en">{{ total_minerals }} minerals in collection</span>
|
||||||
|
<span class="lang-ru">{{ total_minerals }} минералов в коллекции</span>
|
||||||
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<br>
|
<br>
|
||||||
<a href="/" class="home-link">k-boris.tech</a>
|
<a href="/" class="home-link">k-boris.tech</a>
|
||||||
@@ -543,6 +666,34 @@
|
|||||||
);
|
);
|
||||||
thumb.classList.add('active');
|
thumb.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function speak(text) {
|
||||||
|
if (!('speechSynthesis' in window)) return;
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
var u = new SpeechSynthesisUtterance(text);
|
||||||
|
u.lang = 'en-US';
|
||||||
|
u.rate = 0.85;
|
||||||
|
speechSynthesis.speak(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLang(lang) {
|
||||||
|
var en = document.querySelectorAll('.lang-en');
|
||||||
|
var ru = document.querySelectorAll('.lang-ru');
|
||||||
|
for (var i = 0; i < en.length; i++) en[i].style.display = lang === 'en' ? '' : 'none';
|
||||||
|
for (var i = 0; i < ru.length; i++) ru[i].style.display = lang === 'ru' ? '' : 'none';
|
||||||
|
var btns = document.querySelectorAll('.lang-toggle button');
|
||||||
|
for (var i = 0; i < btns.length; i++) {
|
||||||
|
btns[i].classList.toggle('active', btns[i].getAttribute('data-lang') === lang);
|
||||||
|
}
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
try { localStorage.setItem('dailystone-lang', lang); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore saved language preference
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('dailystone-lang');
|
||||||
|
if (saved === 'ru') setLang('ru');
|
||||||
|
} catch(e) {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
Reference in New Issue
Block a user