Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 472e2eac45 | |||
| ce42cb3883 | |||
| e80137dfb5 | |||
| a04547c57b | |||
| 80e48333be | |||
| b5577d57cd | |||
| af9c1bc8ff | |||
| febfe374ed | |||
| c4088df999 | |||
| b1dff396d9 | |||
| 992f7f90a7 | |||
| 360fc6b4be | |||
| a8008c37b4 | |||
| 228e52b9a4 | |||
| 59bccd5ca8 | |||
| 03a5a405f3 | |||
| e3bc69f0f8 | |||
| d1213f68dd | |||
| 742c817d39 | |||
| c888b1995e | |||
| 5f681ae589 | |||
| 9b9cf0d400 | |||
| 2a5d1d504e | |||
| e22f523f2c | |||
| 71b1dc3532 | |||
| 6d63a736cd | |||
| 1676c774bd | |||
| b947b94202 | |||
| 2cf518b903 | |||
| 1c019b6c08 | |||
| bd9db790cb | |||
| d7e475e41a | |||
| 90c76418db | |||
| 4386421459 | |||
| 72c3831eb4 | |||
| 141d97b329 | |||
| 0e163cab8b | |||
| 19cd7eafd9 | |||
| e70952dba6 | |||
| a632775da9 | |||
| 24911e2ec0 | |||
| 84d08ee675 | |||
| 4e5947545c | |||
| 6a1d3beef9 | |||
| 99434e29f5 | |||
| f8e483ba12 | |||
| 5e5c6d5837 |
@ -25,6 +25,7 @@ from django.contrib.staticfiles.storage import staticfiles_storage
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from . import utils as core_utils
|
from . import utils as core_utils
|
||||||
from .models_audit import AuditLog
|
from .models_audit import AuditLog
|
||||||
|
from .scripture_normalizer import normalize_scripture_field
|
||||||
|
|
||||||
|
|
||||||
# Order + labels used in the Search UI
|
# Order + labels used in the Search UI
|
||||||
@ -118,6 +119,9 @@ def search_page(request):
|
|||||||
- quoted phrases
|
- quoted phrases
|
||||||
- * and ? wildcards (regex); if regex returns zero, falls back to icontains
|
- * and ? wildcards (regex); if regex returns zero, falls back to icontains
|
||||||
- AND across tokens, OR across the selected fields
|
- AND across tokens, OR across the selected fields
|
||||||
|
|
||||||
|
Special power term:
|
||||||
|
- 'invalidscripture' -> entries whose Scripture would be INVALID per the JS validator
|
||||||
"""
|
"""
|
||||||
default_fields = {
|
default_fields = {
|
||||||
"subject": True,
|
"subject": True,
|
||||||
@ -143,6 +147,175 @@ def search_page(request):
|
|||||||
|
|
||||||
q = (request.GET.get("q") or "").strip()
|
q = (request.GET.get("q") or "").strip()
|
||||||
if q:
|
if q:
|
||||||
|
# ===== SPECIAL POWER TERM (JS-compatible scripture validation) =====
|
||||||
|
if q.lower() == "invalidscripture":
|
||||||
|
import re
|
||||||
|
|
||||||
|
# --- JS validator port (same logic as ScriptureValidator.isValidSingleRef) ---
|
||||||
|
FULL_TO_CODE = {
|
||||||
|
# OT
|
||||||
|
"genesis":"Ge","exodus":"Ex","leviticus":"Le","numbers":"Nu","deuteronomy":"De",
|
||||||
|
"joshua":"Jos","judges":"Jg","ruth":"Ru",
|
||||||
|
"1 samuel":"1Sa","2 samuel":"2Sa","1 kings":"1Ki","2 kings":"2Ki",
|
||||||
|
"1 chronicles":"1Ch","2 chronicles":"2Ch",
|
||||||
|
"ezra":"Ezr","nehemiah":"Ne","esther":"Es","job":"Job","psalms":"Ps","psalm":"Ps",
|
||||||
|
"proverbs":"Pr","ecclesiastes":"Ec","song of solomon":"Ca","song of songs":"Ca",
|
||||||
|
"isaiah":"Isa","jeremiah":"Jer","lamentations":"La","ezekiel":"Eze","daniel":"Da",
|
||||||
|
"hosea":"Ho","joel":"Joe","amos":"Am","obadiah":"Ob","jonah":"Jon","micah":"Mic",
|
||||||
|
"nahum":"Na","habakkuk":"Hab","zephaniah":"Zep","haggai":"Hag","zechariah":"Zec","malachi":"Mal",
|
||||||
|
# NT
|
||||||
|
"matthew":"Mt","mark":"Mr","luke":"Lu","john":"Joh","acts":"Ac","romans":"Ro",
|
||||||
|
"1 corinthians":"1Co","2 corinthians":"2Co",
|
||||||
|
"galatians":"Ga","ephesians":"Eph","philippians":"Php","colossians":"Col",
|
||||||
|
"1 thessalonians":"1Th","2 thessalonians":"2Th",
|
||||||
|
"1 timothy":"1Ti","2 timothy":"2Ti",
|
||||||
|
"titus":"Tit","philemon":"Phm","hebrews":"Heb","james":"Jas",
|
||||||
|
"1 peter":"1Pe","2 peter":"2Pe",
|
||||||
|
"1 john":"1Jo","2 john":"2Jo","3 john":"3Jo",
|
||||||
|
"jude":"Jude","revelation":"Re",
|
||||||
|
}
|
||||||
|
ALIAS_TO_CODE = {
|
||||||
|
# OT
|
||||||
|
"gen":"Ge","exod":"Ex","lev":"Le","num":"Nu","deut":"De",
|
||||||
|
"josh":"Jos","judg":"Jg","ps":"Ps","prov":"Pr","eccl":"Ec","song":"Ca","cant":"Ca",
|
||||||
|
"isa":"Isa","jer":"Jer","lam":"La","ezek":"Eze","dan":"Da","hos":"Ho","joel":"Joe",
|
||||||
|
"amos":"Am","obad":"Ob","jon":"Jon","mic":"Mic","nah":"Na","hab":"Hab","zeph":"Zep",
|
||||||
|
"hag":"Hag","zech":"Zec","mal":"Mal",
|
||||||
|
# NT
|
||||||
|
"matt":"Mt","mark":"Mr","luke":"Lu","john":"Joh","acts":"Ac","rom":"Ro",
|
||||||
|
"gal":"Ga","eph":"Eph","phil":"Php","col":"Col","heb":"Heb","jas":"Jas",
|
||||||
|
"jude":"Jude","rev":"Re",
|
||||||
|
}
|
||||||
|
CODE_TO_NUM = {
|
||||||
|
# OT
|
||||||
|
"Ge":1,"Ex":2,"Le":3,"Nu":4,"De":5,"Jos":6,"Jg":7,"Ru":8,"1Sa":9,"2Sa":10,
|
||||||
|
"1Ki":11,"2Ki":12,"1Ch":13,"2Ch":14,"Ezr":15,"Ne":16,"Es":17,"Job":18,
|
||||||
|
"Ps":19,"Pr":20,"Ec":21,"Ca":22,"Isa":23,"Jer":24,"La":25,"Eze":26,"Da":27,"Ho":28,
|
||||||
|
"Joe":29,"Am":30,"Ob":31,"Jon":32,"Mic":33,"Na":34,"Hab":35,"Zep":36,"Hag":37,"Zec":38,"Mal":39,
|
||||||
|
# NT
|
||||||
|
"Mt":40,"Mr":41,"Lu":42,"Joh":43,"Ac":44,"Ro":45,"1Co":46,"2Co":47,"Ga":48,"Eph":49,
|
||||||
|
"Php":50,"Col":51,"1Th":52,"2Th":53,"1Ti":54,"2Ti":55,"Tit":56,"Phm":57,
|
||||||
|
"Heb":58,"Jas":59,"1Pe":60,"2Pe":61,"1Jo":62,"2Jo":63,"3Jo":64,"Jude":65,"Re":66,
|
||||||
|
}
|
||||||
|
SERIES = [
|
||||||
|
{"prefixes":["sam","samu","samuel"], "codes":{1:"1Sa",2:"2Sa"}},
|
||||||
|
{"prefixes":["ki","king","kings","kgs"], "codes":{1:"1Ki",2:"2Ki"}},
|
||||||
|
{"prefixes":["chron","chr","ch","chronicles"], "codes":{1:"1Ch",2:"2Ch"}},
|
||||||
|
{"prefixes":["cor","corin","corinth","corinthians","co","c"], "codes":{1:"1Co",2:"2Co"}},
|
||||||
|
{"prefixes":["thes","thess","thessalon","thessalonians","th"], "codes":{1:"1Th",2:"2Th"}},
|
||||||
|
{"prefixes":["tim","ti","timothy","t"], "codes":{1:"1Ti",2:"2Ti"}},
|
||||||
|
{"prefixes":["pet","pe","peter","pt","p"], "codes":{1:"1Pe",2:"2Pe"}},
|
||||||
|
{"prefixes":["jo","jn","joh","john","jno","jhn"], "codes":{1:"1Jo",2:"2Jo",3:"3Jo"}},
|
||||||
|
]
|
||||||
|
WOL_ABBR = set(CODE_TO_NUM.keys())
|
||||||
|
versesRe = re.compile(r"""
|
||||||
|
^
|
||||||
|
(?:
|
||||||
|
(\d{1,3}) # chapter only
|
||||||
|
|
|
||||||
|
(\d{1,3})\s*:\s*
|
||||||
|
(
|
||||||
|
\d{1,3} # v1
|
||||||
|
(?:\s*-\s*(?:\d{1,3}|\d{1,3}:\d{1,3}))? # -v2 OR -ch:vs
|
||||||
|
(?:\s*,\s*\d{1,3}(?:\s*-\s*(?:\d{1,3}|\d{1,3}:\d{1,3}))?)* # ,vN[-…]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
$
|
||||||
|
""", re.VERBOSE)
|
||||||
|
|
||||||
|
def _norm_spaces(s): return re.sub(r"\s+", " ", (s or "").strip())
|
||||||
|
def _strip_dots(s): return re.sub(r"\.+$", "", s or "")
|
||||||
|
def _lower(s): return (s or "").lower()
|
||||||
|
|
||||||
|
def _lookup_book_code(book_raw: str):
|
||||||
|
b = _norm_spaces(_strip_dots(book_raw))
|
||||||
|
# Full names
|
||||||
|
c = FULL_TO_CODE.get(_lower(b))
|
||||||
|
if c: return c
|
||||||
|
# Aliases
|
||||||
|
c = ALIAS_TO_CODE.get(_lower(b))
|
||||||
|
if c: return c
|
||||||
|
# WOL abbr (allow a space after the number, and arbitrary spaces)
|
||||||
|
tightened = re.sub(r"^([1-3])\s+([A-Za-z].*)$", r"\1\2", b)
|
||||||
|
if tightened in WOL_ABBR: return tightened
|
||||||
|
no_space = re.sub(r"\s+", "", b)
|
||||||
|
if no_space in WOL_ABBR: return no_space
|
||||||
|
# Numbered prose (e.g., "2 Sam", "1 Chron", "3 Jo")
|
||||||
|
m = re.match(r"^([1-3])\s*([A-Za-z]+)$", _lower(b))
|
||||||
|
if m:
|
||||||
|
n = int(m.group(1)); base = m.group(2)
|
||||||
|
for fam in SERIES:
|
||||||
|
if any(base.startswith(p) for p in fam["prefixes"]):
|
||||||
|
code = fam["codes"].get(n)
|
||||||
|
if code: return code
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _split_book_and_rest(s: str):
|
||||||
|
m = re.match(r"^(.+?)\s+(\d{1,3}(?:\s*:\s*.*)?)$", s)
|
||||||
|
return {"book": m.group(1), "rest": m.group(2)} if m else None
|
||||||
|
|
||||||
|
def _is_valid_single_ref(ref: str) -> bool:
|
||||||
|
s = (ref or "").strip()
|
||||||
|
if not s: return False
|
||||||
|
parts = _split_book_and_rest(s)
|
||||||
|
if not parts: return False
|
||||||
|
if not _lookup_book_code(parts["book"]): return False
|
||||||
|
rest = (parts.get("rest") or "").trim() if hasattr(str, "trim") else (parts.get("rest") or "").strip()
|
||||||
|
if not rest: return False
|
||||||
|
return bool(versesRe.match(rest))
|
||||||
|
|
||||||
|
def _field_is_valid(text: str) -> bool:
|
||||||
|
pieces = [p.strip() for p in (text or "").split(";") if p.strip()]
|
||||||
|
if not pieces: # empty field considered neutral/invalid? UI treats empty as neither; we exclude empties anyway
|
||||||
|
return False
|
||||||
|
return all(_is_valid_single_ref(p) for p in pieces)
|
||||||
|
# --- end JS port ---
|
||||||
|
|
||||||
|
invalid_ids = []
|
||||||
|
qs = Entry.objects.exclude(scripture_raw="").only("id", "scripture_raw", "date_added")
|
||||||
|
for e in qs.iterator(chunk_size=1000):
|
||||||
|
if not _field_is_valid(e.scripture_raw or ""):
|
||||||
|
invalid_ids.append(e.id)
|
||||||
|
|
||||||
|
ids = list(
|
||||||
|
Entry.objects.filter(id__in=invalid_ids)
|
||||||
|
.order_by("-date_added", "-id")
|
||||||
|
.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"[search] q='invalidscripture' count={len(ids)}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
request.session["result_ids"] = ids
|
||||||
|
request.session["last_search"] = {"q": q, "fields": ["scripture_raw"]}
|
||||||
|
request.session.modified = True
|
||||||
|
|
||||||
|
if ids:
|
||||||
|
entry = Entry.objects.get(pk=ids[0])
|
||||||
|
ctx = entry_context(entry, ids)
|
||||||
|
ctx.update({"from_search": True})
|
||||||
|
if request.user.is_staff:
|
||||||
|
ctx["tts_url"] = reverse("api_tts_for_entry", args=[entry.id])
|
||||||
|
return render(request, "entry_view.html", ctx)
|
||||||
|
|
||||||
|
total = Entry.objects.count()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"search.html",
|
||||||
|
{
|
||||||
|
"q": q,
|
||||||
|
"selected": selected,
|
||||||
|
"field_options": field_options,
|
||||||
|
"total": total,
|
||||||
|
"ran_search": True,
|
||||||
|
"result_count": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# ===== END SPECIAL TERM =====
|
||||||
|
|
||||||
|
# --- existing search flow ---
|
||||||
tokens = terms(q)
|
tokens = terms(q)
|
||||||
fields = [f for f, sel in selected.items() if sel] or ["subject"]
|
fields = [f for f, sel in selected.items() if sel] or ["subject"]
|
||||||
|
|
||||||
@ -178,20 +351,15 @@ def search_page(request):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
request.session["result_ids"] = ids
|
request.session["result_ids"] = ids
|
||||||
count = len(ids)
|
|
||||||
# Ensure highlighter data is available BEFORE navigating to entry_view
|
|
||||||
request.session["last_search"] = {"q": q, "fields": fields}
|
request.session["last_search"] = {"q": q, "fields": fields}
|
||||||
request.session.modified = True # be explicit so it’s flushed
|
request.session.modified = True
|
||||||
|
|
||||||
if count:
|
if ids:
|
||||||
entry = Entry.objects.get(pk=ids[0])
|
entry = Entry.objects.get(pk=ids[0])
|
||||||
ctx = entry_context(entry, ids)
|
ctx = entry_context(entry, ids)
|
||||||
ctx.update({"from_search": True})
|
ctx.update({"from_search": True})
|
||||||
|
|
||||||
# 🔽 ADD THIS
|
|
||||||
if request.user.is_staff:
|
if request.user.is_staff:
|
||||||
ctx["tts_url"] = reverse("api_tts_for_entry", args=[entry.id])
|
ctx["tts_url"] = reverse("api_tts_for_entry", args=[entry.id])
|
||||||
|
|
||||||
return render(request, "entry_view.html", ctx)
|
return render(request, "entry_view.html", ctx)
|
||||||
|
|
||||||
total = Entry.objects.count()
|
total = Entry.objects.count()
|
||||||
@ -1078,3 +1246,50 @@ def set_theme(request):
|
|||||||
request.session["theme"] = theme
|
request.session["theme"] = theme
|
||||||
messages.success(request, f"Theme set to {theme.title()}.")
|
messages.success(request, f"Theme set to {theme.title()}.")
|
||||||
return redirect("settings_home")
|
return redirect("settings_home")
|
||||||
|
|
||||||
|
# web/core/views.py
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
|
from django.http import JsonResponse, HttpResponseBadRequest
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@login_required
|
||||||
|
@user_passes_test(lambda u: u.is_superuser)
|
||||||
|
def api_update_pub_codes(request):
|
||||||
|
"""
|
||||||
|
Accepts a 'json' field (string) that should parse to {"pub_codes": [..]}.
|
||||||
|
Normalizes, de-duplicates, and writes to web/static/data/wol-pub-codes.v1.json.
|
||||||
|
"""
|
||||||
|
payload = request.POST.get("json") or (request.body.decode("utf-8") if request.body else "")
|
||||||
|
if not payload:
|
||||||
|
return HttpResponseBadRequest("Missing 'json'.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(payload)
|
||||||
|
except Exception as e:
|
||||||
|
return HttpResponseBadRequest(f"Invalid JSON: {e}")
|
||||||
|
|
||||||
|
if not isinstance(data, dict) or "pub_codes" not in data or not isinstance(data["pub_codes"], list):
|
||||||
|
return HttpResponseBadRequest('JSON must be an object with a "pub_codes" array.')
|
||||||
|
|
||||||
|
# Normalize to unique, lowercase, trimmed strings
|
||||||
|
seen = set()
|
||||||
|
codes = []
|
||||||
|
for c in data["pub_codes"]:
|
||||||
|
s = str(c or "").strip().lower()
|
||||||
|
if s and s not in seen:
|
||||||
|
seen.add(s)
|
||||||
|
codes.append(s)
|
||||||
|
|
||||||
|
# Write back to static data file
|
||||||
|
target_path = os.path.join(settings.BASE_DIR, "web", "static", "data", "wol-pub-codes.v1.json")
|
||||||
|
try:
|
||||||
|
with open(target_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"pub_codes": codes}, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
return HttpResponseBadRequest(f"Could not write file: {e}")
|
||||||
|
|
||||||
|
return JsonResponse({"ok": True, "count": len(codes)})
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# illustrations/urls.py
|
# illustrations/urls.py
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, re_path
|
from django.urls import path, re_path, include
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.views.generic import TemplateView # NEW: for manifest / service worker / offline
|
from django.views.generic import TemplateView # NEW: for manifest / service worker / offline
|
||||||
|
|
||||||
@ -54,6 +54,7 @@ urlpatterns = [
|
|||||||
path("api/get-recent-views/", feat_views.api_get_recent_views, name="api_get_recent_views"),
|
path("api/get-recent-views/", feat_views.api_get_recent_views, name="api_get_recent_views"),
|
||||||
path("settings/", core_views.settings_home, name="settings_home"),
|
path("settings/", core_views.settings_home, name="settings_home"),
|
||||||
path("settings/theme/", core_views.set_theme, name="set_theme"), # ✅ FIXED
|
path("settings/theme/", core_views.set_theme, name="set_theme"), # ✅ FIXED
|
||||||
|
path("api/update-pub-codes/", core_views.api_update_pub_codes, name="api_update_pub_codes"),
|
||||||
|
|
||||||
# --- NEW (PWA) ---
|
# --- NEW (PWA) ---
|
||||||
re_path(
|
re_path(
|
||||||
|
|||||||
10
web/static/data/wol-pub-codes.v1.json
Normal file
10
web/static/data/wol-pub-codes.v1.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"pub_codes": [
|
||||||
|
"wp", "ws", "yb", "mwb", "w", "g",
|
||||||
|
"ap", "apf", "be", "bh", "br", "bt", "btg", "cf", "cl", "ct", "dp",
|
||||||
|
"fg", "fy", "gt", "hb", "im", "ip", "it", "jv", "ka", "kj", "kl",
|
||||||
|
"lf", "lff", "ll", "ly", "my", "od", "pe", "po", "pt", "rr", "rs",
|
||||||
|
"sg", "sh", "si", "td", "tp", "tr", "ts", "un", "jy",
|
||||||
|
"uw", "su", "re", "lvs", "lp", "yy", "yp2", "yp", "sl", "pm", "kc", "jd", "jr", "ia", "hs", "ia", "hs", "lv", "kr", "km", "wcg", "bw", "ce", "ad"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,22 +1,47 @@
|
|||||||
/* source-validator.v1.js
|
/* source-validator.v1.js
|
||||||
Centralizes logic for deciding if a "Source" string should link to WOL.
|
Centralizes logic for deciding if a "Source" string should link to WOL.
|
||||||
|
Loads publication codes from /static/data/wol-pub-codes.v1.json.
|
||||||
|
|
||||||
Exposes:
|
Exposes:
|
||||||
- SourceValidator.isWOLSource(text) -> boolean
|
- SourceValidator.isWOLSource(text) -> boolean
|
||||||
- SourceValidator.buildWOLSearchURL(text) -> string
|
- SourceValidator.buildWOLSearchURL(text) -> string
|
||||||
*/
|
*/
|
||||||
window.SourceValidator = (function () {
|
window.SourceValidator = (function () {
|
||||||
// Publications / codes that produce valid WOL links.
|
// ---- Load publication codes (sync so callers can use API immediately) ----
|
||||||
// Added: uw, su, re, lvs, rs (rs was already present).
|
function loadPubCodesSync() {
|
||||||
const PUB_CODES = [
|
try {
|
||||||
"wp","ws","yb","mwb","w","g","ap","apf","be","bh","br","bt","btg","cf","cl","ct","dp",
|
var xhr = new XMLHttpRequest();
|
||||||
"fg","fy","gt","hb","im","ip","it","jv","ka","kj","kl","lf","lff","ll","ly","my","od",
|
xhr.open("GET", "/static/data/wol-pub-codes.v1.json", false); // synchronous
|
||||||
"pe","po","pt","rr","rs","sg","sh","si","td","tp","tr","ts","un","jy",
|
xhr.send(null);
|
||||||
"uw","su","re","lvs","lp","yy","yp2","yp","jv","sl","pm"// new
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
];
|
var data = JSON.parse(xhr.responseText || "{}");
|
||||||
|
if (data && Array.isArray(data.pub_codes)) {
|
||||||
|
// de-duplicate and normalize to lowercase strings
|
||||||
|
var uniq = Object.create(null), out = [];
|
||||||
|
for (var i = 0; i < data.pub_codes.length; i++) {
|
||||||
|
var c = String(data.pub_codes[i] || "").trim().toLowerCase();
|
||||||
|
if (!c) continue;
|
||||||
|
if (!uniq[c]) { uniq[c] = 1; out.push(c); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fall through to fallback
|
||||||
|
}
|
||||||
|
// Fallback (very small set) — only used if JSON cannot be loaded
|
||||||
|
return ["w", "wp", "ws", "g", "rs"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publications / codes loaded from JSON
|
||||||
|
var PUB_CODES = loadPubCodesSync();
|
||||||
|
|
||||||
|
// Choose the longest matching code at the start (so "ws" beats "w").
|
||||||
|
var PUB_CODES_SORTED = PUB_CODES.slice().sort(function (a, b) { return b.length - a.length; });
|
||||||
|
|
||||||
// Year validation rules (applies only if a year can be parsed from the source).
|
// Year validation rules (applies only if a year can be parsed from the source).
|
||||||
// Watchtower (w/wp/ws) back to 1950; Awake (g) back to 1970.
|
// Watchtower (w/wp/ws) back to 1950; Awake (g) back to 1970.
|
||||||
const YEAR_RULES = [
|
var YEAR_RULES = [
|
||||||
{ codes: ["w","wp","ws"], minYear: 1950 },
|
{ codes: ["w","wp","ws"], minYear: 1950 },
|
||||||
{ codes: ["g"], minYear: 1970 }
|
{ codes: ["g"], minYear: 1970 }
|
||||||
];
|
];
|
||||||
@ -24,12 +49,10 @@ window.SourceValidator = (function () {
|
|||||||
// Normalize helper
|
// Normalize helper
|
||||||
function normalize(s) { return (s || "").trim().toLowerCase(); }
|
function normalize(s) { return (s || "").trim().toLowerCase(); }
|
||||||
|
|
||||||
// Choose the longest matching code at the start (so "ws" beats "w").
|
|
||||||
const PUB_CODES_SORTED = [...PUB_CODES].sort((a,b)=>b.length-a.length);
|
|
||||||
|
|
||||||
function leadingCode(textLower) {
|
function leadingCode(textLower) {
|
||||||
for (const code of PUB_CODES_SORTED) {
|
for (var i = 0; i < PUB_CODES_SORTED.length; i++) {
|
||||||
if (textLower.startsWith(code)) return code;
|
var code = PUB_CODES_SORTED[i];
|
||||||
|
if (textLower.indexOf(code) === 0) return code;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -37,60 +60,51 @@ window.SourceValidator = (function () {
|
|||||||
// Try to extract a year that appears right after the leading code (allow spaces),
|
// Try to extract a year that appears right after the leading code (allow spaces),
|
||||||
// accepting either 4-digit (e.g., 1955, 2001) or 2-digit (e.g., 55, 95, 12) forms.
|
// accepting either 4-digit (e.g., 1955, 2001) or 2-digit (e.g., 55, 95, 12) forms.
|
||||||
function extractYearAfterCode(textLower, code) {
|
function extractYearAfterCode(textLower, code) {
|
||||||
let s = textLower.slice(code.length).trim();
|
var s = textLower.slice(code.length).trim();
|
||||||
|
|
||||||
// 1) Look for a 4-digit year first
|
// 1) Look for a 4-digit year first (1800–2099)
|
||||||
let m = s.match(/\b(1[89]\d{2}|20\d{2})\b/); // 1800-2099 (broad, but OK)
|
var m = s.match(/\b(1[89]\d{2}|20\d{2})\b/);
|
||||||
if (m) {
|
if (m) return parseInt(m[1], 10);
|
||||||
return parseInt(m[1], 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) If not found, accept a 2-digit year at the *start* of the remainder,
|
// 2) If not found, accept a 2-digit year at the *start* of the remainder
|
||||||
// or right after an optional space: e.g., "w55 1/1", "w 95", "g70 1/22"
|
// (e.g., "w55 1/1", "w 95", "g70 1/22")
|
||||||
m = s.match(/^\s*(\d{2})\b/);
|
m = s.match(/^\s*(\d{2})\b/);
|
||||||
if (m) {
|
if (m) {
|
||||||
const yy = parseInt(m[1], 10);
|
var yy = parseInt(m[1], 10);
|
||||||
// Infer century based on publication + threshold logic
|
if (code === "g") return yy >= 70 ? (1900 + yy) : (2000 + yy);
|
||||||
// - For Watchtower: 50–99 -> 1950–1999; 00–49 -> 2000–2049
|
if (code === "w" || code === "wp" || code === "ws")
|
||||||
// - For Awake: 70–99 -> 1970–1999; 00–69 -> 2000–2069
|
return yy >= 50 ? (1900 + yy) : (2000 + yy);
|
||||||
if (code === "g") {
|
|
||||||
return yy >= 70 ? (1900 + yy) : (2000 + yy);
|
|
||||||
}
|
|
||||||
if (code === "w" || code === "wp" || code === "ws") {
|
|
||||||
return yy >= 50 ? (1900 + yy) : (2000 + yy);
|
|
||||||
}
|
|
||||||
// For other pubs, if they ever include 2-digit years, assume 1900+yy≥70 else 2000+yy
|
|
||||||
return yy >= 70 ? (1900 + yy) : (2000 + yy);
|
return yy >= 70 ? (1900 + yy) : (2000 + yy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No recognizable year → don't enforce year limits
|
// No recognizable year → don't enforce year limits
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function passesYearRuleIfPresent(textLower, code) {
|
function passesYearRuleIfPresent(textLower, code) {
|
||||||
const rule = YEAR_RULES.find(r => r.codes.includes(code));
|
var rule = null;
|
||||||
|
for (var i=0;i<YEAR_RULES.length;i++){
|
||||||
|
if (YEAR_RULES[i].codes.indexOf(code) !== -1) { rule = YEAR_RULES[i]; break; }
|
||||||
|
}
|
||||||
if (!rule) return true; // no year rule for this pub
|
if (!rule) return true; // no year rule for this pub
|
||||||
|
|
||||||
const y = extractYearAfterCode(textLower, code);
|
var y = extractYearAfterCode(textLower, code);
|
||||||
if (y == null) return true; // no year present → allow
|
if (y == null) return true; // no year present → allow
|
||||||
return y >= rule.minYear;
|
return y >= rule.minYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWOLSource(text) {
|
function isWOLSource(text) {
|
||||||
const t = normalize(text);
|
var t = normalize(text);
|
||||||
if (!t) return false;
|
if (!t) return false;
|
||||||
|
var code = leadingCode(t);
|
||||||
const code = leadingCode(t);
|
|
||||||
if (!code) return false;
|
if (!code) return false;
|
||||||
|
|
||||||
// If starts with a known pub code, it’s WOL-capable — but enforce year rules where applicable.
|
// If starts with a known pub code, it’s WOL-capable — but enforce year rules where applicable.
|
||||||
return passesYearRuleIfPresent(t, code);
|
return passesYearRuleIfPresent(t, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWOLSearchURL(text) {
|
function buildWOLSearchURL(text) {
|
||||||
const q = encodeURIComponent(text || "");
|
var q = encodeURIComponent(text || "");
|
||||||
return `https://wol.jw.org/en/wol/l/r1/lp-e?q=${q}`;
|
return "https://wol.jw.org/en/wol/l/r1/lp-e?q=" + q;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isWOLSource, buildWOLSearchURL };
|
return { isWOLSource: isWOLSource, buildWOLSearchURL: buildWOLSearchURL };
|
||||||
})();
|
})();
|
||||||
415
web/static/themes/arcade.css
Normal file
415
web/static/themes/arcade.css
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
/* ===============================
|
||||||
|
Arcade — 80s Side-Scroller
|
||||||
|
=============================== */
|
||||||
|
|
||||||
|
/* Optional pixel fonts (load externally if you like):
|
||||||
|
- "Press Start 2P", "VT323", "Pixelify Sans"
|
||||||
|
This theme will fall back to monospace if those aren’t present.
|
||||||
|
*/
|
||||||
|
|
||||||
|
html[data-theme="arcade"] {
|
||||||
|
/* Core palette */
|
||||||
|
--arc-ink: #dff5ff; /* UI text on dark */
|
||||||
|
--arc-ink-muted: #9bd0e7;
|
||||||
|
--arc-ink-strong: #ffffff;
|
||||||
|
|
||||||
|
--arc-face: #0a1630; /* panels/cards dark navy */
|
||||||
|
--arc-elev: #0f1f45; /* elevated */
|
||||||
|
--arc-border: #1c3b77;
|
||||||
|
--arc-shadow: rgba(0,0,0,.45);
|
||||||
|
|
||||||
|
/* Accent pixels */
|
||||||
|
--arc-accent: #1de1ff; /* cyan */
|
||||||
|
--arc-accent-600: #10b6d1; /* cyan hover */
|
||||||
|
--arc-accent-200: #7ee9ff;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--arc-success: #22d36f;
|
||||||
|
--arc-warning: #f4c542;
|
||||||
|
--arc-danger: #ff4d6d;
|
||||||
|
--arc-info: #5ea1ff;
|
||||||
|
|
||||||
|
/* Buttons / geometry */
|
||||||
|
--arc-radius: 10px;
|
||||||
|
--arc-chip-radius: 999px;
|
||||||
|
|
||||||
|
/* Pixel borders (fake 1px “CRT” bevel) */
|
||||||
|
--px-light: rgba(255,255,255,.22);
|
||||||
|
--px-dark: rgba(0,0,0,.6);
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
--arc-bar: #08102a;
|
||||||
|
--arc-bar-ink: #e6f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base + Animated Background
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] body{
|
||||||
|
min-height:100vh;
|
||||||
|
color:var(--arc-ink);
|
||||||
|
background:#07132a;
|
||||||
|
font-family: "Press Start 2P","Pixelify Sans","VT323","SFMono-Regular",
|
||||||
|
Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
|
||||||
|
letter-spacing:.2px;
|
||||||
|
position:relative;
|
||||||
|
overflow-x:hidden; /* prevent any horizontal jiggle */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parallax stars + floating orbs + moving ground.
|
||||||
|
All CSS-only; no images. */
|
||||||
|
html[data-theme="arcade"] body::before,
|
||||||
|
html[data-theme="arcade"] body::after{
|
||||||
|
content:"";
|
||||||
|
position:fixed;
|
||||||
|
inset:0;
|
||||||
|
pointer-events:none;
|
||||||
|
z-index:-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stars layer */
|
||||||
|
html[data-theme="arcade"] body::before{
|
||||||
|
background:
|
||||||
|
radial-gradient(2px 2px at 20% 30%, #ffffff 60%, transparent 61%) repeat,
|
||||||
|
radial-gradient(2px 2px at 70% 60%, #a4ffff 60%, transparent 61%) repeat,
|
||||||
|
radial-gradient(1px 1px at 40% 80%, #d0f0ff 60%, transparent 61%) repeat;
|
||||||
|
background-size: 200px 200px, 260px 260px, 180px 180px;
|
||||||
|
animation: arc-stars 60s linear infinite;
|
||||||
|
opacity:.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Platforms / ground + orbs */
|
||||||
|
html[data-theme="arcade"] body::after{
|
||||||
|
background:
|
||||||
|
/* orbs/coins */
|
||||||
|
radial-gradient(14px 14px at 15% 25%, #ffdf57 35%, #ff9a3d 36% 55%, transparent 56%) ,
|
||||||
|
radial-gradient(10px 10px at 65% 35%, #ff8bd6 35%, #e24da8 36% 55%, transparent 56%) ,
|
||||||
|
radial-gradient(12px 12px at 80% 70%, #85f766 35%, #2ed158 36% 55%, transparent 56%) ,
|
||||||
|
/* floating platforms (grass on stone) */
|
||||||
|
linear-gradient(#2f5836, #214527) 0 72%, /* top edge */
|
||||||
|
repeating-linear-gradient(
|
||||||
|
#223a7a 0 16px,
|
||||||
|
#1d2f62 16px 32px
|
||||||
|
),
|
||||||
|
/* ground stripe bottom */
|
||||||
|
linear-gradient(#0b1a40, #0b1a40) 0 100%;
|
||||||
|
background-repeat: no-repeat, no-repeat, no-repeat, repeat-x, repeat, repeat-x;
|
||||||
|
background-size:
|
||||||
|
28px 28px, 22px 22px, 24px 24px,
|
||||||
|
100% 10px,
|
||||||
|
100% 100%,
|
||||||
|
100% 8px;
|
||||||
|
background-position:
|
||||||
|
10% 26%, 68% 38%, 82% 72%,
|
||||||
|
0 60%,
|
||||||
|
0 0,
|
||||||
|
0 100%;
|
||||||
|
animation: arc-floating 14s ease-in-out infinite alternate,
|
||||||
|
arc-ground 40s linear infinite;
|
||||||
|
opacity:.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect prefers-reduced-motion */
|
||||||
|
@media (prefers-reduced-motion: reduce){
|
||||||
|
html[data-theme="arcade"] body::before,
|
||||||
|
html[data-theme="arcade"] body::after{
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Starfield gentle drift */
|
||||||
|
@keyframes arc-stars{
|
||||||
|
0% { background-position: 0 0, 0 0, 0 0; }
|
||||||
|
100% { background-position: -400px -200px, -320px -160px, -280px -240px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Platforms/orbs bobbing */
|
||||||
|
@keyframes arc-floating{
|
||||||
|
0% { background-position: 10% 27%, 68% 37%, 82% 71%, 0 60%, 0 0, 0 100%; }
|
||||||
|
100% { background-position: 12% 24%, 70% 39%, 80% 74%, 0 61.5%, 0 0, 0 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slow “ground scroll” illusion */
|
||||||
|
@keyframes arc-ground{
|
||||||
|
0% { background-position: 10% 26%, 68% 38%, 82% 72%,
|
||||||
|
0 60%, 0 0, 0 100%; }
|
||||||
|
100% { background-position: 10% 26%, 68% 38%, 82% 72%,
|
||||||
|
-800px 60%, 0 0, -1200px 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection highlight */
|
||||||
|
html[data-theme="arcade"] ::selection{
|
||||||
|
background: rgba(29,225,255,.35);
|
||||||
|
color:#fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top bar
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .topbar-wrap{
|
||||||
|
background: linear-gradient(180deg, var(--arc-bar) 0%, #0a1a3a 100%);
|
||||||
|
border-bottom: 1px solid #0f2e66;
|
||||||
|
box-shadow: 0 8px 26px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .brand-title,
|
||||||
|
html[data-theme="arcade"] .brand .tagline,
|
||||||
|
html[data-theme="arcade"] .version-link{
|
||||||
|
color: var(--arc-bar-ink) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pixel buttons on the topbar (Find/Create/Insights) */
|
||||||
|
html[data-theme="arcade"] .topbar .nav-btn,
|
||||||
|
html[data-theme="arcade"] .topbar .btn,
|
||||||
|
html[data-theme="arcade"] .topbar .btn-success{
|
||||||
|
display:inline-flex !important;
|
||||||
|
align-items:center !important;
|
||||||
|
justify-content:center !important;
|
||||||
|
height:36px !important;
|
||||||
|
padding:6px 14px !important;
|
||||||
|
box-sizing:border-box !important;
|
||||||
|
|
||||||
|
color:#e6fbff !important;
|
||||||
|
background: #0b254e !important;
|
||||||
|
border: 2px solid #2f6bd4 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #071a3a inset,
|
||||||
|
0 2px 0 0 rgba(0,0,0,.45);
|
||||||
|
text-shadow: 0 1px 0 rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .topbar .nav-btn:hover,
|
||||||
|
html[data-theme="arcade"] .topbar .btn:hover{
|
||||||
|
background:#113066 !important;
|
||||||
|
border-color:#4b89ff !important;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .topbar .user-chip{
|
||||||
|
background:#113066 !important;
|
||||||
|
border-color:#3a79ef !important;
|
||||||
|
color:#e6fbff !important;
|
||||||
|
border-radius:999px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards / Panels
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .card,
|
||||||
|
html[data-theme="arcade"] .result-card,
|
||||||
|
html[data-theme="arcade"] .search-form,
|
||||||
|
html[data-theme="arcade"] .form-card,
|
||||||
|
html[data-theme="arcade"] .user-menu,
|
||||||
|
html[data-theme="arcade"] .mobile-menu-inner{
|
||||||
|
background: linear-gradient(180deg, var(--arc-face) 0%, #0b1d42 100%);
|
||||||
|
border: 1px solid var(--arc-border);
|
||||||
|
color: var(--arc-ink);
|
||||||
|
border-radius: var(--arc-radius);
|
||||||
|
box-shadow: 0 14px 28px var(--arc-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headings & muted text */
|
||||||
|
html[data-theme="arcade"] .page-title,
|
||||||
|
html[data-theme="arcade"] .subject-title,
|
||||||
|
html[data-theme="arcade"] .cc-panel-title{
|
||||||
|
color: var(--arc-ink-strong);
|
||||||
|
text-shadow: 0 2px 0 rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .section-label,
|
||||||
|
html[data-theme="arcade"] .meta-label,
|
||||||
|
html[data-theme="arcade"] .page-subtitle,
|
||||||
|
html[data-theme="arcade"] .muted{
|
||||||
|
color: var(--arc-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .search-input,
|
||||||
|
html[data-theme="arcade"] .form-control,
|
||||||
|
html[data-theme="arcade"] .login-input,
|
||||||
|
html[data-theme="arcade"] .tool-input,
|
||||||
|
html[data-theme="arcade"] textarea{
|
||||||
|
background:#0b1b3d;
|
||||||
|
border:1px solid #2a4ea0;
|
||||||
|
color:#dff5ff;
|
||||||
|
border-radius:8px;
|
||||||
|
box-shadow: 0 2px 0 rgba(0,0,0,.35) inset, 0 0 0 2px rgba(16,34,82,.4) inset;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .search-input::placeholder,
|
||||||
|
html[data-theme="arcade"] .form-control::placeholder{
|
||||||
|
color:#8db5cf;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .search-input:focus,
|
||||||
|
html[data-theme="arcade"] .form-control:focus,
|
||||||
|
html[data-theme="arcade"] .login-input:focus,
|
||||||
|
html[data-theme="arcade"] .tool-input:focus{
|
||||||
|
border-color: var(--arc-accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(29,225,255,.26);
|
||||||
|
outline:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Check pills/chips (filters)
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .check-pill{
|
||||||
|
background:#0c214a;
|
||||||
|
border:1px solid #2a4ea0;
|
||||||
|
color:#c9eaff;
|
||||||
|
border-radius:8px;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .check-pill:hover{ background:#103062; }
|
||||||
|
html[data-theme="arcade"] .chip,
|
||||||
|
html[data-theme="arcade"] .chip-link{
|
||||||
|
background:#0d2552;
|
||||||
|
border:1px solid #2a4ea0;
|
||||||
|
color:#c9eaff;
|
||||||
|
border-radius: var(--arc-chip-radius);
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .chip-muted{
|
||||||
|
background:#0e2143;
|
||||||
|
color:#9ec6df;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .btn{
|
||||||
|
background:#0b254e;
|
||||||
|
border:2px solid #2f6bd4;
|
||||||
|
color:#e6fbff;
|
||||||
|
border-radius:10px;
|
||||||
|
padding:8px 14px;
|
||||||
|
text-shadow:0 1px 0 rgba(0,0,0,.6);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #071a3a inset,
|
||||||
|
0 4px 0 0 rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .btn:hover{
|
||||||
|
background:#113066;
|
||||||
|
border-color:#4b89ff;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .btn-primary{
|
||||||
|
background:linear-gradient(180deg,#1de1ff 0%,#13a7be 100%);
|
||||||
|
border-color:#67f0ff;
|
||||||
|
color:#06202c;
|
||||||
|
text-shadow:none;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .btn-primary:hover{
|
||||||
|
background:linear-gradient(180deg,#57ecff 0%,#18bfd8 100%);
|
||||||
|
border-color:#a1f7ff;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .btn-danger{
|
||||||
|
background:#4a0f1e;
|
||||||
|
border-color:#ff738c;
|
||||||
|
color:#ffdbe2;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .btn-danger:hover{
|
||||||
|
background:#5c1426;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .msg{
|
||||||
|
background:#0c1e43;
|
||||||
|
border:1px solid #2a4ea0;
|
||||||
|
color:#e3f6ff;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .msg.info { border-left:4px solid var(--arc-info); background:#0b1f42; }
|
||||||
|
html[data-theme="arcade"] .msg.success { border-left:4px solid var(--arc-success); background:#0b2b3a; }
|
||||||
|
html[data-theme="arcade"] .msg.warning { border-left:4px solid var(--arc-warning); background:#2a240a; }
|
||||||
|
html[data-theme="arcade"] .msg.error { border-left:4px solid var(--arc-danger); background:#2a0d18; }
|
||||||
|
|
||||||
|
/* Links
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] a{
|
||||||
|
color:#7ee9ff;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] a:hover{
|
||||||
|
color:#b6f4ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menus
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .user-menu,
|
||||||
|
html[data-theme="arcade"] .mobile-menu-inner{
|
||||||
|
background:#0a1836;
|
||||||
|
border:1px solid #2a4ea0;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .user-menu .menu-item:hover,
|
||||||
|
html[data-theme="arcade"] .mobile-link:hover{
|
||||||
|
background:#0e2a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] table{ border-collapse:collapse; }
|
||||||
|
html[data-theme="arcade"] th,
|
||||||
|
html[data-theme="arcade"] td{
|
||||||
|
border:1px solid #223f86;
|
||||||
|
background:#0f2147;
|
||||||
|
color:#daf2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlights & Validation
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .mark-hit{
|
||||||
|
background:#fffb91;
|
||||||
|
color:#0a1630;
|
||||||
|
padding:0 2px;
|
||||||
|
border:1px solid #f0e276;
|
||||||
|
border-radius:3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="arcade"] .form-control.scripture-valid,
|
||||||
|
html[data-theme="arcade"] .search-input.scripture-valid{
|
||||||
|
border-color: var(--arc-success);
|
||||||
|
background:#062a1a;
|
||||||
|
box-shadow:0 0 0 3px rgba(34,211,110,.18);
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .form-control.scripture-invalid,
|
||||||
|
html[data-theme="arcade"] .search-input.scripture-invalid{
|
||||||
|
border-color: var(--arc-danger);
|
||||||
|
background:#2a0d18;
|
||||||
|
box-shadow:0 0 0 3px rgba(255,77,109,.18);
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .scripture-pill-invalid{
|
||||||
|
background:#3a0b18;
|
||||||
|
border:1px solid #ff8ea2;
|
||||||
|
color:#ffdbe2;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .scripture-pill-wol{
|
||||||
|
background:#0d263e;
|
||||||
|
border:1px solid #7ee9ff;
|
||||||
|
color:#d9fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Command Center accents
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .settings-console .sec-tile{
|
||||||
|
background:#0b1d42;
|
||||||
|
border:1px solid #2a4ea0;
|
||||||
|
color:#dff5ff;
|
||||||
|
border-radius:12px;
|
||||||
|
box-shadow:0 8px 20px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .settings-console .sec-tile:hover{
|
||||||
|
transform:translateY(-1px);
|
||||||
|
box-shadow:0 12px 26px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .settings-console .sec-icon{
|
||||||
|
background:#0c2a5a;
|
||||||
|
color:#9ddcff;
|
||||||
|
border:1px solid #2a4ea0;
|
||||||
|
}
|
||||||
|
html[data-theme="arcade"] .settings-console .swatch{
|
||||||
|
border:1px solid #2a4ea0;
|
||||||
|
background:linear-gradient(135deg,#0d2856,#18428d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Misc polish
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
html[data-theme="arcade"] .topbar-wrap.is-scrolled{
|
||||||
|
box-shadow:0 10px 28px rgba(0,0,0,.55);
|
||||||
|
border-bottom-color:#0f2e66;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make subject “chips” look like game powerups when highlighted */
|
||||||
|
html[data-theme="arcade"] .chip-subject.chip-hit{
|
||||||
|
background:#ffe28c;
|
||||||
|
border-color:#ffc864;
|
||||||
|
color:#241300;
|
||||||
|
box-shadow:0 2px 0 0 rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
@ -1,63 +0,0 @@
|
|||||||
/* ===============================
|
|
||||||
Theme: Dawn (bolder sunrise look)
|
|
||||||
=============================== */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #fef9f6; /* very light warm base */
|
|
||||||
--card: #ffffff;
|
|
||||||
--ink: #2c2c2c; /* dark gray text */
|
|
||||||
--ink-muted: #6b7280; /* muted gray */
|
|
||||||
|
|
||||||
--border: #e0d9d5;
|
|
||||||
|
|
||||||
--brand: #e67e5c; /* warm orange */
|
|
||||||
--brand-800: #c45d3d; /* darker orange/red */
|
|
||||||
|
|
||||||
--danger: #c53030;
|
|
||||||
|
|
||||||
--btn-bg: #ffffff;
|
|
||||||
--btn-border: #e5dedb;
|
|
||||||
--btn-hover: #f9ece6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----- Background gradient ----- */
|
|
||||||
html[data-theme="dawn"] body {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
#ffd9a0 0%, /* sunrise peach */
|
|
||||||
#ffb6b9 40%, /* rosy pink */
|
|
||||||
#a3d5ff 100% /* clear sky blue */
|
|
||||||
);
|
|
||||||
background-attachment: fixed;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep cards floating nicely */
|
|
||||||
html[data-theme="dawn"] .card,
|
|
||||||
html[data-theme="dawn"] .result-card,
|
|
||||||
html[data-theme="dawn"] .search-form,
|
|
||||||
html[data-theme="dawn"] .form-card {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Translucent topbar over gradient */
|
|
||||||
html[data-theme="dawn"] .topbar-wrap {
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
border-bottom: 1px solid rgba(229,231,235,.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
html[data-theme="dawn"] .btn-primary {
|
|
||||||
background: var(--brand);
|
|
||||||
border-color: var(--brand);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
html[data-theme="dawn"] .btn-primary:hover {
|
|
||||||
background: var(--brand-800);
|
|
||||||
border-color: var(--brand-800);
|
|
||||||
}
|
|
||||||
@ -11,6 +11,7 @@
|
|||||||
<span class="rt-count">Editing: #{{ entry.id }}</span>
|
<span class="rt-count">Editing: #{{ entry.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rt-right">
|
<div class="rt-right">
|
||||||
|
<a class="btn btn-danger" href="{% url 'entry_delete' entry.id %}">Delete</a>
|
||||||
<a class="btn" href="{% url 'entry_view' entry.id %}">Cancel</a>
|
<a class="btn" href="{% url 'entry_view' entry.id %}">Cancel</a>
|
||||||
<button form="entry-edit-form" class="btn btn-primary">Save</button>
|
<button form="entry-edit-form" class="btn btn-primary">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -55,7 +55,6 @@
|
|||||||
<!-- Edit/Delete for staff -->
|
<!-- Edit/Delete for staff -->
|
||||||
{% if user.is_authenticated and user.is_staff %}
|
{% if user.is_authenticated and user.is_staff %}
|
||||||
<a class="btn btn-outline" href="{% url 'entry_edit' entry.id %}">Edit</a>
|
<a class="btn btn-outline" href="{% url 'entry_edit' entry.id %}">Edit</a>
|
||||||
<a class="btn btn-danger" href="{% url 'entry_delete' entry.id %}">Delete</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="release-title">{{ pending_announcement.title|default:"What’s new" }}</h3>
|
<h3 id="release-title">{{ pending_announcement.title|default:"What’s new" }}</h3>
|
||||||
<button class="modal-close" type="button" aria-label="Close" data-dismiss>×</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="prose">{{ pending_announcement.message|linebreaks }}</div>
|
<div class="prose">{{ pending_announcement.message|linebreaks }}</div>
|
||||||
|
|||||||
@ -122,18 +122,16 @@
|
|||||||
<h2 class="cc-panel-title">Release Announcement</h2>
|
<h2 class="cc-panel-title">Release Announcement</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="cc-panel-body">
|
<div class="cc-panel-body">
|
||||||
<form method="post" action="{% url 'announcement_tools' %}" class="cc-form">
|
<form id="announcement-form" method="post" action="{% url 'announcement_tools' %}" class="cc-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if announcement_form %}
|
<label class="cc-label">Title</label>
|
||||||
{{ announcement_form.as_p }}
|
<input type="text" name="title" class="tool-input" id="annc-title" />
|
||||||
{% else %}
|
<label class="cc-label">Message</label>
|
||||||
<label class="cc-label">Title</label>
|
<textarea name="message" rows="5" class="tool-input" id="annc-message" placeholder="What’s new…"></textarea>
|
||||||
<input type="text" name="title" class="tool-input">
|
<!-- Always active -->
|
||||||
<label class="cc-label">Message</label>
|
<input type="hidden" name="is_active" value="on" />
|
||||||
<textarea name="message" rows="5" class="tool-input" placeholder="What’s new…"></textarea>
|
|
||||||
<label class="cc-check"><input type="checkbox" name="is_active" checked> Active</label>
|
|
||||||
{% endif %}
|
|
||||||
<div class="cc-actions">
|
<div class="cc-actions">
|
||||||
|
<button type="button" id="annc-preview-btn" class="btn">Preview</button>
|
||||||
<button class="btn btn-primary">Publish</button>
|
<button class="btn btn-primary">Publish</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -164,10 +162,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Publication Codes (JSON) – superusers only -->
|
||||||
|
<section class="card cc-panel">
|
||||||
|
<div class="cc-panel-head">
|
||||||
|
<div class="cc-kicker">Comms</div>
|
||||||
|
<h2 class="cc-panel-title">Publication Codes (JSON)</h2>
|
||||||
|
</div>
|
||||||
|
<div class="cc-panel-body">
|
||||||
|
<div class="cc-row">
|
||||||
|
<textarea id="pubCodesEditor" class="tool-input" rows="14"
|
||||||
|
style="width:100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="cc-actions">
|
||||||
|
<button type="button" id="pubCodesReloadBtn" class="btn">Reload</button>
|
||||||
|
<button type="button" id="pubCodesSaveBtn" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
<div id="pubCodesStatus" class="tiny" style="margin-top:6px; color:#64748b;"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Announcement Preview Modal -->
|
||||||
|
<div id="annc-preview-modal" style="position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:10000;background:rgba(15,23,42,.35);backdrop-filter:blur(2px);">
|
||||||
|
<div role="dialog" aria-modal="true" aria-labelledby="annc-prev-title" class="card" style="max-width:680px;width:min(92vw,680px);padding:18px 18px 12px;border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,.25);background:#fff;position:relative;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
||||||
|
<div style="font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#64748b;font-weight:700;">Announcement Preview</div>
|
||||||
|
<button type="button" id="annc-prev-close" class="btn btn-secondary">Close</button>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:14px;background:linear-gradient(180deg,#ffffff, #fbfdff);">
|
||||||
|
<div id="annc-prev-title" style="font-weight:800;font-size:18px;color:#0f172a;margin-bottom:6px;"></div>
|
||||||
|
<div id="annc-prev-message" style="font-size:15px;line-height:1.45;color:#111827;white-space:pre-wrap;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast for Clear History -->
|
<!-- Toast for Clear History -->
|
||||||
<div id="toast-clear-history"
|
<div id="toast-clear-history"
|
||||||
style="position:fixed; right:16px; bottom:16px; padding:10px 14px; border-radius:10px;
|
style="position:fixed; right:16px; bottom:16px; padding:10px 14px; border-radius:10px;
|
||||||
@ -193,6 +224,9 @@
|
|||||||
.cc-label{display:block;font-weight:600;margin:8px 0 6px;color:#0f172a}
|
.cc-label{display:block;font-weight:600;margin:8px 0 6px;color:#0f172a}
|
||||||
.cc-actions{display:flex;align-items:center;gap:10px;margin-top:10px}
|
.cc-actions{display:flex;align-items:center;gap:10px;margin-top:10px}
|
||||||
.cc-form .tool-input{border:1px solid var(--border,#d1d5db);border-radius:10px;padding:8px 10px;font-size:14px}
|
.cc-form .tool-input{border:1px solid var(--border,#d1d5db);border-radius:10px;padding:8px 10px;font-size:14px}
|
||||||
|
/* make announcement inputs full-width */
|
||||||
|
.cc-form .tool-input{width:100%;}
|
||||||
|
textarea.tool-input{min-height:140px;resize:vertical;width:100%;}
|
||||||
/* Switch */
|
/* Switch */
|
||||||
.switch{position:relative;display:inline-block;width:46px;height:26px}
|
.switch{position:relative;display:inline-block;width:46px;height:26px}
|
||||||
.switch input{display:none}
|
.switch input{display:none}
|
||||||
@ -283,6 +317,108 @@
|
|||||||
.catch(()=>alert("Could not save the setting. Please try again."));
|
.catch(()=>alert("Could not save the setting. Please try again."));
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// === Announcement Preview ===
|
||||||
|
(function(){
|
||||||
|
const form = document.getElementById('announcement-form');
|
||||||
|
const btn = document.getElementById('annc-preview-btn');
|
||||||
|
const modal = document.getElementById('annc-preview-modal');
|
||||||
|
const close = document.getElementById('annc-prev-close');
|
||||||
|
const titleI = document.getElementById('annc-title');
|
||||||
|
const msgI = document.getElementById('annc-message');
|
||||||
|
const titleO = document.getElementById('annc-prev-title');
|
||||||
|
const msgO = document.getElementById('annc-prev-message');
|
||||||
|
if (!form || !btn || !modal) return;
|
||||||
|
|
||||||
|
function openModal(){
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
function closeModal(){
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.documentElement.style.overflow = '';
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', function(){
|
||||||
|
const t = (titleI?.value || '').trim();
|
||||||
|
const m = (msgI?.value || '').trim();
|
||||||
|
if (!m){
|
||||||
|
alert('Please enter a Message to preview.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
titleO.textContent = t || 'Release Notes';
|
||||||
|
msgO.textContent = m;
|
||||||
|
openModal();
|
||||||
|
});
|
||||||
|
(close||modal).addEventListener('click', (ev)=>{
|
||||||
|
if (ev.target === modal || ev.target === close) closeModal();
|
||||||
|
});
|
||||||
|
window.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && modal.style.display === 'flex') closeModal(); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// === Publication Codes Editor (superuser) ===
|
||||||
|
(function(){
|
||||||
|
const ta = document.getElementById('pubCodesEditor');
|
||||||
|
const btnR = document.getElementById('pubCodesReloadBtn');
|
||||||
|
const btnS = document.getElementById('pubCodesSaveBtn');
|
||||||
|
const stat = document.getElementById('pubCodesStatus');
|
||||||
|
if (!ta || !btnR || !btnS) return; // not superuser or card missing
|
||||||
|
|
||||||
|
function setStatus(msg, ok=true){
|
||||||
|
if (!stat) return;
|
||||||
|
stat.textContent = msg;
|
||||||
|
stat.style.color = ok ? '#64748b' : '#b91c1c';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadJSON(){
|
||||||
|
try{
|
||||||
|
setStatus('Loading…');
|
||||||
|
const r = await fetch('/static/data/wol-pub-codes.v1.json', { cache:'no-store' });
|
||||||
|
if (!r.ok) throw new Error('HTTP '+r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
ta.value = JSON.stringify(data, null, 2);
|
||||||
|
setStatus('Loaded.');
|
||||||
|
}catch(err){
|
||||||
|
setStatus('Failed to load JSON: ' + (err?.message||err), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJSON(){
|
||||||
|
let parsed;
|
||||||
|
try{
|
||||||
|
parsed = JSON.parse(ta.value);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.pub_codes))
|
||||||
|
throw new Error('JSON must be an object with a "pub_codes" array.');
|
||||||
|
}catch(err){
|
||||||
|
setStatus('Invalid JSON: ' + (err?.message||err), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try{
|
||||||
|
setStatus('Saving…');
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('json', JSON.stringify(parsed));
|
||||||
|
const r = await fetch("{% url 'api_update_pub_codes' %}", {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': (document.cookie.match(/(^|;)\s*csrftoken\s*=\s*([^;]+)/)||[]).pop() || '' }
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const txt = await r.text().catch(()=>String(r.status));
|
||||||
|
throw new Error(txt.slice(0,200));
|
||||||
|
}
|
||||||
|
const out = await r.json().catch(()=>({ok:false}));
|
||||||
|
if (!out.ok) throw new Error('Server rejected the update.');
|
||||||
|
setStatus('Saved. ' + out.count + ' codes.');
|
||||||
|
}catch(err){
|
||||||
|
setStatus('Save failed: ' + (err?.message||err), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnR.addEventListener('click', reloadJSON);
|
||||||
|
btnS.addEventListener('click', saveJSON);
|
||||||
|
reloadJSON(); // initial load
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
v4.1.21
|
v4.1.24
|
||||||
Loading…
Reference in New Issue
Block a user