This commit is contained in:
Joshua Laymon 2025-08-13 00:05:43 -05:00
parent 22a4f8c09a
commit a266dd9ea2
13 changed files with 100 additions and 114 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -1,4 +1,3 @@
version: "3.9"
services: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine

BIN
imports/.DS_Store vendored

Binary file not shown.

View File

@ -1 +0,0 @@
Place your seed file as illustrations_seed.csv here if you want auto-import on first boot.

BIN
web/.DS_Store vendored

Binary file not shown.

View File

@ -7,4 +7,4 @@ COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/ COPY . /app/
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
CMD ["bash","-lc","/app/entrypoint.sh"] CMD ["bash","-lc","/app/entrypoint.sh"]

View File

@ -1,13 +1,13 @@
import csv, io, re import csv, io, re
from dateutil import parser as dateparser from dateutil import parser as dateparser
from datetime import date from datetime import date
from .models import Entry, ScriptureRef
SCR_REF_RE = re.compile(r"""^\s*([1-3]?\s*[A-Za-z\.]+)\s+(\d+)(?::(\d+))?(?:\s*[-–—]\s*(\d+)(?::(\d+))?)?\s*$""", re.VERBOSE) SCR_REF_RE = re.compile(r"""^\s*([1-3]?\s*[A-Za-z\.]+)\s+(\d+)(?::(\d+))?(?:\s*[-–—]\s*(\d+)(?::(\d+))?)?\s*$""", re.VERBOSE)
BOOK_ALIASES={'matt':'Matthew','mt':'Matthew','jn':'John','john':'John','lk':'Luke','luke':'Luke','ps':'Psalms'} BOOK_ALIASES={'matt':'Matthew','mt':'Matthew','jn':'John','john':'John','lk':'Luke','luke':'Luke','ps':'Psalms'}
def normalize_book(s): def normalize_book(s):
b = re.sub(r"[\.\s]","", s).lower() import re as _re
b = _re.sub(r"[.\s]","", s).lower()
return BOOK_ALIASES.get(b, s.strip()) return BOOK_ALIASES.get(b, s.strip())
def parse_scripture(s): def parse_scripture(s):
@ -35,10 +35,11 @@ def import_csv_bytes(b: bytes, dry_run=True):
if missing: raise ValueError(f"Missing required headers: {missing}") if missing: raise ValueError(f"Missing required headers: {missing}")
report={"rows":0,"inserted":0,"updated":0,"skipped":0,"errors":[],"scripture_parsed":0,"scripture_failed":0} report={"rows":0,"inserted":0,"updated":0,"skipped":0,"errors":[],"scripture_parsed":0,"scripture_failed":0}
rows=list(reader); report["rows"]=len(rows) rows=list(reader); report["rows"]=len(rows)
from core.models import Entry, ScriptureRef
for row in rows: for row in rows:
try: try:
code=(row.get("code") or "").strip() code=(row.get("code") or "").strip()
talk=row.get("talk number"); talk=row.get("talk number")
try: talk=int(talk) if str(talk).strip() else None try: talk=int(talk) if str(talk).strip() else None
except: talk=None except: talk=None
data=dict( data=dict(
@ -75,17 +76,12 @@ def import_csv_bytes(b: bytes, dry_run=True):
report["skipped"]+=1; report["errors"].append(str(e)) report["skipped"]+=1; report["errors"].append(str(e))
return report return report
def wildcard_to_like(q:str)->str: # Tokenization with quoted phrases; wildcards tolerated but removed for icontains
return q.replace("%","\%").replace("_","\_").replace("*","%").replace("?","_") _QUOTED_OR_WORD = re.compile(r'"([^"]+)"|(\S+)')
def terms(q: str):
def terms(q:str): return [t for t in q.split() if t.strip()] out = []
for m in _QUOTED_OR_WORD.finditer(q or ""):
def month_buckets_last_12(today: date): token = (m.group(1) or m.group(2) or "").replace("*","").replace("?","").strip()
months=[]; y=today.year; m=today.month if token:
for i in range(12): out.append(token)
mm=m-i; yy=y return out
while mm<=0: mm+=12; yy-=1
start=date(yy,mm,1)
end=date(yy+1,1,1) if mm==12 else date(yy,mm+1,1)
months.append((f"{yy}-{mm:02d}", start, end))
return list(reversed(months))

View File

@ -6,10 +6,9 @@ from django.contrib import messages
from django.db.models import Q from django.db.models import Q
from datetime import date, timedelta from datetime import date, timedelta
import csv import csv
from .models import Entry from .models import Entry
from .forms import ImportForm, EntryForm from .forms import ImportForm, EntryForm
from .utils import import_csv_bytes, wildcard_to_like, terms, month_buckets_last_12 from .utils import import_csv_bytes, terms
FIELD_ORDER=[ FIELD_ORDER=[
("subject","Subject"), ("subject","Subject"),
@ -34,33 +33,52 @@ def login_view(request):
ctx["error"]="Invalid credentials" ctx["error"]="Invalid credentials"
return render(request,"login.html",ctx) return render(request,"login.html",ctx)
def entry_context(entry, result_ids):
count = len(result_ids or [])
if entry and result_ids and entry.id in result_ids:
position = result_ids.index(entry.id) + 1
else:
position = 1
subject_list = [t.strip() for t in (entry.subject or "").split(",") if t.strip()]
scripture_list = [t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()]
return {"entry":entry,"locked":True,"position":position,"count":count,
"subject_list":subject_list,"scripture_list":scripture_list}
@login_required @login_required
def search_page(request): def search_page(request):
default_fields={"subject":True,"illustration":True,"application":True, default_fields={"subject":True,"illustration":True,"application":True,
"scripture_raw":False,"source":False,"talk_title":False,"talk_number":False,"entry_code":False} "scripture_raw":False,"source":False,"talk_title":False,"talk_number":False,"entry_code":False}
if request.method=="GET": # robust checkbox parsing: present only if checked
selected={k:(request.GET.get(k,"on" if v else "")!="") for k,v in default_fields.items()} form_submitted = ("q" in request.GET) or any(k in request.GET for k in default_fields)
field_options=[{"name":k,"label":label,"checked":bool(selected.get(k))} for k,label in FIELD_ORDER] if form_submitted:
q=request.GET.get("q","").strip() selected={k:(k in request.GET) for k in default_fields}
if q: else:
term_list=terms(q) selected=default_fields.copy()
qs=Entry.objects.all() field_options=[{"name":k,"label":label,"checked":bool(selected.get(k))} for k,label in FIELD_ORDER]
fields=[f for f,sel in selected.items() if sel]
for t in term_list: q=request.GET.get("q","").strip()
clause=Q() if q:
for f in fields: tokens = terms(q) # supports quoted phrases
clause |= Q(**{f+"__icontains": t.replace("*","").replace("?","")}) fields = [f for f, sel in selected.items() if sel]
qs=qs.filter(clause) qs = Entry.objects.all()
ids=list(qs.order_by("-date_added","-id").values_list("id", flat=True)) # AND across tokens, OR across selected fields
request.session["result_ids"]=ids for t in tokens:
if ids: clause = Q()
entry=Entry.objects.get(pk=ids[0]) for f in fields:
subject_list=[t.strip() for t in (entry.subject or "").split(",") if t.strip()] clause |= Q(**{f + "__icontains": t})
scripture_list=[t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()] qs = qs.filter(clause)
return render(request,"entry_view.html",{"entry":entry,"subject_list":subject_list,"scripture_list":scripture_list,"locked":True,"position":1,"count":len(ids),"from_search":True}) ids = list(qs.order_by("-date_added","-id").values_list("id", flat=True))
request.session["result_ids"]=ids
count=len(ids)
if count:
entry=Entry.objects.get(pk=ids[0])
ctx=entry_context(entry, ids); ctx.update({"from_search":True})
return render(request,"entry_view.html",ctx)
total=Entry.objects.count() total=Entry.objects.count()
return render(request,"search.html",{"q":q,"selected":selected,"field_options":field_options,"total":total}) return render(request,"search.html",{"q":q,"selected":selected,"field_options":field_options,"total":total,"ran_search":True,"result_count":0})
return render(request,"search.html",{"selected":default_fields,"field_options":[{"name":k,"label":lbl,"checked":default_fields.get(k,False)} for k,lbl in FIELD_ORDER]})
total=Entry.objects.count()
return render(request,"search.html",{"q":q,"selected":selected,"field_options":field_options,"total":total,"ran_search":False})
@login_required @login_required
def nav_next(request): def nav_next(request):
@ -68,9 +86,7 @@ def nav_next(request):
if not ids: return redirect("search") if not ids: return redirect("search")
idx=int(request.GET.get("i","0")); idx=min(idx+1, len(ids)-1) idx=int(request.GET.get("i","0")); idx=min(idx+1, len(ids)-1)
entry=get_object_or_404(Entry, pk=ids[idx]) entry=get_object_or_404(Entry, pk=ids[idx])
subject_list=[t.strip() for t in (entry.subject or "").split(",") if t.strip()] return render(request,"entry_view.html",entry_context(entry, ids))
scripture_list=[t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()]
return render(request,"entry_view.html",{"entry":entry,"subject_list":subject_list,"scripture_list":scripture_list,"locked":True,"position":idx+1,"count":len(ids)})
@login_required @login_required
def nav_prev(request): def nav_prev(request):
@ -78,21 +94,17 @@ def nav_prev(request):
if not ids: return redirect("search") if not ids: return redirect("search")
idx=int(request.GET.get("i","0")); idx=max(idx-1, 0) idx=int(request.GET.get("i","0")); idx=max(idx-1, 0)
entry=get_object_or_404(Entry, pk=ids[idx]) entry=get_object_or_404(Entry, pk=ids[idx])
subject_list=[t.strip() for t in (entry.subject or "").split(",") if t.strip()] return render(request,"entry_view.html",entry_context(entry, ids))
scripture_list=[t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()]
return render(request,"entry_view.html",{"entry":entry,"subject_list":subject_list,"scripture_list":scripture_list,"locked":True,"position":idx+1,"count":len(ids)})
@login_required @login_required
def entry_view(request, entry_id): def entry_view(request, entry_id):
ids=request.session.get("result_ids",[])
entry=get_object_or_404(Entry, pk=entry_id) entry=get_object_or_404(Entry, pk=entry_id)
ids=request.session.get("result_ids",[]); count=len(ids) return render(request,"entry_view.html",entry_context(entry, ids))
position=ids.index(entry.id)+1 if entry.id in ids else 1
subject_list=[t.strip() for t in (entry.subject or "").split(",") if t.strip()]
scripture_list=[t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()]
return render(request,"entry_view.html",{"entry":entry,"subject_list":subject_list,"scripture_list":scripture_list,"locked":True,"position":position,"count":count})
@login_required @login_required
def entry_edit(request, entry_id): def entry_edit(request, entry_id):
ids=request.session.get("result_ids",[])
entry=get_object_or_404(Entry, pk=entry_id) entry=get_object_or_404(Entry, pk=entry_id)
if request.method=="POST": if request.method=="POST":
form=EntryForm(request.POST) form=EntryForm(request.POST)
@ -101,8 +113,8 @@ def entry_edit(request, entry_id):
entry.save(); messages.success(request,"Entry saved."); return redirect("entry_view", entry_id=entry.id) entry.save(); messages.success(request,"Entry saved."); return redirect("entry_view", entry_id=entry.id)
else: else:
form=EntryForm(initial={"subject":entry.subject,"illustration":entry.illustration,"application":entry.application,"scripture_raw":entry.scripture_raw,"source":entry.source,"talk_number":entry.talk_number,"talk_title":entry.talk_title,"entry_code":entry.entry_code,"date_added":entry.date_added,"date_edited":entry.date_edited}) form=EntryForm(initial={"subject":entry.subject,"illustration":entry.illustration,"application":entry.application,"scripture_raw":entry.scripture_raw,"source":entry.source,"talk_number":entry.talk_number,"talk_title":entry.talk_title,"entry_code":entry.entry_code,"date_added":entry.date_added,"date_edited":entry.date_edited})
ids=request.session.get("result_ids",[]); count=len(ids); position=ids.index(entry.id)+1 if entry.id in ids else 1 ctx={"entry":entry,"form":form}; ctx.update(entry_context(entry, ids))
return render(request,"entry_edit.html",{"entry":entry,"form":form,"position":position,"count":count}) return render(request,"entry_edit.html",ctx)
@login_required @login_required
def entry_delete(request, entry_id): def entry_delete(request, entry_id):
@ -146,10 +158,21 @@ def stats_page(request):
today=date.today() today=date.today()
last30=Entry.objects.filter(date_added__gte=today - timedelta(days=30)).count() last30=Entry.objects.filter(date_added__gte=today - timedelta(days=30)).count()
last365=Entry.objects.filter(date_added__gte=today - timedelta(days=365)).count() last365=Entry.objects.filter(date_added__gte=today - timedelta(days=365)).count()
buckets=month_buckets_last_12(today) # last 12 months
series=[(label, Entry.objects.filter(date_added__gte=start, date_added__lt=end).count()) for label,start,end in buckets] months=[]; y=today.year; m=today.month
peak=max((v for _,v in series), default=1) for i in range(12):
heights=[(label, value, 8 + int((value/peak)*100) if peak else 8) for label,value in series] mm=m-i; yy=y
while mm<=0: mm+=12; yy-=1
from datetime import date as _d
start=_d(yy,mm,1)
end=_d(yy+1,1,1) if mm==12 else _d(yy,mm+1,1)
label=f"{yy}-{mm:02d}"
months.append((label,start,end))
months=list(reversed(months))
series=[(label, Entry.objects.filter(date_added__gte=start, date_added__lt=end).count()) for label,start,end in months]
peak = max((v for _, v in series), default=1)
heights=[(label, value, 8 + int((value/peak)*100) if peak else 8) for label, value in series]
# top subjects
from collections import Counter from collections import Counter
counts=Counter() counts=Counter()
for subj in Entry.objects.exclude(subject="").values_list("subject", flat=True): for subj in Entry.objects.exclude(subject="").values_list("subject", flat=True):

0
web/entrypoint.sh Normal file → Executable file
View File

View File

@ -4,7 +4,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY","dev-insecure") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY","dev-insecure")
DEBUG = os.getenv("DJANGO_DEBUG","False") == "True" DEBUG = os.getenv("DJANGO_DEBUG","False") == "True"
ALLOWED_HOSTS = [h.strip() for h in os.getenv("DJANGO_ALLOWED_HOSTS","*").split(",") if h.strip()] ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS","*").split(",")
CSRF_TRUSTED_ORIGINS = [x.strip() for x in os.getenv("CSRF_TRUSTED_ORIGINS","").split(",") if x.strip()] CSRF_TRUSTED_ORIGINS = [x.strip() for x in os.getenv("CSRF_TRUSTED_ORIGINS","").split(",") if x.strip()]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

View File

@ -1,45 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{% block title %}Illustrations DB{% endblock %}</title>
<title>{% block title %}Illustrations DB{% endblock %}</title> <style>:root { --blue:#1f6cd8; --light:#f6f8fb; --panel:#ffffff; --line:#e5e9f2; --text:#1a1a1a; } body { margin:0; font-family: system-ui,-apple-system,Segoe UI,Roboto,Noto Sans,Ubuntu,Cantarell,Helvetica,Arial,sans-serif; background:var(--light); color:var(--text);} .topbar{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;background:#fff;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}.brand{font-weight:700;color:var(--blue);font-size:18px}.menu{display:flex;align-items:center;gap:8px}.menu a{text-decoration:none;color:var(--blue);padding:6px 10px;border-radius:6px;border:1px solid transparent}.menu a:hover{background:#eef4ff}.container{max-width:1100px;margin:24px auto;padding:0 16px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 8px 20px rgba(0,0,0,0.04);padding:18px}.btn{display:inline-block;padding:10px 14px;border-radius:8px;border:1px solid var(--line);background:#fff;color:#0d1b2a;text-decoration:none;cursor:pointer}.btn.primary{background:var(--blue);color:#fff;border-color:var(--blue)}.btn.danger{background:#e11d48;color:#fff;border-color:#e11d48}.flash{margin:16px 0;padding:12px;border-radius:10px;background:#eaf2ff;color:#0b3d91} input[type=file],input[type=text],input[type=password],textarea,select{width:100%;padding:10px;border:1px solid var(--line);border-radius:10px;background:#fff} label{font-size:14px;color:#333;display:block;margin-bottom:6px}.row{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media (max-width:800px){.row{grid-template-columns:1fr}}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}@media (max-width:1000px){.cards{grid-template-columns:repeat(2,1fr)}}@media (max-width:640px){.cards{grid-template-columns:1fr}}.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 6px 14px rgba(0,0,0,0.03)}.card:hover{box-shadow:0 10px 24px rgba(0,0,0,0.05);transform:translateY(-1px);transition:all .15s ease}.stat{padding:16px;text-align:center;border:1px solid var(--line);border-radius:12px;background:#fff}.badge{display:inline-block;padding:2px 8px;border-radius:6px;border:1px solid var(--line);background:#f7faff;color:#1f4bb6;font-size:12px}.chips{display:flex;flex-wrap:wrap;gap:6px}.chip{padding:2px 8px;border-radius:6px;border:1px solid var(--line);background:#f1f5ff;color:#1a45a0;font-size:12px}.toolbar{display:flex;align-items:center;gap:8px;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap}.spacer{height:8px}.small{font-size:12px;color:#444} h1{margin:0 0 12px 0;font-size:24px}h2{margin:0 0 12px 0;font-size:18px}</style>
<style>
:root { --blue:#1f6cd8; --light:#f6f8fb; --panel:#ffffff; --line:#e5e9f2; --text:#1a1a1a; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica, Arial, sans-serif; background:var(--light); color:var(--text); }
.topbar { display:flex; align-items:center; justify-content:space-between; padding:14px 18px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:10; }
.brand { font-weight:700; color:var(--blue); font-size: 18px; }
.menu { display:flex; align-items:center; gap:8px; }
.menu a { text-decoration:none; color:var(--blue); padding:6px 10px; border-radius:6px; border:1px solid transparent; }
.menu a:hover { background:#eef4ff; }
.container { max-width: 1100px; margin: 24px auto; padding: 0 16px; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; box-shadow: 0 8px 20px rgba(0,0,0,0.04); padding: 18px; }
.btn { display:inline-block; padding:10px 14px; border-radius:8px; border:1px solid var(--line); background:#fff; color:#0d1b2a; text-decoration:none; cursor:pointer; }
.btn.primary { background:var(--blue); color:#fff; border-color:var(--blue); }
.btn.danger { background:#e11d48; color:#fff; border-color:#e11d48; }
.flash { margin: 16px 0; padding: 12px; border-radius:10px; background:#eaf2ff; color:#0b3d91; }
input[type=file], input[type=text], input[type=password], textarea, select { width:100%; padding:10px; border:1px solid var(--line); border-radius:10px; background:#fff; }
label { font-size:14px; color:#333; display:block; margin-bottom:6px; }
.row { display:grid; grid-template-columns: 1fr 1fr; gap:16px; }
@media (max-width: 800px){ .row{ grid-template-columns: 1fr; } }
.cards { display:grid; grid-template-columns: repeat(3, 1fr); gap:16px; }
@media (max-width: 1000px){ .cards{ grid-template-columns: repeat(2, 1fr);} }
@media (max-width: 640px){ .cards{ grid-template-columns: 1fr;} }
.card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:16px; box-shadow: 0 6px 14px rgba(0,0,0,0.03); }
.card:hover { box-shadow: 0 10px 24px rgba(0,0,0,0.05); transform: translateY(-1px); transition: all .15s ease; }
.stat { padding:16px; text-align:center; border:1px solid var(--line); border-radius:12px; background:#fff; }
.badge { display:inline-block; padding:2px 8px; border-radius:6px; border:1px solid var(--line); background:#f7faff; color:#1f4bb6; font-size:12px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; }
.chip { padding:2px 8px; border-radius:6px; border:1px solid var(--line); background:#f1f5ff; color:#1a45a0; font-size:12px; }
.toolbar { display:flex; align-items:center; gap:8px; justify-content:space-between; margin-bottom:12px; flex-wrap:wrap; }
.spacer { height:8px; }
.small { font-size:12px; color:#444; }
h1 { margin:0 0 12px 0; font-size:24px; }
h2 { margin:0 0 12px 0; font-size:18px; }
</style>
</head> </head>
<body> <body>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<div class="topbar"> <div class="topbar">
<div class="brand">Illustrations Database</div> <div class="brand">Illustrations Database</div>
<div class="menu"> <div class="menu">
@ -50,10 +17,10 @@ h2 { margin:0 0 12px 0; font-size:18px; }
<a href="/logout/">Logout</a> <a href="/logout/">Logout</a>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="container"> <div class="container">
{% for message in messages %}<div class="flash">{{ message }}</div>{% endfor %} {% for message in messages %}<div class="flash">{{ message }}</div>{% endfor %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</body> </body>
</html> </html>

View File

@ -1,14 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sign in - Illustrations DB{% endblock %} {% block title %}Sign in - Illustrations DB{% endblock %}
{% block content %} {% block content %}
<div class="panel" style="max-width:420px; margin: 80px auto;"> <div class="panel" style="max-width:420px; margin:80px auto;">
<h2 style="margin-top:0; color: var(--blue);">Sign in</h2> <h2 style="margin-top:0; color: var(--blue);">Sign in</h2>
{% if error %}<div class="flash">{{ error }}</div>{% endif %} {% if error %}<div class="flash">{{ error }}</div>{% endif %}
<form method="post">{% csrf_token %} <form method="post">{% csrf_token %}
<label>Username</label> <label>Username</label><input type="text" name="username" required />
<input type="text" name="username" required /> <label style="margin-top:12px;">Password</label><input type="password" name="password" required />
<label style="margin-top:12px;">Password</label>
<input type="password" name="password" required />
<div style="margin-top:16px; display:flex; gap:10px; justify-content:flex-end;"> <div style="margin-top:16px; display:flex; gap:10px; justify-content:flex-end;">
<a class="btn" href="#">Cancel</a> <a class="btn" href="#">Cancel</a>
<button class="btn primary" type="submit">Sign in</button> <button class="btn primary" type="submit">Sign in</button>

View File

@ -3,8 +3,14 @@
{% block content %} {% block content %}
<div class="panel"> <div class="panel">
<h1>Search</h1> <h1>Search</h1>
<p class="small"><strong>How to search:</strong> Type words or phrases, use wildcards, and choose which fields to search. {% if ran_search %}
<br/>Examples: <code>faith</code> finds entries containing “faith”; <code>*faith*</code> uses wildcards; <code>"exact phrase"</code> matches the phrase; multiple words are AND (e.g., <code>faith loyalty</code>).</p> {% if result_count and result_count > 0 %}
<div class="flash">Found <strong>{{ result_count }}</strong> matching entries. Opening the first match…</div>
{% else %}
<div class="flash">No results found for <code>{{ q }}</code> in the selected fields.</div>
{% endif %}
{% endif %}
<p class="small"><strong>How to search:</strong> Type words or phrases (quotes for exact), use wildcards, and choose which fields to search.</p>
<form method="get" action="/search/"> <form method="get" action="/search/">
<div class="row"> <div class="row">
<div> <div>
@ -15,9 +21,7 @@
<label>Fields to search</label> <label>Fields to search</label>
<div class="chips"> <div class="chips">
{% for opt in field_options %} {% for opt in field_options %}
<label class="badge"> <label class="badge"><input type="checkbox" name="{{ opt.name }}" {% if opt.checked %}checked{% endif %}/> {{ opt.label }}</label>
<input type="checkbox" name="{{ opt.name }}" {% if opt.checked %}checked{% endif %}/> {{ opt.label }}
</label>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>