Add Phase 8 features: analytics, reporting, health check
- Add pageviews table and /api/v1/analytics/pageview endpoint - Abuse reporting API already existed at /api/v1/reports - Add health_check.py script for monitoring - ChatGPT additions: report modal UI, analytics tracking JS 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
096c0739d8
commit
1ac3054aaa
|
|
@ -0,0 +1,21 @@
|
||||||
|
# code-review
|
||||||
|
|
||||||
|
Review code for issues, risks, and improvements.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
code-review --language python --focus bugs,security --severity medium < app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
- `--language`: Programming language or `auto`.
|
||||||
|
- `--focus`: Comma-separated focus areas (e.g., `bugs,security,performance`).
|
||||||
|
- `--severity`: Minimum severity level (`low`, `medium`, `high`).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat main.go | code-review --language go --focus performance --severity high
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
name: code-review
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "Review code for issues and improvements"
|
||||||
|
category: code-analysis
|
||||||
|
tags:
|
||||||
|
- code
|
||||||
|
- review
|
||||||
|
- quality
|
||||||
|
arguments:
|
||||||
|
- name: language
|
||||||
|
flag: "--language"
|
||||||
|
default: "auto"
|
||||||
|
description: "Programming language (e.g., python, javascript, go)"
|
||||||
|
- name: focus
|
||||||
|
flag: "--focus"
|
||||||
|
default: "bugs,security,performance"
|
||||||
|
description: "Comma-separated focus areas"
|
||||||
|
- name: severity
|
||||||
|
flag: "--severity"
|
||||||
|
default: "medium"
|
||||||
|
description: "Minimum severity: low, medium, high"
|
||||||
|
steps:
|
||||||
|
- type: prompt
|
||||||
|
provider: "claude"
|
||||||
|
template: |
|
||||||
|
You are a senior code reviewer.
|
||||||
|
Language: {language}
|
||||||
|
Focus areas: {focus}
|
||||||
|
Minimum severity: {severity}
|
||||||
|
|
||||||
|
Review the following code. Identify issues, risks, and improvements.
|
||||||
|
Provide a concise, prioritized list with suggestions.
|
||||||
|
|
||||||
|
Code:
|
||||||
|
{input}
|
||||||
|
output_var: review
|
||||||
|
output: "{review}"
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# explain
|
||||||
|
|
||||||
|
Explain code or concepts clearly with adjustable audience and format.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
explain --audience beginner --format step-by-step < snippet.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
- `--audience`: Audience level (`beginner`, `general`, `expert`).
|
||||||
|
- `--format`: Output format (`paragraph`, `bullet`, `step-by-step`).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "Explain recursion" | explain --audience general --format bullet
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
name: explain
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "Explain code or concepts clearly"
|
||||||
|
category: education
|
||||||
|
tags:
|
||||||
|
- explanation
|
||||||
|
- learning
|
||||||
|
- code
|
||||||
|
arguments:
|
||||||
|
- name: audience
|
||||||
|
flag: "--audience"
|
||||||
|
default: "general"
|
||||||
|
description: "Audience level: beginner, general, expert"
|
||||||
|
- name: format
|
||||||
|
flag: "--format"
|
||||||
|
default: "paragraph"
|
||||||
|
description: "Output format: paragraph, bullet, or step-by-step"
|
||||||
|
steps:
|
||||||
|
- type: prompt
|
||||||
|
provider: "claude"
|
||||||
|
template: |
|
||||||
|
You are a clear and concise explainer.
|
||||||
|
Audience: {audience}
|
||||||
|
Format: {format}
|
||||||
|
|
||||||
|
Explain the following content:
|
||||||
|
{input}
|
||||||
|
output_var: explanation
|
||||||
|
output: "{explanation}"
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# summarize
|
||||||
|
|
||||||
|
Summarize text using AI. Designed for quick overviews, briefs, or executive summaries.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
summarize --max-length 200 --style neutral < input.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
- `--max-length`: Maximum length of the summary in words. Default: `200`.
|
||||||
|
- `--style`: Summary style. Options: `neutral`, `bullet`, `executive`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat report.txt | summarize --max-length 120 --style executive
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
name: summarize
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "Summarize text using AI"
|
||||||
|
category: text-processing
|
||||||
|
tags:
|
||||||
|
- summarization
|
||||||
|
- text
|
||||||
|
- ai
|
||||||
|
arguments:
|
||||||
|
- name: max_length
|
||||||
|
flag: "--max-length"
|
||||||
|
default: "200"
|
||||||
|
description: "Maximum length of the summary in words"
|
||||||
|
- name: style
|
||||||
|
flag: "--style"
|
||||||
|
default: "neutral"
|
||||||
|
description: "Summary style: neutral, bullet, or executive"
|
||||||
|
steps:
|
||||||
|
- type: prompt
|
||||||
|
provider: "claude"
|
||||||
|
template: |
|
||||||
|
You are a helpful assistant that summarizes text.
|
||||||
|
Style: {style}
|
||||||
|
Max length: {max_length} words.
|
||||||
|
|
||||||
|
Summarize the following text:
|
||||||
|
{input}
|
||||||
|
output_var: summary
|
||||||
|
output: "{summary}"
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# translate
|
||||||
|
|
||||||
|
Translate text between languages using AI.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
translate --from auto --to en --tone neutral < input.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
- `--from`: Source language (e.g., `en`, `es`, `fr`) or `auto`.
|
||||||
|
- `--to`: Target language (e.g., `en`, `es`, `fr`).
|
||||||
|
- `--tone`: Translation tone: `neutral`, `formal`, `casual`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "Hola mundo" | translate --from es --to en --tone formal
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
name: translate
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "Translate text between languages using AI"
|
||||||
|
category: text-processing
|
||||||
|
tags:
|
||||||
|
- translation
|
||||||
|
- localization
|
||||||
|
- text
|
||||||
|
arguments:
|
||||||
|
- name: from_language
|
||||||
|
flag: "--from"
|
||||||
|
default: "auto"
|
||||||
|
description: "Source language (e.g., en, es, fr) or auto"
|
||||||
|
- name: to_language
|
||||||
|
flag: "--to"
|
||||||
|
default: "en"
|
||||||
|
description: "Target language (e.g., en, es, fr)"
|
||||||
|
- name: tone
|
||||||
|
flag: "--tone"
|
||||||
|
default: "neutral"
|
||||||
|
description: "Tone: neutral, formal, or casual"
|
||||||
|
steps:
|
||||||
|
- type: prompt
|
||||||
|
provider: "claude"
|
||||||
|
template: |
|
||||||
|
You are a translation assistant.
|
||||||
|
Translate from: {from_language}
|
||||||
|
Translate to: {to_language}
|
||||||
|
Tone: {tone}
|
||||||
|
|
||||||
|
Translate the following text:
|
||||||
|
{input}
|
||||||
|
output_var: translation
|
||||||
|
output: "{translation}"
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Basic health check for the SmartTools Registry API and DB."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
|
||||||
|
|
||||||
|
def _add_src_to_path() -> None:
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(repo_root / "src"))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_registry_url() -> str:
|
||||||
|
from smarttools.config import get_registry_url
|
||||||
|
|
||||||
|
return get_registry_url().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db_path() -> Path:
|
||||||
|
from smarttools.registry.db import get_db_path
|
||||||
|
|
||||||
|
return get_db_path()
|
||||||
|
|
||||||
|
|
||||||
|
def check_api(url: str) -> str | None:
|
||||||
|
endpoint = f"{url}/tools?limit=1"
|
||||||
|
try:
|
||||||
|
req = Request(endpoint, headers={"Accept": "application/json"})
|
||||||
|
with urlopen(req, timeout=5) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return f"API unhealthy: status {resp.status}"
|
||||||
|
payload = json.loads(resp.read().decode("utf-8"))
|
||||||
|
if "data" not in payload:
|
||||||
|
return "API unhealthy: missing data field"
|
||||||
|
except HTTPError as exc:
|
||||||
|
return f"API unhealthy: HTTP {exc.code}"
|
||||||
|
except URLError as exc:
|
||||||
|
return f"API unhealthy: {exc.reason}"
|
||||||
|
except Exception as exc:
|
||||||
|
return f"API unhealthy: {exc}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_db(db_path: Path) -> str | None:
|
||||||
|
if not db_path.exists():
|
||||||
|
return f"DB missing: {db_path}"
|
||||||
|
if not db_path.is_file():
|
||||||
|
return f"DB path is not a file: {db_path}"
|
||||||
|
try:
|
||||||
|
with db_path.open("rb"):
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
return f"DB not readable: {exc}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_last_sync(db_path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT MAX(processed_at) FROM webhook_log").fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
return f"DB error: {exc}"
|
||||||
|
|
||||||
|
if not row or not row[0]:
|
||||||
|
return "No webhook sync recorded in webhook_log"
|
||||||
|
|
||||||
|
raw = str(row[0]).replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
last_sync = datetime.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
return f"Invalid sync timestamp: {row[0]}"
|
||||||
|
|
||||||
|
if datetime.utcnow() - last_sync > timedelta(hours=1):
|
||||||
|
return f"Last sync too old: {last_sync.isoformat()}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
_add_src_to_path()
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
registry_url = _get_registry_url()
|
||||||
|
db_path = _get_db_path()
|
||||||
|
|
||||||
|
api_error = check_api(registry_url)
|
||||||
|
if api_error:
|
||||||
|
errors.append(api_error)
|
||||||
|
|
||||||
|
db_error = check_db(db_path)
|
||||||
|
if db_error:
|
||||||
|
errors.append(db_error)
|
||||||
|
else:
|
||||||
|
sync_error = check_last_sync(db_path)
|
||||||
|
if sync_error:
|
||||||
|
errors.append(sync_error)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
print(err, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("Health check OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1394,6 +1394,36 @@ def create_app() -> Flask:
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@app.route("/api/v1/analytics/pageview", methods=["POST"])
|
||||||
|
def track_pageview() -> Response:
|
||||||
|
"""Track a page view (privacy-friendly, no cookies)."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
path = data.get("path", "").strip()
|
||||||
|
|
||||||
|
if not path or len(path) > 500:
|
||||||
|
return jsonify({"data": {"tracked": False}})
|
||||||
|
|
||||||
|
# Hash the IP for privacy (don't store raw IP)
|
||||||
|
ip_hash = hashlib.sha256(
|
||||||
|
(request.remote_addr or "unknown").encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
|
||||||
|
referrer = request.headers.get("Referer", "")[:500] if request.headers.get("Referer") else None
|
||||||
|
user_agent = request.headers.get("User-Agent", "")[:500] if request.headers.get("User-Agent") else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
g.db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO pageviews (path, referrer, user_agent, ip_hash)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
[path, referrer, user_agent, ip_hash],
|
||||||
|
)
|
||||||
|
g.db.commit()
|
||||||
|
return jsonify({"data": {"tracked": True}})
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"data": {"tracked": False}})
|
||||||
|
|
||||||
@app.route("/api/v1/webhook/gitea", methods=["POST"])
|
@app.route("/api/v1/webhook/gitea", methods=["POST"])
|
||||||
def webhook_gitea() -> Response:
|
def webhook_gitea() -> Response:
|
||||||
if request.content_length and request.content_length > MAX_BODY_BYTES:
|
if request.content_length and request.content_length > MAX_BODY_BYTES:
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,18 @@ CREATE INDEX IF NOT EXISTS idx_featured_tools_placement ON featured_tools(placem
|
||||||
CREATE INDEX IF NOT EXISTS idx_featured_contributors_placement ON featured_contributors(placement, status);
|
CREATE INDEX IF NOT EXISTS idx_featured_contributors_placement ON featured_contributors(placement, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_content_pages_type ON content_pages(content_type, published);
|
CREATE INDEX IF NOT EXISTS idx_content_pages_type ON content_pages(content_type, published);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pageviews (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
referrer TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_hash TEXT,
|
||||||
|
viewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pageviews_path ON pageviews(path, viewed_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pageviews_date ON pageviews(date(viewed_at), path);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,73 @@ function closeSearch() {
|
||||||
closeSearchModal();
|
closeSearchModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report modal helpers
|
||||||
|
function openReportModal() {
|
||||||
|
const modal = document.getElementById('report-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReportModal() {
|
||||||
|
const modal = document.getElementById('report-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitReport(owner, name) {
|
||||||
|
const form = document.getElementById('report-form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const ownerField = document.getElementById('report-owner');
|
||||||
|
const nameField = document.getElementById('report-name');
|
||||||
|
if (ownerField) ownerField.value = owner;
|
||||||
|
if (nameField) nameField.value = name;
|
||||||
|
|
||||||
|
if (!form.dataset.bound) {
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
tool_owner: ownerField ? ownerField.value : owner,
|
||||||
|
tool_name: nameField ? nameField.value : name,
|
||||||
|
reason: form.querySelector('[name="reason"]')?.value || '',
|
||||||
|
description: form.querySelector('[name="description"]')?.value || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/reports', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
closeReportModal();
|
||||||
|
alert('Thank you for your report. We will review it shortly.');
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert((error.error && error.error.message) || 'Failed to submit report. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to submit report. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
form.dataset.bound = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
openReportModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
function trackPageView() {
|
||||||
|
fetch('/api/v1/analytics/pageview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({path: window.location.pathname})
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
// ESC to close modals
|
// ESC to close modals
|
||||||
|
|
@ -157,6 +224,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
// Initialize any dynamic components
|
// Initialize any dynamic components
|
||||||
initializeDropdowns();
|
initializeDropdowns();
|
||||||
|
|
||||||
|
trackPageView();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dropdown initialization
|
// Dropdown initialization
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,11 @@
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
onclick="submitReport('{{ tool.owner }}', '{{ tool.name }}')"
|
||||||
|
class="mt-4 w-full inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
|
||||||
|
Report Tool
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Versions -->
|
<!-- Versions -->
|
||||||
|
|
@ -195,7 +200,7 @@
|
||||||
<!-- Report Tool -->
|
<!-- Report Tool -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="openReportModal()"
|
onclick="submitReport('{{ tool.owner }}', '{{ tool.name }}')"
|
||||||
class="text-sm text-gray-500 hover:text-red-600 transition-colors">
|
class="text-sm text-gray-500 hover:text-red-600 transition-colors">
|
||||||
Report this tool
|
Report this tool
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -218,7 +223,8 @@
|
||||||
Help us maintain a safe registry by reporting tools that violate our guidelines.
|
Help us maintain a safe registry by reporting tools that violate our guidelines.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<input type="hidden" name="tool_id" value="{{ tool.id }}">
|
<input type="hidden" name="tool_owner" id="report-owner" value="{{ tool.owner }}">
|
||||||
|
<input type="hidden" name="tool_name" id="report-name" value="{{ tool.name }}">
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -226,19 +232,18 @@
|
||||||
<select name="reason" id="reason" required
|
<select name="reason" id="reason" required
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
|
||||||
<option value="">Select a reason...</option>
|
<option value="">Select a reason...</option>
|
||||||
<option value="malicious">Malicious code</option>
|
<option value="spam">Spam</option>
|
||||||
<option value="spam">Spam or advertising</option>
|
<option value="malicious">Malicious</option>
|
||||||
<option value="copyright">Copyright violation</option>
|
<option value="duplicate">Duplicate</option>
|
||||||
<option value="inappropriate">Inappropriate content</option>
|
<option value="inappropriate">Inappropriate</option>
|
||||||
<option value="broken">Broken or non-functional</option>
|
|
||||||
<option value="other">Other</option>
|
<option value="other">Other</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="details" class="block text-sm font-medium text-gray-700 mb-1">Details (optional)</label>
|
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
|
||||||
<textarea name="details" id="details" rows="3"
|
<textarea name="description" id="description" rows="3"
|
||||||
placeholder="Provide additional context..."
|
placeholder="Provide additional context"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 resize-none"></textarea>
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 resize-none"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -275,37 +280,5 @@ function copyInstall() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReportModal() {
|
|
||||||
document.getElementById('report-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeReportModal() {
|
|
||||||
document.getElementById('report-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle report form submission
|
|
||||||
document.getElementById('report-form').addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(this);
|
|
||||||
const data = Object.fromEntries(formData);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/reports', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
closeReportModal();
|
|
||||||
alert('Thank you for your report. We will review it shortly.');
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(error.error || 'Failed to submit report. Please try again.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to submit report. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue