Add SmartTools Registry and Web UI (Phases 1-7)

Registry Features (Phases 1-6):
- Tool manifest format and validation
- Registry client for API communication
- Resolver integration for tool installation
- CLI commands: install, search, browse, publish, auth
- TUI browse interface with urwid
- Smart features: auto-categorization, similarity suggestions

Web UI (Phase 7):
- Flask blueprint with server-side sessions
- Authentication routes (login, register, logout)
- Public pages: landing, tools browse, tool detail, search
- Dashboard: overview, tools management, API tokens, settings
- Template components: cards, forms, callouts, code blocks
- Tailwind CSS build pipeline
- SEO infrastructure (sitemap, robots.txt)
- Cookie consent and privacy compliance

Infrastructure:
- SQLite database with FTS5 search
- Rate limiting for API endpoints
- Git-based tool submission workflow
- Session management with auto-cleanup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2025-12-31 19:03:41 -04:00
co-authored by Claude Opus 4.5
parent 284e9981bc
commit 34428f0e72
61 changed files with 14492 additions and 6 deletions
@@ -0,0 +1,61 @@
{# Callout/alert macros for documentation and content pages #}
{# Info callout #}
{% macro info(title='Note', content='') %}
<div class="my-4 p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-blue-800">{{ title }}</h4>
<p class="mt-1 text-sm text-blue-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Warning callout #}
{% macro warning(title='Warning', content='') %}
<div class="my-4 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-amber-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-amber-800">{{ title }}</h4>
<p class="mt-1 text-sm text-amber-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Error callout #}
{% macro error(title='Important', content='') %}
<div class="my-4 p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-red-800">{{ title }}</h4>
<p class="mt-1 text-sm text-red-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
{# Tip callout #}
{% macro tip(title='Tip', content='') %}
<div class="my-4 p-4 bg-green-50 border-l-4 border-green-500 rounded-r-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-green-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
<div class="ml-3">
<h4 class="text-sm font-medium text-green-800">{{ title }}</h4>
<p class="mt-1 text-sm text-green-700">{{ content }}</p>
</div>
</div>
</div>
{% endmacro %}
@@ -0,0 +1,47 @@
{# Code block macro with syntax highlighting and copy button #}
{% macro code(content, language='', filename='', show_line_numbers=false) %}
<div class="my-4 relative group">
{% if filename %}
<div class="bg-gray-800 text-gray-400 text-xs px-4 py-2 rounded-t-lg border-b border-gray-700">
{{ filename }}
</div>
{% endif %}
<div class="relative">
<pre class="{% if filename %}rounded-t-none{% endif %} bg-gray-900 text-gray-100 text-sm p-4 rounded-lg overflow-x-auto"><code class="language-{{ language }}">{{ content }}</code></pre>
<button type="button"
onclick="copyCode(this)"
data-code="{{ content|e }}"
class="absolute top-2 right-2 p-2 text-gray-400 hover:text-white bg-gray-800 rounded opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy code">
<svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
{% endmacro %}
{# Inline code #}
{% macro inline(content) %}
<code class="px-1.5 py-0.5 bg-gray-100 text-gray-800 text-sm font-mono rounded">{{ content }}</code>
{% endmacro %}
{# Install command (special styling) #}
{% macro install(command) %}
<div class="my-4 flex items-center bg-gray-100 rounded-lg p-4 group">
<span class="text-gray-400 mr-2 select-none">$</span>
<code class="flex-1 text-gray-800 font-mono text-sm">{{ command }}</code>
<button type="button"
onclick="copyToClipboard('{{ command }}')"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy command">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
{% endmacro %}
@@ -0,0 +1,148 @@
{# Cookie consent banner #}
<div id="consent-banner"
class="fixed bottom-0 inset-x-0 z-50 bg-white border-t border-gray-200 shadow-lg transform transition-transform duration-300"
role="dialog"
aria-labelledby="consent-title"
aria-describedby="consent-description">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div class="flex-1">
<h3 id="consent-title" class="text-sm font-semibold text-gray-900">
We value your privacy
</h3>
<p id="consent-description" class="mt-1 text-sm text-gray-600">
We use cookies to improve your experience and for analytics.
You can choose to accept all cookies or manage your preferences.
<a href="{{ url_for('web.privacy') }}" class="text-indigo-600 hover:text-indigo-800 underline">Learn more</a>
</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<button type="button"
onclick="openConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
Manage preferences
</button>
<button type="button"
onclick="rejectConsent()"
class="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors">
Reject all
</button>
<button type="button"
onclick="acceptAllConsent()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors">
Accept all
</button>
</div>
</div>
</div>
</div>
<!-- Consent preferences modal -->
<div id="consent-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-modal="true"
role="dialog"
aria-labelledby="consent-modal-title">
<div class="min-h-screen px-4 text-center">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeConsentPreferences()"></div>
<!-- Modal -->
<div class="inline-block w-full max-w-lg my-8 text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="p-6">
<h3 id="consent-modal-title" class="text-lg font-semibold text-gray-900">
Cookie Preferences
</h3>
<p class="mt-2 text-sm text-gray-600">
Choose which cookies you want to accept. Essential cookies cannot be disabled as they are required for the site to function.
</p>
<div class="mt-6 space-y-4">
<!-- Essential cookies (always on) -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h4 class="text-sm font-medium text-gray-900">Essential cookies</h4>
<p class="mt-1 text-sm text-gray-500">Required for the website to function properly.</p>
</div>
<input type="checkbox" checked disabled class="mt-1 h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
<!-- Analytics cookies -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<label for="consent-analytics" class="text-sm font-medium text-gray-900 cursor-pointer">Analytics cookies</label>
<p class="mt-1 text-sm text-gray-500">Help us understand how visitors use our site.</p>
</div>
<input type="checkbox"
id="consent-analytics"
name="analytics"
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
<!-- Advertising cookies -->
<div class="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
<div>
<label for="consent-ads" class="text-sm font-medium text-gray-900 cursor-pointer">Advertising cookies</label>
<p class="mt-1 text-sm text-gray-500">Used to show relevant ads and support the project.</p>
</div>
<input type="checkbox"
id="consent-ads"
name="ads"
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 rounded-b-lg flex justify-end gap-3">
<button type="button"
onclick="closeConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
Cancel
</button>
<button type="button"
onclick="saveConsentPreferences()"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors">
Save preferences
</button>
</div>
</div>
</div>
</div>
<script>
function acceptAllConsent() {
saveConsent(true, true);
}
function rejectConsent() {
saveConsent(false, false);
}
function openConsentPreferences() {
document.getElementById('consent-modal').classList.remove('hidden');
}
function closeConsentPreferences() {
document.getElementById('consent-modal').classList.add('hidden');
}
function saveConsentPreferences() {
const analytics = document.getElementById('consent-analytics').checked;
const ads = document.getElementById('consent-ads').checked;
saveConsent(analytics, ads);
closeConsentPreferences();
}
function saveConsent(analytics, ads) {
fetch('/api/v1/consent', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({analytics: analytics, ads: ads})
}).then(() => {
document.getElementById('consent-banner').style.transform = 'translateY(100%)';
setTimeout(() => {
document.getElementById('consent-banner').remove();
}, 300);
});
}
</script>
@@ -0,0 +1,58 @@
{# Contributor card macro #}
{% macro contributor_card(contributor=none, display_name=none, slug=none, bio=none, website=none, verified=none, featured=false) %}
{% if contributor is none %}
{% set contributor = {
"display_name": display_name,
"slug": slug,
"bio": bio,
"website": website,
"verified": verified
} %}
{% endif %}
{% if featured %}
<!-- Featured contributor (horizontal layout) -->
<article class="bg-white rounded-lg border border-gray-200 shadow-sm p-6 flex items-center space-x-6">
<!-- Avatar -->
<div class="flex-shrink-0 w-16 h-16 bg-gradient-to-br from-indigo-500 to-cyan-500 rounded-full flex items-center justify-center">
<span class="text-white text-2xl font-bold">{{ contributor.display_name[0]|upper }}</span>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<h3 class="text-xl font-semibold text-gray-900">{{ contributor.display_name }}</h3>
<p class="text-sm text-gray-500">@{{ contributor.slug }}</p>
<p class="mt-2 text-gray-600 line-clamp-2">{{ contributor.bio or 'Active community member and tool creator.' }}</p>
</div>
<!-- Actions -->
<div class="flex-shrink-0">
<a href="{{ url_for('web.publisher_profile', slug=contributor.slug) }}"
class="inline-flex items-center px-4 py-2 border border-indigo-600 text-sm font-medium rounded-md text-indigo-600 hover:bg-indigo-50 transition-colors">
View Profile
</a>
</div>
</article>
{% else %}
<!-- Regular contributor card (vertical layout) -->
<article class="bg-white rounded-lg border border-gray-200 shadow-sm p-4 text-center">
<!-- Avatar -->
<div class="w-12 h-12 bg-gradient-to-br from-indigo-500 to-cyan-500 rounded-full flex items-center justify-center mx-auto">
<span class="text-white text-lg font-bold">{{ contributor.display_name[0]|upper }}</span>
</div>
<!-- Info -->
<h3 class="mt-3 font-semibold text-gray-900">{{ contributor.display_name }}</h3>
<p class="text-sm text-gray-500">@{{ contributor.slug }}</p>
{% if contributor.bio %}
<p class="mt-2 text-sm text-gray-600 line-clamp-2">{{ contributor.bio }}</p>
{% endif %}
<!-- Link -->
<a href="{{ url_for('web.publisher_profile', slug=contributor.slug) }}"
class="mt-3 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
View Profile
</a>
</article>
{% endif %}
{% endmacro %}
@@ -0,0 +1,59 @@
<footer class="bg-slate-800 text-white mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<a href="{{ url_for('web.index') }}" class="text-xl font-bold">SmartTools</a>
<p class="mt-2 text-gray-400 text-sm max-w-md">
Build custom AI commands in YAML. Create Unix-style pipeable tools that work with any AI provider.
Provider-agnostic, composable, and community-driven.
</p>
<div class="mt-4 flex space-x-4">
<a href="https://github.com/rob/smarttools" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-white transition-colors" aria-label="GitHub">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
</a>
<a href="https://twitter.com/smarttools" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-white transition-colors" aria-label="Twitter">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"/>
</svg>
</a>
</div>
</div>
<!-- Resources -->
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider">Resources</h3>
<ul class="mt-4 space-y-2">
<li><a href="{{ url_for('web.docs') }}" class="text-gray-400 hover:text-white text-sm">Documentation</a></li>
<li><a href="{{ url_for('web.tutorials') }}" class="text-gray-400 hover:text-white text-sm">Tutorials</a></li>
<li><a href="{{ url_for('web.tools_browse') }}" class="text-gray-400 hover:text-white text-sm">Registry</a></li>
<li><a href="{{ url_for('web.community') }}" class="text-gray-400 hover:text-white text-sm">Community</a></li>
</ul>
</div>
<!-- Legal -->
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider">Legal</h3>
<ul class="mt-4 space-y-2">
<li><a href="{{ url_for('web.about') }}" class="text-gray-400 hover:text-white text-sm">About</a></li>
<li><a href="{{ url_for('web.privacy') }}" class="text-gray-400 hover:text-white text-sm">Privacy Policy</a></li>
<li><a href="{{ url_for('web.terms') }}" class="text-gray-400 hover:text-white text-sm">Terms of Service</a></li>
<li><a href="{{ url_for('web.donate') }}" class="text-gray-400 hover:text-white text-sm">Donate</a></li>
</ul>
</div>
</div>
<div class="mt-8 pt-8 border-t border-slate-700 flex flex-col sm:flex-row justify-between items-center">
<p class="text-gray-400 text-sm">
&copy; {{ now().year }} SmartTools. Open source under MIT License.
</p>
<p class="text-gray-500 text-xs mt-2 sm:mt-0">
Made with care for the developer community.
</p>
</div>
</div>
</footer>
@@ -0,0 +1,169 @@
{# Reusable form macros #}
{# Text input field #}
{% macro text_input(name, label, type='text', placeholder='', value='', required=false, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<input type="{{ type }}"
name="{{ name }}"
id="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Textarea field #}
{% macro textarea(name, label, placeholder='', value='', required=false, rows=4, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<textarea name="{{ name }}"
id="{{ name }}"
rows="{{ rows }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors resize-y
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">{{ value }}</textarea>
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Select dropdown #}
{% macro select(name, label, options, selected='', required=false, error=none, help=none) %}
<div class="mb-4">
<label for="{{ name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<select name="{{ name }}"
id="{{ name }}"
{% if required %}required{% endif %}
class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors bg-white
{% if error %}border-red-500 bg-red-50{% else %}border-gray-300{% endif %}"
aria-describedby="{{ name }}-help {{ name }}-error">
{% for value, label in options %}
<option value="{{ value }}" {% if value == selected %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% if help %}
<p id="{{ name }}-help" class="mt-1 text-sm text-gray-500">{{ help }}</p>
{% endif %}
{% if error %}
<p id="{{ name }}-error" class="mt-1 text-sm text-red-600" role="alert">{{ error }}</p>
{% endif %}
</div>
{% endmacro %}
{# Checkbox #}
{% macro checkbox(name, label, checked=false, help=none) %}
<div class="mb-4 flex items-start">
<input type="checkbox"
name="{{ name }}"
id="{{ name }}"
{% if checked %}checked{% endif %}
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<div class="ml-3">
<label for="{{ name }}" class="text-sm font-medium text-gray-700">{{ label }}</label>
{% if help %}
<p class="text-sm text-gray-500">{{ help }}</p>
{% endif %}
</div>
</div>
{% endmacro %}
{# Primary button #}
{% macro button_primary(text, type='submit', name=none, disabled=false, full_width=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="{% if full_width %}w-full{% endif %} inline-flex justify-center items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Secondary button #}
{% macro button_secondary(text, type='button', name=none, disabled=false, full_width=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="{% if full_width %}w-full{% endif %} inline-flex justify-center items-center px-6 py-3 border-2 border-cyan-500 text-base font-medium rounded-md text-cyan-600 bg-transparent hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Danger button #}
{% macro button_danger(text, type='button', name=none, disabled=false) %}
<button type="{{ type }}"
{% if name %}name="{{ name }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{{ text }}
</button>
{% endmacro %}
{# Ghost/link button #}
{% macro button_ghost(text, href='#') %}
<a href="{{ href }}"
class="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
{{ text }}
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{% endmacro %}
{# Form error alert #}
{% macro form_errors(errors) %}
{% if errors %}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg" role="alert">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">There were errors with your submission</h3>
<ul class="mt-2 text-sm text-red-700 list-disc list-inside">
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{% endmacro %}
{# Success alert #}
{% macro success_alert(message) %}
{% if message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg" role="alert">
<div class="flex items-center">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="ml-3 text-sm font-medium text-green-800">{{ message }}</p>
</div>
</div>
{% endif %}
{% endmacro %}
@@ -0,0 +1,183 @@
<header class="bg-slate-800 text-white sticky top-0 z-40">
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" aria-label="Main navigation">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<div class="flex-shrink-0">
<a href="{{ url_for('web.index') }}" class="text-xl font-bold hover:text-indigo-400 transition-colors">
SmartTools
</a>
</div>
<!-- Desktop navigation -->
<div class="hidden md:flex items-center space-x-1">
<a href="{{ url_for('web.docs') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/docs') %}bg-slate-700{% endif %}">
Docs
</a>
<a href="{{ url_for('web.tutorials') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/tutorials') %}bg-slate-700{% endif %}">
Tutorials
</a>
<a href="{{ url_for('web.tools_browse') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/tools') %}bg-slate-700{% endif %}">
Registry
</a>
<a href="{{ url_for('web.community') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path.startswith('/community') %}bg-slate-700{% endif %}">
Community
</a>
<a href="{{ url_for('web.about') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors {% if request.path == '/about' %}bg-slate-700{% endif %}">
About
</a>
</div>
<!-- Right side: Search + Auth -->
<div class="hidden md:flex items-center space-x-4">
<!-- Search button -->
<button type="button"
onclick="openSearch()"
class="p-2 rounded-md hover:bg-slate-700 transition-colors"
aria-label="Search">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
<!-- Donate button -->
<a href="{{ url_for('web.donate') }}"
class="px-3 py-2 rounded-md text-sm font-medium bg-indigo-600 hover:bg-indigo-700 transition-colors">
Donate
</a>
<!-- Auth links -->
{% if session.get('user') %}
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-slate-700 transition-colors">
<span class="text-sm font-medium">{{ session.user.display_name }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div x-show="open"
@click.away="open = false"
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<a href="{{ url_for('web.dashboard') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
<a href="{{ url_for('web.dashboard_settings') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Settings</a>
<hr class="my-1">
<form action="{{ url_for('web.logout') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Sign out
</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('web.login') }}"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-slate-700 transition-colors">
Sign in
</a>
{% endif %}
</div>
<!-- Mobile menu button -->
<div class="md:hidden">
<button type="button"
onclick="toggleMobileMenu()"
class="p-2 rounded-md hover:bg-slate-700 transition-colors"
aria-expanded="false"
aria-controls="mobile-menu"
aria-label="Toggle menu">
<svg id="menu-icon-open" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg id="menu-icon-close" class="w-6 h-6 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Mobile menu -->
<div id="mobile-menu" class="hidden md:hidden pb-4">
<div class="space-y-1">
<a href="{{ url_for('web.docs') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/docs') %}bg-slate-700{% endif %}">
Docs
</a>
<a href="{{ url_for('web.tutorials') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/tutorials') %}bg-slate-700{% endif %}">
Tutorials
</a>
<a href="{{ url_for('web.tools_browse') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/tools') %}bg-slate-700{% endif %}">
Registry
</a>
<a href="{{ url_for('web.community') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path.startswith('/community') %}bg-slate-700{% endif %}">
Community
</a>
<a href="{{ url_for('web.about') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700 {% if request.path == '/about' %}bg-slate-700{% endif %}">
About
</a>
<hr class="border-slate-600 my-2">
<a href="{{ url_for('web.donate') }}"
class="block px-3 py-2 rounded-md text-base font-medium bg-indigo-600 hover:bg-indigo-700 mx-3">
Donate
</a>
{% if session.get('user') %}
<a href="{{ url_for('web.dashboard') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700">
Dashboard
</a>
<form action="{{ url_for('web.logout') }}" method="POST" class="px-3 py-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-base font-medium hover:text-indigo-400">
Sign out
</button>
</form>
{% else %}
<a href="{{ url_for('web.login') }}"
class="block px-3 py-2 rounded-md text-base font-medium hover:bg-slate-700">
Sign in
</a>
{% endif %}
</div>
</div>
</nav>
</header>
<!-- Search modal (hidden by default) -->
<div id="search-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-modal="true">
<div class="min-h-screen px-4 text-center">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeSearch()"></div>
<!-- Modal content -->
<div class="inline-block w-full max-w-2xl my-8 text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="p-4">
<div class="relative">
<svg class="absolute left-3 top-3 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text"
id="search-input"
placeholder="Search tools, docs, tutorials..."
class="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
autocomplete="off">
</div>
<div id="search-results" class="mt-4 max-h-96 overflow-y-auto">
<!-- Search results will be inserted here -->
<p class="text-sm text-gray-500 text-center py-8">Start typing to search...</p>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 rounded-b-lg flex justify-between items-center text-sm text-gray-500">
<span>Press <kbd class="px-2 py-1 bg-gray-200 rounded">ESC</kbd> to close</span>
<button onclick="closeSearch()" class="text-gray-500 hover:text-gray-700">Close</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,70 @@
{# Tool card macro #}
{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none) %}
{% if tool is none %}
{% set tool = {
"owner": owner,
"name": name,
"description": description,
"category": category,
"downloads": downloads,
"version": version
} %}
{% endif %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow p-4 relative">
<!-- Category badge -->
{% if tool.category %}
<span class="absolute top-3 right-3 px-2 py-1 bg-cyan-500 text-white text-xs font-medium rounded">
{{ tool.category }}
</span>
{% endif %}
<!-- Tool icon and name -->
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
<span class="text-indigo-600 font-bold text-sm">{{ tool.name[0]|upper }}</span>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 truncate">
<a href="{{ url_for('web.tool_detail', owner=tool.owner, name=tool.name) }}"
class="hover:text-indigo-600 transition-colors">
{{ tool.name }}
</a>
</h3>
<p class="text-sm text-gray-500">by {{ tool.owner }}</p>
</div>
</div>
<!-- Description -->
<p class="mt-3 text-sm text-gray-600 line-clamp-2">
{{ tool.description or 'No description available.' }}
</p>
<!-- Meta info -->
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{{ tool.downloads|default(0) }} downloads
</span>
<span class="text-xs text-gray-400">v{{ tool.version }}</span>
</div>
<!-- Install command -->
<div class="mt-4">
<div class="flex items-center bg-gray-100 rounded px-3 py-2 group">
<code class="text-xs text-gray-700 flex-1 truncate font-mono">
smarttools install {{ tool.owner }}/{{ tool.name }}
</code>
<button type="button"
onclick="copyToClipboard('smarttools install {{ tool.owner }}/{{ tool.name }}')"
class="ml-2 p-1 text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Copy install command">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
</article>
{% endmacro %}
@@ -0,0 +1,53 @@
{# Tutorial card macro #}
{% macro tutorial_card(tutorial=none, title=none, description=none, href=none, slug=none, thumbnail=none, step_number=none) %}
{% if tutorial is none %}
{% set tutorial = {
"slug": slug,
"title": title,
"description": description,
"thumbnail": thumbnail
} %}
{% endif %}
{% if href is none and tutorial.slug %}
{% set href = url_for('web.tutorial', slug=tutorial.slug) %}
{% endif %}
<article class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<!-- Thumbnail (optional) -->
{% if tutorial.thumbnail %}
<div class="aspect-video bg-gray-100 relative">
<img src="{{ tutorial.thumbnail }}"
alt="{{ tutorial.title }}"
class="w-full h-full object-cover"
loading="lazy">
</div>
{% else %}
<div class="aspect-video bg-gradient-to-br from-indigo-500 to-cyan-500 flex items-center justify-center">
<svg class="w-12 h-12 text-white opacity-75" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
{% endif %}
<div class="p-4">
{% if step_number %}
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-500 mb-2">Step {{ step_number }}</p>
{% endif %}
<h3 class="text-lg font-semibold text-gray-900">
<a href="{{ href or '#' }}"
class="hover:text-indigo-600 transition-colors">
{{ tutorial.title }}
</a>
</h3>
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
{{ tutorial.description or 'Learn how to use SmartTools effectively.' }}
</p>
<a href="{{ href or '#' }}"
class="mt-4 inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800 transition-colors">
Read More
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</article>
{% endmacro %}