feat(roadmap): YAML-driven roadmap + Tailwind UI w/ filters & details modal
- 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
This commit is contained in:
262
app/templates/roadmap.html
Normal file
262
app/templates/roadmap.html
Normal file
@@ -0,0 +1,262 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user