- Convert roadmap to YAML: - Add data structure: id, priority, title, goal, tags, milestone - Add `details` field (supports list or block string); populated initial content - Quote scalars and use explicit nulls to avoid YAML parse edge cases - Update `updated` date to 2025-08-22 - Flask blueprint + loader: - New /roadmap view with section switching (roadmap | backlog | open_questions) - Filters: q (search), tag (multi, AND), min_priority, milestone - Dataclasses: RoadmapData/RoadmapItem; include `details` - `_normalize_details()` to accept string or list, normalize to list[str] - Configurable path via `ROADMAP_FILE` (env or defaults) - Remove cache layer for simplicity - UI (Tailwind): - `templates/roadmap.html` with responsive cards, tag chips, and filter form - Details modal (larger max width, scrollable body) showing ID/goal/priority/tags/milestone - Safe JSON payload to modal via `|tojson|forceescape` - JS: - DOM-ready, event-delegated handler for `data-item` buttons - Populate modal fields and render multi-paragraph details - Fixes & polish: - Resolved YAML `ScannerError` by quoting strings with `:` and `#` - Ensured `details` is passed through route to template and included in button payload - Minor styling tweaks for consistency with Tailwind setup Usage: - Set `ROADMAP_FILE` if not using default path - Visit /roadmap and filter via q/tag/min_priority/milestone
262 lines
9.9 KiB
HTML
262 lines
9.9 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Roadmap{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mx-auto max-w-6xl px-4 py-6">
|
|
<!-- Header -->
|
|
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-semibold tracking-tight">SneakyScope Roadmap</h1>
|
|
{% if updated %}
|
|
<p class="text-sm text-gray-400">Last updated: {{ updated }}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Section switcher -->
|
|
<form method="get" action="{{ url_for('roadmap.roadmap_view') }}" class="flex items-center gap-2">
|
|
<input type="hidden" name="q" value="{{ q }}">
|
|
{% for t in selected_tags %}
|
|
<input type="hidden" name="tag" value="{{ t }}">
|
|
{% endfor %}
|
|
{% if min_priority is not none %}
|
|
<input type="hidden" name="min_priority" value="{{ min_priority }}">
|
|
{% endif %}
|
|
{% if milestone %}
|
|
<input type="hidden" name="milestone" value="{{ milestone }}">
|
|
{% endif %}
|
|
|
|
<select name="section" class="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm">
|
|
<option value="roadmap" {{ "selected" if section == "roadmap" else "" }}>Roadmap</option>
|
|
<option value="backlog" {{ "selected" if section == "backlog" else "" }}>Backlog</option>
|
|
<option value="open_questions" {{ "selected" if section == "open_questions" else "" }}>Open Questions</option>
|
|
</select>
|
|
<button class="rounded-2xl bg-gray-800 px-4 py-2 text-sm font-medium hover:bg-gray-700">Go</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<form method="get" action="{{ url_for('roadmap.roadmap_view') }}" class="mb-6 grid gap-4 sm:grid-cols-12">
|
|
<input type="hidden" name="section" value="{{ section }}">
|
|
|
|
<!-- search -->
|
|
<div class="sm:col-span-5">
|
|
<label class="mb-1 block text-sm text-gray-300">Search</label>
|
|
<input name="q" value="{{ q }}" placeholder="Search title, goal, or ID"
|
|
class="w-full rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
|
</div>
|
|
|
|
<!-- min priority -->
|
|
<div class="sm:col-span-2">
|
|
<label class="mb-1 block text-sm text-gray-300">Min Priority</label>
|
|
<input name="min_priority" type="number" min="1" max="9" value="{{ min_priority if min_priority is not none }}"
|
|
class="w-full rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500">
|
|
</div>
|
|
|
|
<!-- milestone -->
|
|
<div class="sm:col-span-3">
|
|
<label class="mb-1 block text-sm text-gray-300">Milestone</label>
|
|
<input name="milestone" value="{{ milestone or '' }}" placeholder="e.g., v0.2"
|
|
class="w-full rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500">
|
|
</div>
|
|
|
|
<!-- submit -->
|
|
<div class="sm:col-span-2 flex items-end">
|
|
<button class="w-full rounded-2xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500">Filter</button>
|
|
</div>
|
|
|
|
<!-- tags -->
|
|
<div class="sm:col-span-12">
|
|
<label class="mb-2 block text-sm text-gray-300">Tags</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
{% for t in all_tags %}
|
|
<label class="inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm hover:border-gray-600">
|
|
<input type="checkbox" name="tag" value="{{ t }}" class="h-4 w-4 accent-blue-600"
|
|
{% if t in selected_tags %}checked{% endif %}>
|
|
<span class="text-gray-200">{{ t }}</span>
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Empty state -->
|
|
{% if not items %}
|
|
<div class="rounded-2xl border border-gray-700 bg-gray-900 p-6 text-gray-300">
|
|
No items match your filters.
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Cards -->
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
{% for it in items %}
|
|
<article class="rounded-2xl border border-gray-700 bg-gray-900 p-4">
|
|
<div class="mb-2 flex items-start justify-between gap-3">
|
|
<h2 class="text-base font-semibold leading-snug">{{ it.title }}</h2>
|
|
{% if it.priority is not none %}
|
|
<span class="badge badge-info">
|
|
P{{ it.priority }}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
<p class="mb-3 text-sm text-gray-300">{{ it.goal }}</p>
|
|
|
|
<!-- chips -->
|
|
<div class="mb-3 flex flex-wrap gap-2">
|
|
{% for tag in it.tags %}
|
|
<span class="chips">
|
|
{{ tag }}
|
|
</span>
|
|
{% endfor %}
|
|
{% if it.milestone %}
|
|
<span class="badge badge-success">
|
|
milestone: {{ it.milestone }}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between">
|
|
<code class="text-xs text-gray-400">{{ it.id }}</code>
|
|
<!-- Placeholder for future actions (Flowbite buttons/menus) -->
|
|
<button
|
|
type="button"
|
|
class="rounded-xl border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-200 hover:bg-gray-700"
|
|
data-modal-target="roadmap-modal"
|
|
data-modal-toggle="roadmap-modal"
|
|
data-item='{{ {
|
|
"id": it.id,
|
|
"title": it.title,
|
|
"goal": it.goal,
|
|
"priority": it.priority,
|
|
"tags": it.tags,
|
|
"milestone": it.milestone,
|
|
"details": it.details
|
|
} | tojson | forceescape }}'
|
|
>
|
|
Details
|
|
</button>
|
|
|
|
|
|
</div>
|
|
</article>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal -->
|
|
<div id="roadmap-modal" tabindex="-1" aria-hidden="true"
|
|
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
|
<div class="fixed inset-0 bg-black/60"></div>
|
|
|
|
<!-- Make SIZE + LAYOUT CHANGES HERE -->
|
|
<div class="relative z-10 w-full sm:max-w-3xl md:max-w-4xl lg:max-w-5xl
|
|
max-h-[85vh] overflow-hidden rounded-2xl border border-gray-700 bg-gray-900">
|
|
|
|
<!-- Header (sticky inside modal) -->
|
|
<div class="flex items-center justify-between gap-2 border-b border-gray-800 px-4 py-3">
|
|
<h3 id="rm-title" class="text-lg font-semibold text-gray-100">Item</h3>
|
|
<button type="button" class="rounded-lg p-2 hover:bg-gray-800" data-modal-hide="roadmap-modal" aria-label="Close">✕</button>
|
|
</div>
|
|
|
|
<!-- Body (scrolls if long) -->
|
|
<div class="px-4 py-4 overflow-y-auto">
|
|
<div class="mb-4 space-y-2 text-sm">
|
|
<div class="text-gray-300"><span class="font-medium">ID:</span> <code id="rm-id" class="text-gray-400"></code></div>
|
|
<div class="text-gray-300"><span class="font-medium">Goal:</span> <span id="rm-goal" class="text-gray-200"></span></div>
|
|
<div class="text-gray-300"><span class="font-medium">Priority:</span> <span id="rm-priority"></span></div>
|
|
<div class="text-gray-300"><span class="font-medium">Milestone:</span> <span id="rm-milestone"></span></div>
|
|
<div class="text-gray-300"><span class="font-medium">Tags:</span>
|
|
<span id="rm-tags" class="inline-flex flex-wrap gap-2 align-middle"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 class="mb-2 text-sm font-semibold text-gray-200">Details</h4>
|
|
<div id="rm-details" class="prose prose-invert max-w-none text-sm"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex justify-end gap-2 border-t border-gray-800 px-4 py-3">
|
|
<button type="button" class="rounded-xl border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 hover:bg-gray-700" data-modal-hide="roadmap-modal">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
(function(){
|
|
function onReady(fn){
|
|
if(document.readyState !== 'loading') fn();
|
|
else document.addEventListener('DOMContentLoaded', fn);
|
|
}
|
|
|
|
onReady(function(){
|
|
const modal = document.getElementById('roadmap-modal');
|
|
|
|
function el(id){
|
|
return document.getElementById(id);
|
|
}
|
|
function pill(text){
|
|
const span = document.createElement('span');
|
|
span.className = "inline-flex items-center rounded-full border border-gray-700 bg-gray-800 px-2.5 py-0.5 text-xs text-gray-200";
|
|
span.textContent = text;
|
|
return span;
|
|
}
|
|
function setText(id, v){ el(id).textContent = (v ?? ""); }
|
|
function setTags(tags){
|
|
const holder = el("rm-tags"); holder.innerHTML = "";
|
|
(tags || []).forEach(t => holder.appendChild(pill(t)));
|
|
}
|
|
function setDetails(list){
|
|
const c = el("rm-details"); c.innerHTML = "";
|
|
if(!list || !list.length){
|
|
const p = document.createElement('p'); p.className = "text-gray-400"; p.textContent = "No additional details.";
|
|
c.appendChild(p); return;
|
|
}
|
|
list.forEach(part => {
|
|
const p = document.createElement('p'); p.className = "text-gray-200"; p.textContent = part;
|
|
c.appendChild(p);
|
|
});
|
|
}
|
|
function populate(data){
|
|
setText("rm-title", data.title || "Item");
|
|
setText("rm-id", data.id || "");
|
|
setText("rm-goal", data.goal || "");
|
|
setText("rm-priority", (data.priority != null) ? `P${data.priority}` : "");
|
|
setText("rm-milestone", data.milestone || "");
|
|
setTags(data.tags || []);
|
|
setDetails(data.details || []);
|
|
}
|
|
|
|
// Event delegation: works for all current and future buttons
|
|
document.addEventListener('click', function(ev){
|
|
const btn = ev.target.closest('[data-item]');
|
|
if(!btn) return;
|
|
try{
|
|
const raw = btn.getAttribute('data-item') || "{}";
|
|
const data = JSON.parse(raw);
|
|
populate(data);
|
|
// If not using Flowbite to open, uncomment:
|
|
// modal.classList.remove('hidden');
|
|
} catch(err){
|
|
console.error("Failed to parse data-item JSON", err);
|
|
}
|
|
});
|
|
|
|
// If not using Flowbite to close, uncomment:
|
|
// document.querySelectorAll('[data-modal-hide="roadmap-modal"]').forEach(b => {
|
|
// b.addEventListener('click', () => modal.classList.add('hidden'));
|
|
// });
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
</script>
|
|
|
|
{% endblock %} |