First description
+ Town Hall +diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5624cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ +test-results/*.txt +.venv/ +data/ +*.db diff --git a/Dockerfile b/Dockerfile index dfa7562..31ab55b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,9 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app -COPY README.md . +COPY tests ./tests +COPY pytest.ini . +COPY README.md STRATEGIES.md ./ RUN mkdir -p /data EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index bb6775b..7e2dadd 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,22 @@ Use **Test extraction** to preview up to 20 records before saving. Static server Addresses are geocoded and cached using OpenStreetMap's public Nominatim service. The search field also accepts `latitude,longitude` directly, and every source has optional coordinate fields for deterministic/offline venue positioning. Set a real contact in `NOMINATIM_USER_AGENT` before public/shared use and follow the [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/). For heavier use, configure or self-host a compatible geocoder. +## Tests + +The approved 50-source Illawarra corpus is stored in `tests/fixtures/illawarra_sources.json`. Normal tests are fixture-based and do not access live websites: + +```sh +docker compose --profile test run --rm test +``` + +Detailed per-test outcomes, durations, failure traces, and totals are exported to `test-results/latest.txt` after every run. The file is replaced on the next run and is ignored by Git. + +Run optional live reachability checks separately (results depend on third-party availability and access policies): + +```sh +docker compose --profile test run --rm test pytest -m network tests/integration +``` + ## Operational notes - SQLite data is persisted in the `what2do-data` Docker volume. diff --git a/app/crawler.py b/app/crawler.py index c02aa43..1b96bef 100644 --- a/app/crawler.py +++ b/app/crawler.py @@ -105,15 +105,17 @@ def _parse_date(value: str, source: dict, now: datetime) -> datetime: candidates = [fmt] # Event lists commonly omit a year. Resolve to the next occurrence. for candidate in candidates: - parsed = datetime.strptime(value, candidate).replace(tzinfo=tz) - if "%Y" not in candidate and "%y" not in candidate: - parsed = parsed.replace(year=now.year) + has_year = "%Y" in candidate or "%y" in candidate + parse_value = value if has_year else f"{value} {now.year}" + parse_format = candidate if has_year else f"{candidate} %Y" + parsed = datetime.strptime(parse_value, parse_format).replace(tzinfo=tz) + if not has_year: if parsed < now - timedelta(days=2): parsed = parsed.replace(year=now.year + 1) return parsed if not re.search(r"\b\d{1,4}\b", value): raise ValueError("Date text must contain a numeric day or year") - parsed = date_parser.parse(value, fuzzy=True, default=now.replace(tzinfo=None), tzinfos={"INFO": None, "IPAC": None, "IMAX": None}) + parsed = date_parser.parse(value, fuzzy=True, default=now.replace(tzinfo=None, second=0, microsecond=0), tzinfos={"INFO": None, "IPAC": None, "IMAX": None}) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=tz) if not now.year - 1 <= parsed.year <= now.year + 10: diff --git a/app/main.py b/app/main.py index 710d281..58d3565 100644 --- a/app/main.py +++ b/app/main.py @@ -149,6 +149,8 @@ def crawl_source(source_id: int): if seen: placeholders = ",".join("?" for _ in seen) db.execute(f"DELETE FROM events WHERE source_id=? AND external_key NOT IN ({placeholders})", (source_id, *seen)) + else: + db.execute("DELETE FROM events WHERE source_id=?", (source_id,)) db.execute("UPDATE sources SET last_crawled_at=CURRENT_TIMESTAMP,last_status=?,last_error=NULL WHERE id=?", (f"Found {len(events)} events", source_id)) return len(events) except Exception as exc: @@ -189,6 +191,22 @@ def distance_km(lat1, lon1, lat2, lon2): a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2 return 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) + +def event_overlaps_range(event_start: datetime, event_end: datetime | None, range_start: datetime, range_end: datetime) -> bool: + return event_start < range_end and (event_end or event_start) >= range_start + + +def calendar_day_counts(events: list[dict]) -> dict[str, int]: + counts = {} + for event in events: + covered_day = datetime.fromisoformat(event["day_key"]).date() + final_day = datetime.fromisoformat(event["end_day_key"]).date() + while covered_day <= final_day: + key = covered_day.isoformat() + counts[key] = counts.get(key, 0) + 1 + covered_day += timedelta(days=1) + return counts + @app.get("/", response_class=HTMLResponse) def index(request: Request, location: str = "", latitude: float | None = None, longitude: float | None = None, start: str = "", end: str = "", radius: float = 25, tag: list[str] = Query(default=[]), term: str = ""): point = (latitude, longitude, location) if latitude is not None and longitude is not None else (geocode(location) if location else None) @@ -230,14 +248,7 @@ def index(request: Request, location: str = "", latitude: float | None = None, l rows = [event for event in rows if needle in f"{event['title']} {event['description']} {event['venue_name']} {event['address']}".casefold()] calendar_start = start_at.astimezone() month_days = list(Calendar(firstweekday=0).itermonthdates(calendar_start.year, calendar_start.month)) - day_counts = {} - for event in rows: - covered_day = datetime.fromisoformat(event["day_key"]).date() - final_day = datetime.fromisoformat(event["end_day_key"]).date() - while covered_day <= final_day: - key = covered_day.strftime("%Y-%m-%d") - day_counts[key] = day_counts.get(key, 0) + 1 - covered_day += timedelta(days=1) + day_counts = calendar_day_counts(rows) calendar_days = [{"date": day, "key": day.strftime("%Y-%m-%d"), "count": day_counts.get(day.strftime("%Y-%m-%d"), 0), "in_month": day.month == calendar_start.month} for day in month_days] map_events = [{"id": event["id"], "title": event["title"], "venue": event["venue_name"], "time": event["display_time"], "latitude": event["latitude"], "longitude": event["longitude"]} for event in rows if event["latitude"] is not None] context = {"events": rows, "map_events": map_events, "calendar_days": calendar_days, "calendar_month": calendar_start.strftime("%B %Y"), "event_tags": EVENT_TAGS.keys(), "location": location, "latitude": latitude, "longitude": longitude, "start": start, "end": end, "radius": radius, "selected_tags": selected_tags, "term": term, "geocoded": point} @@ -404,7 +415,7 @@ def sanitize_picker_html(html: str, source_url: str) -> str: if soup.html: soup.html.insert(0, head) else: soup.insert(0, head) style = soup.new_tag("style") - style.string = "html,body{min-height:100%;background:white;color:#17211b} body{margin:12px} *{cursor:crosshair!important} img{max-width:100%;height:auto} .w2d-hover{outline:3px solid #dd5b32!important;outline-offset:2px!important}.w2d-picked{outline:4px solid #234c3c!important;outline-offset:2px!important}" + style.string = "html,body{min-height:100%;background:white;color:#17211b} body{margin:12px} *{cursor:crosshair!important} img{max-width:100%;height:auto} .w2d-hover{outline:3px solid #dd5b32!important;outline-offset:2px!important}.w2d-picked{outline:4px solid #234c3c!important;outline-offset:2px!important}.w2d-container{outline:3px solid #dd5b32!important;outline-offset:2px!important;background-color:#dd5b3214!important}.w2d-field{outline:3px dashed #234c3c!important;outline-offset:1px!important;background-color:#234c3c1a!important}" head.append(style) script = soup.new_tag("script") script.string = PICKER_SCRIPT @@ -412,7 +423,7 @@ def sanitize_picker_html(html: str, source_url: str) -> str: return str(soup) PICKER_SCRIPT = r'''(() => { - let hovered = null, picked = null; + let hovered = null, picked = null, containerSelector = '', fieldSelectors = {}; const esc = value => CSS.escape(value); function stableClasses(element) { return [...element.classList].filter(name => !name.startsWith('w2d-') && !/^elementor-element-[a-f0-9]+$/.test(name) && !/^post-\d+$/.test(name)).slice(0, 3); @@ -481,6 +492,46 @@ PICKER_SCRIPT = r'''(() => { } return best; } + function safeSelect(root, selector) { + try { return selector === ':scope' ? [root] : [...root.querySelectorAll(selector)]; } catch (_) { return []; } + } + function clearHighlights(className) { + document.querySelectorAll(`.${className}`).forEach(node => node.classList.remove(className)); + } + function containers() { + return containerSelector ? safeSelect(document, containerSelector) : []; + } + function preview() { + const nodes = containers(); + const fields = {}; + const inconsistencies = []; + Object.entries(fieldSelectors).forEach(([target, selector]) => { + const counts = nodes.map(node => safeSelect(node, selector).length); + const present = counts.filter(count => count === 1).length; + fields[target] = { selector, present, total: nodes.length, counts }; + if (counts.some(count => count !== 1)) inconsistencies.push({ target, selector, present, total: nodes.length, multiple: counts.filter(count => count > 1).length }); + }); + const rows = nodes.slice(0, 20).map((node, index) => { + const values = { index: index + 1 }; + Object.entries(fieldSelectors).forEach(([target, selector]) => { + const matches = safeSelect(node, selector); + values[target] = matches.length === 1 ? (matches[0].textContent.trim().replace(/\s+/g, ' ').slice(0, 160) || matches[0].getAttribute('href') || '') : ''; + }); + return values; + }); + parent.postMessage({ type: 'what2do-picker-preview', containerSelector, containerCount: nodes.length, fields, inconsistencies, rows }, location.origin); + } + function highlight() { + clearHighlights('w2d-container'); clearHighlights('w2d-field'); + containers().forEach(node => node.classList.add('w2d-container')); + containers().forEach(node => Object.values(fieldSelectors).forEach(selector => safeSelect(node, selector).forEach(match => match.classList.add('w2d-field')))); + } + window.addEventListener('message', event => { + if (event.source !== parent || event.data?.type !== 'what2do-picker-config') return; + containerSelector = event.data.containerSelector || ''; + fieldSelectors = event.data.fieldSelectors || {}; + highlight(); preview(); + }); document.addEventListener('mouseover', event => { event.preventDefault(); event.stopPropagation(); if (hovered) hovered.classList.remove('w2d-hover'); @@ -493,7 +544,9 @@ PICKER_SCRIPT = r'''(() => { picked = event.target; picked.classList.add('w2d-picked'); const selector = selectorFor(picked); const repeated = repeatedAncestor(picked); - parent.postMessage({ type: 'what2do-selector', selector, selectorCount: document.querySelectorAll(selector).length, generalizedSelector: simpleSelector(picked), generalizedCount: document.querySelectorAll(simpleSelector(picked)).length, containerSelector: repeated?.selector || '', containerCount: repeated?.count || 0, relativeSelector: repeated ? relativeSelector(picked, repeated.node) : selector, tag: picked.tagName.toLowerCase(), text: picked.textContent.trim().slice(0, 160), href: picked.getAttribute('href') }, location.origin); + const selectedContainer = containerSelector ? picked.closest(containerSelector) : null; + const relative = selectedContainer ? relativeSelector(picked, selectedContainer) : (repeated ? relativeSelector(picked, repeated.node) : selector); + parent.postMessage({ type: 'what2do-selector', selector, selectorCount: document.querySelectorAll(selector).length, generalizedSelector: simpleSelector(picked), generalizedCount: document.querySelectorAll(simpleSelector(picked)).length, containerSelector: repeated?.selector || '', containerCount: repeated?.count || 0, withinConfiguredContainer: Boolean(selectedContainer), relativeSelector: relative, tag: picked.tagName.toLowerCase(), text: picked.textContent.trim().slice(0, 160), href: picked.getAttribute('href') }, location.origin); }, true); })();''' diff --git a/app/static/source.js b/app/static/source.js index 613f70e..da29cf4 100644 --- a/app/static/source.js +++ b/app/static/source.js @@ -7,10 +7,37 @@ const pickedText = document.querySelector('#picked-text'); const pickedCount = document.querySelector('#picked-count'); const pickedContainer = document.querySelector('#picked-container'); const pickedRelative = document.querySelector('#picked-relative'); -const pickerTarget = document.querySelector('#picker-target'); const applySelector = document.querySelector('#apply-selector'); -const applyPattern = document.querySelector('#apply-pattern'); +const resetPicker = document.querySelector('#reset-picker'); +const pickerSteps = document.querySelector('#picker-steps'); +const pickerWorkspace = document.querySelector('#picker-workspace'); +const pickerContainerCount = document.querySelector('#picker-container-count'); +const pickerWarning = document.querySelector('#picker-warning'); +const pickerPreviewEmpty = document.querySelector('#picker-preview-empty'); +const pickerPreviewRows = document.querySelector('#picker-preview-rows'); +const guidedTargets = ['item_selector', 'title_selector', 'date_selector', 'time_selector']; +const targetLabels = { item_selector: 'event container', title_selector: 'title', date_selector: 'date or range', time_selector: 'time' }; let currentSelection = null; +let pickerStep = 0; +let containerSelector = ''; +let fieldSelectors = {}; + +function currentTarget() { return guidedTargets[Math.min(pickerStep, guidedTargets.length - 1)]; } +function renderPickerSteps() { + pickerSteps?.querySelectorAll('li').forEach((step, index) => { + step.classList.toggle('active', index === pickerStep); + step.classList.toggle('complete', index < pickerStep); + }); +} +function sendPickerConfig() { + pickerFrame?.contentWindow?.postMessage({ type: 'what2do-picker-config', containerSelector, fieldSelectors }, location.origin); +} +function resetPickerState() { + currentSelection = null; pickerStep = 0; containerSelector = ''; fieldSelectors = {}; + pickerSelection.hidden = true; pickerWarning.hidden = true; pickerPreviewRows.replaceChildren(); pickerPreviewEmpty.hidden = false; + pickerContainerCount.textContent = ''; pickerStatus.textContent = 'Step 1: click one complete event container.'; + renderPickerSteps(); sendPickerConfig(); +} if (loadPicker && pickerFrame) loadPicker.addEventListener('click', async () => { const url = document.querySelector('input[name="url"]')?.value.trim(); @@ -21,22 +48,31 @@ if (loadPicker && pickerFrame) loadPicker.addEventListener('click', async () => const response = await fetch('/picker/snapshots', { method: 'POST', body }); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Unable to create preview'); - pickerFrame.src = result.preview_url; pickerFrame.hidden = false; - pickerStatus.textContent = 'Hover to highlight, then click an element.'; + pickerFrame.src = result.preview_url; pickerWorkspace.hidden = false; pickerSteps.hidden = false; resetPicker.hidden = false; + resetPickerState(); } catch (error) { pickerStatus.textContent = error.message; } finally { loadPicker.disabled = false; } }); +if (resetPicker) resetPicker.addEventListener('click', resetPickerState); +if (pickerFrame) pickerFrame.addEventListener('load', sendPickerConfig); window.addEventListener('message', event => { - if (!pickerFrame || event.source !== pickerFrame.contentWindow || event.data?.type !== 'what2do-selector') return; - currentSelection = event.data; - pickedSelector.textContent = event.data.selector; - pickedCount.textContent = `${event.data.selectorCount} exact match${event.data.selectorCount === 1 ? '' : 'es'}; ${event.data.generalizedCount} similar elements`; - pickedContainer.textContent = event.data.containerSelector || 'No repeated ancestor detected'; - pickedRelative.textContent = event.data.containerSelector ? `${event.data.containerCount} containers · child: ${event.data.relativeSelector}` : ''; - pickedText.textContent = event.data.text || event.data.tag; - applyPattern.disabled = !event.data.containerSelector; - pickerSelection.hidden = false; + if (!pickerFrame || event.source !== pickerFrame.contentWindow) return; + if (event.data?.type === 'what2do-selector') { + if (pickerStep >= guidedTargets.length) return; + currentSelection = event.data; + const target = currentTarget(); + pickedSelector.textContent = target === 'item_selector' ? (event.data.containerSelector || event.data.generalizedSelector) : event.data.relativeSelector; + pickedCount.textContent = target === 'item_selector' ? `${event.data.containerCount || event.data.generalizedCount} matching containers` : 'Corresponding field selector'; + pickedContainer.textContent = target === 'item_selector' ? (event.data.containerSelector || event.data.generalizedSelector) : containerSelector; + pickedRelative.textContent = target === 'item_selector' ? 'All matches will be highlighted.' : event.data.relativeSelector; + pickedText.textContent = event.data.text || event.data.tag; + applySelector.disabled = target !== 'item_selector' && !event.data.withinConfiguredContainer; + applySelector.textContent = `Use as ${targetLabels[target]}`; + pickerSelection.hidden = false; + pickerStatus.textContent = applySelector.disabled ? 'Pick a field inside one of the highlighted event containers.' : `Review this ${targetLabels[target]}, then confirm.`; + } + if (event.data?.type === 'what2do-picker-preview') renderExtractionPreview(event.data); }); function updateSelectorField(target, selector) { @@ -51,18 +87,39 @@ function updateSelectorField(target, selector) { } catch (_) {} } +function renderExtractionPreview(data) { + pickerContainerCount.textContent = data.containerCount ? `${data.containerCount} containers` : ''; + pickerPreviewRows.replaceChildren(); + data.rows.forEach(row => { + const item = document.createElement('div'); + item.className = 'picker-preview-row'; + const title = document.createElement('strong'); title.textContent = row.title_selector || `Container ${row.index}`; item.append(title); + const details = [row.date_selector, row.time_selector].filter(Boolean).join(' · '); + if (details) { const meta = document.createElement('span'); meta.textContent = details; item.append(meta); } + pickerPreviewRows.append(item); + }); + pickerPreviewEmpty.hidden = data.rows.length > 0; + if (data.inconsistencies.length) { + pickerWarning.textContent = data.inconsistencies.map(issue => `${targetLabels[issue.target] || issue.target}: exactly one match in ${issue.present}/${issue.total} containers${issue.multiple ? `; multiple matches in ${issue.multiple}` : ''}`).join(' '); + pickerWarning.hidden = false; + } else pickerWarning.hidden = true; +} + if (applySelector) applySelector.addEventListener('click', () => { if (!currentSelection) return; - updateSelectorField(pickerTarget.value, currentSelection.selector); - pickerStatus.textContent = `Applied exact selector to ${pickerTarget.options[pickerTarget.selectedIndex].text}.`; -}); - -if (applyPattern) applyPattern.addEventListener('click', () => { - if (!currentSelection?.containerSelector) return; - const target = pickerTarget.value; - updateSelectorField('item_selector', currentSelection.containerSelector); - updateSelectorField(target, target === 'item_selector' ? currentSelection.containerSelector : currentSelection.relativeSelector); - pickerStatus.textContent = `Matched ${currentSelection.containerCount} event containers and applied a relative ${pickerTarget.options[pickerTarget.selectedIndex].text.toLowerCase()} selector.`; + const target = currentTarget(); + if (target === 'item_selector') { + containerSelector = currentSelection.containerSelector || currentSelection.generalizedSelector; + if (!containerSelector) return; + updateSelectorField(target, containerSelector); + } else { + if (!currentSelection.withinConfiguredContainer) return; + fieldSelectors[target] = currentSelection.relativeSelector; + updateSelectorField(target, currentSelection.relativeSelector); + } + pickerStep += 1; currentSelection = null; pickerSelection.hidden = true; sendPickerConfig(); renderPickerSteps(); + if (pickerStep < guidedTargets.length) pickerStatus.textContent = `Step ${pickerStep + 1}: pick the ${targetLabels[currentTarget()]} inside a highlighted container.`; + else pickerStatus.textContent = 'Guided setup complete. Review the preview and any consistency warnings.'; }); const strategyTemplates = { diff --git a/app/static/style.css b/app/static/style.css index 853741b..c051759 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1 +1 @@ -:root{--ink:#17211b;--muted:#657068;--paper:#f4f0e7;--card:#fffdf7;--accent:#dd5b32;--green:#234c3c;--line:#d9d4c8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.5 system-ui,-apple-system,sans-serif}a{color:var(--green)}header{height:68px;padding:0 max(24px,calc((100% - 1120px)/2));display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}nav{display:flex;gap:24px}nav a{text-decoration:none;font-weight:650}.brand{font:bold 24px Georgia,serif;text-decoration:none;color:var(--ink)}main{max-width:1120px;margin:auto;padding:48px 24px 90px}h1,h2,h3{font-family:Georgia,serif;line-height:1.1;margin:.25em 0}h1{font-size:clamp(2.5rem,6vw,5.5rem);max-width:850px}h2{font-size:2rem}.hero{padding:45px 0}.hero p:not(.eyebrow){font-size:1.2rem;color:var(--muted)}.eyebrow{font-size:.75rem;font-weight:800;letter-spacing:.16em;color:var(--accent)}.card{background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 5px 18px #17211b0a}.search{display:grid;grid-template-columns:2fr 1.4fr 1.4fr .8fr auto;gap:12px;padding:18px;align-items:end}label{display:flex;flex-direction:column;gap:6px;font-size:.8rem;font-weight:750;position:relative}input,select{width:100%;border:1px solid var(--line);background:white;border-radius:7px;padding:12px;color:var(--ink);font:inherit}.suffix{position:absolute;right:12px;bottom:12px;color:var(--muted)}button,.button{border:0;border-radius:7px;padding:12px 18px;background:var(--green);color:white;text-decoration:none;font-weight:750;font:inherit;cursor:pointer;display:inline-block}.secondary{background:transparent;color:var(--green);border:1px solid var(--green)}.danger{background:#9d302c}.section-head{display:flex;justify-content:space-between;align-items:end;margin:52px 0 22px;gap:20px}.section-head h1{font-size:3.5rem}.section-head p{max-width:650px;color:var(--muted)}.events,.source-grid{display:grid;gap:14px}.event{padding:24px}.event h3{font-size:1.55rem}.event p{color:var(--muted)}.date{color:var(--accent);font-weight:800}.event-foot,.source-title,.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:22px}.pill{font-size:.75rem;background:#eee9de;padding:5px 9px;border-radius:99px}.good{background:#dce9df;color:#285039}.empty{text-align:center;padding:60px}.source{padding:24px}.source-stats{display:flex;gap:30px;flex-wrap:wrap;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:15px 0;margin-top:18px}.source-stats>div{display:grid}.source-stats small{color:var(--muted)}.admin-events{display:grid;gap:12px}.admin-event{padding:22px;display:flex;align-items:center;justify-content:space-between;gap:20px}.admin-event.is-hidden{opacity:.6;border-style:dashed}.event-admin-form{padding:25px;display:grid;gap:18px}textarea{width:100%;border:1px solid var(--line);background:white;border-radius:7px;padding:12px;color:var(--ink);font:inherit;resize:vertical}.source-title{margin-top:0}.source-title a{word-break:break-all;font-size:.85rem}dl{display:flex;gap:40px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:15px 0}dt{color:var(--muted);font-size:.75rem}dd{margin:2px 0;font-weight:700}.actions{justify-content:flex-start}.actions form{margin:0}.notice{padding:12px 16px;border-radius:7px}.error{background:#f4deda;color:#792a27}.success{background:#dce9df;color:#285039}.url-detect-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.url-detect-row .detect-submit{width:auto;white-space:nowrap;background:var(--green);color:white;border:0;font-weight:700}.address-field{position:relative}.picker-panel{grid-column:1/-1;padding:25px;display:grid;gap:14px}.picker-controls{display:flex;align-items:end;gap:10px}.picker-controls .field{min-width:220px}.selector-picker{width:100%;height:620px;border:1px solid var(--line);border-radius:8px;background:white}.picker-selection{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:start;gap:14px;padding:14px;background:#eee9de;border-radius:8px}.picker-selection>div{display:grid;gap:5px}.picker-selection code{overflow-wrap:anywhere}.picker-selection span{color:var(--muted);font-size:.8rem;overflow:hidden;text-overflow:ellipsis}.picker-actions{display:flex!important;flex-direction:row;flex-wrap:wrap}.picker-actions button{padding:9px 12px}.rules-details{grid-column:1/-1;overflow:hidden}.rules-details>summary{cursor:pointer;padding:22px;font:700 1.5rem Georgia,serif;display:flex;justify-content:space-between;gap:15px}.rules-details>summary span{font:400 .8rem system-ui;color:var(--muted)}.rules-details[open]>summary{border-bottom:1px solid var(--line)}.sticky-actions input[type=submit]{width:auto;background:var(--green);color:white;border:0;font-weight:700;cursor:pointer}.sticky-actions input.primary{background:var(--accent)}.config-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start}.form-section{padding:25px;display:grid;gap:17px}.form-heading{font:700 2rem/1.1 Georgia,serif;border-bottom:1px solid var(--line);padding-bottom:14px}.form-title{font:700 3.5rem/1.1 Georgia,serif;margin:.25em 0}.muted{color:var(--muted)}.field{display:flex;flex-direction:column;gap:6px;font-size:.8rem;font-weight:750}.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}.check{flex-direction:row;align-items:center}.check input{width:auto}.form-section small{font-weight:400;color:var(--muted)}.sticky-actions{grid-column:1/-1;display:flex;justify-content:flex-end;gap:10px}.primary{background:var(--accent)}.preview{margin-top:24px;padding:25px}.preview div{display:flex;justify-content:space-between;padding:10px;border-top:1px solid var(--line);gap:20px}.preview span{font-size:.8rem;color:var(--muted);white-space:nowrap}footer{text-align:center;border-top:1px solid var(--line);padding:30px;color:var(--muted);font-size:.8rem}.hero-title{font:700 clamp(2.5rem,6vw,5.5rem)/1.05 Georgia,serif;max-width:850px;margin:.12em 0}.hero-copy{font-size:1.2rem;color:var(--muted)}.section-title{font:700 2rem/1.1 Georgia,serif;margin-top:5px}.location-field{position:relative}.location-options{position:absolute;z-index:20;top:100%;left:0;right:0;background:white;border:1px solid var(--line);border-radius:8px;box-shadow:0 12px 30px #17211b26;overflow:hidden}.location-options button{display:block;width:100%;text-align:left;border-radius:0;background:white;color:var(--ink);font-size:.85rem;border-bottom:1px solid var(--line)}.location-options button:hover{background:#eee9de}.location-message{padding:12px;color:var(--muted);font-size:.8rem;font-weight:400}.tag-field{position:relative}.tag-toggle{width:100%;text-align:left;background:white;color:var(--ink);border:1px solid var(--line);font-weight:400}.tag-options{position:absolute;z-index:19;top:100%;left:0;right:0;min-width:190px;background:white;border:1px solid var(--line);border-radius:8px;box-shadow:0 12px 30px #17211b26;padding:8px}.tag-option{display:flex;flex-direction:row;align-items:center;gap:8px;padding:7px;border-radius:5px;cursor:pointer}.tag-option:hover{background:#eee9de}.tag-option input{width:auto}.range-field{position:relative}.view-tabs{display:flex;gap:8px;margin-bottom:16px}.tab{background:transparent;color:var(--green);border:1px solid var(--green)}.tab.active{background:var(--green);color:white}.view-panel{display:none}.view-panel.active{display:block}.search-filters{grid-template-columns:1.8fr 1.35fr 1fr .65fr auto;position:relative}.date-calendar{overflow:hidden;margin-bottom:18px}.calendar{overflow:hidden}.calendar-heading{font:700 1.6rem Georgia,serif;padding:22px}.calendar-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 22px 14px;color:var(--muted);font-size:.8rem}.clear-day{background:transparent;color:var(--green);border:1px solid var(--green);padding:7px 10px;font-size:.75rem}.weekdays,.calendar-grid{display:grid;grid-template-columns:repeat(7,1fr)}.weekdays{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:9px 0;color:var(--muted);font-size:.75rem;font-weight:700;text-align:center}.weekdays>input{display:block;min-width:0;border:0;border-radius:0;padding:0;background:transparent;color:var(--muted);font-size:inherit;font-weight:inherit;text-align:center;opacity:1}.calendar-day{min-width:0;min-height:82px;padding:8px;border:0;border-right:1px solid var(--line);border-bottom:1px solid var(--line);border-radius:0;background:transparent;color:var(--ink);text-align:left;display:flex;flex-direction:column;justify-content:space-between}.calendar-day:nth-child(7n){border-right:0}.calendar-day.outside{opacity:.35}.calendar-day.has-events{background:#fff4e8}.calendar-day.has-events:hover,.calendar-day.selected{background:#f4d6c6}.calendar-day.range-start,.calendar-day.range-end{box-shadow:inset 0 0 0 2px var(--accent)}.calendar-day b{font-size:.7rem;color:var(--accent)}.day-events{display:grid;gap:10px;margin-top:18px}.tag-list{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}[hidden]{display:none!important}.event-row{padding:18px 22px;text-decoration:none;color:var(--ink);display:flex;justify-content:space-between;align-items:center}.event-row:hover{border-color:var(--accent)}.event-title{font:700 1.25rem Georgia,serif;margin:5px 0}.map{height:580px}.back-link{display:inline-block;margin-bottom:34px}.event-detail{display:grid;grid-template-columns:1.1fr .9fr;gap:45px;align-items:start}.detail-title{font:700 clamp(2.5rem,5vw,4.8rem)/1.05 Georgia,serif;margin:10px 0}.detail-time{font:700 2rem Georgia,serif;color:var(--accent)}.description{font-size:1.1rem}.detail-card{padding:22px;margin:30px 0}.detail-field{display:grid;grid-template-columns:90px minmax(0,1fr);align-items:start;gap:14px;padding:13px 0;border-bottom:1px solid var(--line)}.detail-field:last-child{border-bottom:0}.detail-label{font-size:.75rem;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.08em}.detail-value{font-weight:700;overflow-wrap:anywhere}.hint{font-size:.75rem;color:var(--muted);font-weight:400}.detail-actions{display:flex;gap:10px}.detail-map{height:520px;position:sticky;top:24px}@media(max-width:800px){.search,.search-filters,.config-grid,.event-detail,.url-detect-row,.picker-selection{grid-template-columns:1fr}.picker-controls{align-items:stretch;flex-direction:column}.selector-picker{height:480px}.two{grid-template-columns:1fr}.sticky-actions{grid-column:auto}.section-head{align-items:flex-start;flex-direction:column}.source-title,.event-foot,.admin-event{align-items:flex-start;flex-direction:column}dl{flex-wrap:wrap}.search button{width:100%}nav{gap:10px;font-size:.85rem}.calendar-day{min-height:72px;padding:7px}.calendar-day b{font-size:.6rem}.weekdays{font-size:.6rem}.map,.detail-map{height:420px}.event-detail{gap:25px}} +:root{--ink:#17211b;--muted:#657068;--paper:#f4f0e7;--card:#fffdf7;--accent:#dd5b32;--green:#234c3c;--line:#d9d4c8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.5 system-ui,-apple-system,sans-serif}a{color:var(--green)}header{height:68px;padding:0 max(24px,calc((100% - 1120px)/2));display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}nav{display:flex;gap:24px}nav a{text-decoration:none;font-weight:650}.brand{font:bold 24px Georgia,serif;text-decoration:none;color:var(--ink)}main{max-width:1120px;margin:auto;padding:48px 24px 90px}h1,h2,h3{font-family:Georgia,serif;line-height:1.1;margin:.25em 0}h1{font-size:clamp(2.5rem,6vw,5.5rem);max-width:850px}h2{font-size:2rem}.hero{padding:45px 0}.hero p:not(.eyebrow){font-size:1.2rem;color:var(--muted)}.eyebrow{font-size:.75rem;font-weight:800;letter-spacing:.16em;color:var(--accent)}.card{background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 5px 18px #17211b0a}.search{display:grid;grid-template-columns:2fr 1.4fr 1.4fr .8fr auto;gap:12px;padding:18px;align-items:end}label{display:flex;flex-direction:column;gap:6px;font-size:.8rem;font-weight:750;position:relative}input,select{width:100%;border:1px solid var(--line);background:white;border-radius:7px;padding:12px;color:var(--ink);font:inherit}.suffix{position:absolute;right:12px;bottom:12px;color:var(--muted)}button,.button{border:0;border-radius:7px;padding:12px 18px;background:var(--green);color:white;text-decoration:none;font-weight:750;font:inherit;cursor:pointer;display:inline-block}.secondary{background:transparent;color:var(--green);border:1px solid var(--green)}.danger{background:#9d302c}.section-head{display:flex;justify-content:space-between;align-items:end;margin:52px 0 22px;gap:20px}.section-head h1{font-size:3.5rem}.section-head p{max-width:650px;color:var(--muted)}.events,.source-grid{display:grid;gap:14px}.event{padding:24px}.event h3{font-size:1.55rem}.event p{color:var(--muted)}.date{color:var(--accent);font-weight:800}.event-foot,.source-title,.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:22px}.pill{font-size:.75rem;background:#eee9de;padding:5px 9px;border-radius:99px}.good{background:#dce9df;color:#285039}.empty{text-align:center;padding:60px}.source{padding:24px}.source-stats{display:flex;gap:30px;flex-wrap:wrap;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:15px 0;margin-top:18px}.source-stats>div{display:grid}.source-stats small{color:var(--muted)}.admin-events{display:grid;gap:12px}.admin-event{padding:22px;display:flex;align-items:center;justify-content:space-between;gap:20px}.admin-event.is-hidden{opacity:.6;border-style:dashed}.event-admin-form{padding:25px;display:grid;gap:18px}textarea{width:100%;border:1px solid var(--line);background:white;border-radius:7px;padding:12px;color:var(--ink);font:inherit;resize:vertical}.source-title{margin-top:0}.source-title a{word-break:break-all;font-size:.85rem}dl{display:flex;gap:40px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:15px 0}dt{color:var(--muted);font-size:.75rem}dd{margin:2px 0;font-weight:700}.actions{justify-content:flex-start}.actions form{margin:0}.notice{padding:12px 16px;border-radius:7px}.error{background:#f4deda;color:#792a27}.success{background:#dce9df;color:#285039}.url-detect-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.url-detect-row .detect-submit{width:auto;white-space:nowrap;background:var(--green);color:white;border:0;font-weight:700}.address-field{position:relative}.picker-panel{grid-column:1/-1;padding:25px;display:grid;gap:14px}.picker-controls{display:flex;align-items:end;gap:10px}.picker-controls .field{min-width:220px}.picker-steps{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:0;padding:0;list-style:none;counter-reset:picker-step}.picker-steps li{padding:10px;border:1px solid var(--line);border-radius:7px;color:var(--muted);font-size:.8rem}.picker-steps li:before{counter-increment:picker-step;content:counter(picker-step) ". ";font-weight:800}.picker-steps li.active{border-color:var(--accent);color:var(--ink);background:#fff4e8}.picker-steps li.complete{border-color:var(--green);color:var(--green);background:#dce9df}.picker-steps li span{font-weight:400}.picker-workspace{display:grid;grid-template-columns:minmax(0,2fr) minmax(260px,1fr);gap:14px;align-items:start}.selector-picker{width:100%;height:620px;border:1px solid var(--line);border-radius:8px;background:white}.picker-preview{height:620px;overflow:auto;border:1px solid var(--line);border-radius:8px;background:white;padding:14px}.picker-preview-head{display:flex;justify-content:space-between;gap:10px;padding-bottom:12px;border-bottom:1px solid var(--line)}.picker-preview-head span{color:var(--muted);font-size:.8rem}.picker-preview .notice{margin:12px 0;font-size:.8rem}.picker-preview-rows{display:grid}.picker-preview-row{display:grid;gap:3px;padding:11px 0;border-bottom:1px solid var(--line)}.picker-preview-row span{color:var(--muted);font-size:.8rem}.picker-selection{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:start;gap:14px;padding:14px;background:#eee9de;border-radius:8px}.picker-selection>div{display:grid;gap:5px}.picker-selection code{overflow-wrap:anywhere}.picker-selection span{color:var(--muted);font-size:.8rem;overflow:hidden;text-overflow:ellipsis}.picker-actions{display:flex!important;flex-direction:row;flex-wrap:wrap}.picker-actions button{padding:9px 12px}.rules-details{grid-column:1/-1;overflow:hidden}.rules-details>summary{cursor:pointer;padding:22px;font:700 1.5rem Georgia,serif;display:flex;justify-content:space-between;gap:15px}.rules-details>summary span{font:400 .8rem system-ui;color:var(--muted)}.rules-details[open]>summary{border-bottom:1px solid var(--line)}.sticky-actions input[type=submit]{width:auto;background:var(--green);color:white;border:0;font-weight:700;cursor:pointer}.sticky-actions input.primary{background:var(--accent)}.config-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start}.form-section{padding:25px;display:grid;gap:17px}.form-heading{font:700 2rem/1.1 Georgia,serif;border-bottom:1px solid var(--line);padding-bottom:14px}.form-title{font:700 3.5rem/1.1 Georgia,serif;margin:.25em 0}.muted{color:var(--muted)}.field{display:flex;flex-direction:column;gap:6px;font-size:.8rem;font-weight:750}.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}.check{flex-direction:row;align-items:center}.check input{width:auto}.form-section small{font-weight:400;color:var(--muted)}.sticky-actions{grid-column:1/-1;display:flex;justify-content:flex-end;gap:10px}.primary{background:var(--accent)}.preview{margin-top:24px;padding:25px}.preview div{display:flex;justify-content:space-between;padding:10px;border-top:1px solid var(--line);gap:20px}.preview span{font-size:.8rem;color:var(--muted);white-space:nowrap}footer{text-align:center;border-top:1px solid var(--line);padding:30px;color:var(--muted);font-size:.8rem}.hero-title{font:700 clamp(2.5rem,6vw,5.5rem)/1.05 Georgia,serif;max-width:850px;margin:.12em 0}.hero-copy{font-size:1.2rem;color:var(--muted)}.section-title{font:700 2rem/1.1 Georgia,serif;margin-top:5px}.location-field{position:relative}.location-options{position:absolute;z-index:20;top:100%;left:0;right:0;background:white;border:1px solid var(--line);border-radius:8px;box-shadow:0 12px 30px #17211b26;overflow:hidden}.location-options button{display:block;width:100%;text-align:left;border-radius:0;background:white;color:var(--ink);font-size:.85rem;border-bottom:1px solid var(--line)}.location-options button:hover{background:#eee9de}.location-message{padding:12px;color:var(--muted);font-size:.8rem;font-weight:400}.tag-field{position:relative}.tag-toggle{width:100%;text-align:left;background:white;color:var(--ink);border:1px solid var(--line);font-weight:400}.tag-options{position:absolute;z-index:19;top:100%;left:0;right:0;min-width:190px;background:white;border:1px solid var(--line);border-radius:8px;box-shadow:0 12px 30px #17211b26;padding:8px}.tag-option{display:flex;flex-direction:row;align-items:center;gap:8px;padding:7px;border-radius:5px;cursor:pointer}.tag-option:hover{background:#eee9de}.tag-option input{width:auto}.range-field{position:relative}.view-tabs{display:flex;gap:8px;margin-bottom:16px}.tab{background:transparent;color:var(--green);border:1px solid var(--green)}.tab.active{background:var(--green);color:white}.view-panel{display:none}.view-panel.active{display:block}.search-filters{grid-template-columns:1.8fr 1.35fr 1fr .65fr auto;position:relative}.date-calendar{overflow:hidden;margin-bottom:18px}.calendar{overflow:hidden}.calendar-heading{font:700 1.6rem Georgia,serif;padding:22px}.calendar-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 22px 14px;color:var(--muted);font-size:.8rem}.clear-day{background:transparent;color:var(--green);border:1px solid var(--green);padding:7px 10px;font-size:.75rem}.weekdays,.calendar-grid{display:grid;grid-template-columns:repeat(7,1fr)}.weekdays{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:9px 0;color:var(--muted);font-size:.75rem;font-weight:700;text-align:center}.weekdays>input{display:block;min-width:0;border:0;border-radius:0;padding:0;background:transparent;color:var(--muted);font-size:inherit;font-weight:inherit;text-align:center;opacity:1}.calendar-day{min-width:0;min-height:82px;padding:8px;border:0;border-right:1px solid var(--line);border-bottom:1px solid var(--line);border-radius:0;background:transparent;color:var(--ink);text-align:left;display:flex;flex-direction:column;justify-content:space-between}.calendar-day:nth-child(7n){border-right:0}.calendar-day.outside{opacity:.35}.calendar-day.has-events{background:#fff4e8}.calendar-day.has-events:hover,.calendar-day.selected{background:#f4d6c6}.calendar-day.range-start,.calendar-day.range-end{box-shadow:inset 0 0 0 2px var(--accent)}.calendar-day b{font-size:.7rem;color:var(--accent)}.day-events{display:grid;gap:10px;margin-top:18px}.tag-list{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}[hidden]{display:none!important}.event-row{padding:18px 22px;text-decoration:none;color:var(--ink);display:flex;justify-content:space-between;align-items:center}.event-row:hover{border-color:var(--accent)}.event-title{font:700 1.25rem Georgia,serif;margin:5px 0}.map{height:580px}.back-link{display:inline-block;margin-bottom:34px}.event-detail{display:grid;grid-template-columns:1.1fr .9fr;gap:45px;align-items:start}.detail-title{font:700 clamp(2.5rem,5vw,4.8rem)/1.05 Georgia,serif;margin:10px 0}.detail-time{font:700 2rem Georgia,serif;color:var(--accent)}.description{font-size:1.1rem}.detail-card{padding:22px;margin:30px 0}.detail-field{display:grid;grid-template-columns:90px minmax(0,1fr);align-items:start;gap:14px;padding:13px 0;border-bottom:1px solid var(--line)}.detail-field:last-child{border-bottom:0}.detail-label{font-size:.75rem;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.08em}.detail-value{font-weight:700;overflow-wrap:anywhere}.hint{font-size:.75rem;color:var(--muted);font-weight:400}.detail-actions{display:flex;gap:10px}.detail-map{height:520px;position:sticky;top:24px}@media(max-width:800px){.search,.search-filters,.config-grid,.event-detail,.url-detect-row,.picker-selection,.picker-workspace{grid-template-columns:1fr}.picker-controls{align-items:stretch;flex-direction:column}.picker-steps{grid-template-columns:1fr 1fr}.selector-picker{height:480px}.picker-preview{height:auto;max-height:480px}.two{grid-template-columns:1fr}.sticky-actions{grid-column:auto}.section-head{align-items:flex-start;flex-direction:column}.source-title,.event-foot,.admin-event{align-items:flex-start;flex-direction:column}dl{flex-wrap:wrap}.search button{width:100%}nav{gap:10px;font-size:.85rem}.calendar-day{min-height:72px;padding:7px}.calendar-day b{font-size:.6rem}.weekdays{font-size:.6rem}.map,.detail-map{height:420px}.event-detail{gap:25px}} diff --git a/app/templates/source_form.html b/app/templates/source_form.html index 9935236..82975c3 100644 --- a/app/templates/source_form.html +++ b/app/templates/source_form.html @@ -12,7 +12,7 @@
First description
+ Town Hall +Second description
+ Art Gallery +
+