Fix item visibility bugs and add effects web UI

- Fix recipe filter to allow owner=None (S-expression compiled recipes)
- Fix media uploads to use category (video/image/audio) not MIME type
- Fix IPFS imports to detect and store correct media type
- Add Effects navigation link between Recipes and Media
- Create effects list and detail templates with upload functionality
- Add cache/not_found.html template (was missing)
- Add type annotations to service classes
- Add tests for item visibility and effects web UI (30 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-12 12:01:54 +00:00
parent 19e2277155
commit 585c75e846
12 changed files with 1090 additions and 53 deletions

View File

@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% set meta = effect.meta or effect %}
{% block title %}{{ meta.name or 'Effect' }} - Effects - Art-DAG L1{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/python.min.js"></script>
{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="flex items-center space-x-4 mb-6">
<a href="/effects" class="text-gray-400 hover:text-white">&larr; Effects</a>
<h1 class="text-2xl font-bold">{{ meta.name or 'Unnamed Effect' }}</h1>
<span class="text-gray-500">v{{ meta.version or '1.0.0' }}</span>
{% if meta.temporal %}
<span class="bg-purple-900 text-purple-300 px-2 py-1 rounded text-sm">temporal</span>
{% endif %}
</div>
{% if meta.author %}
<p class="text-gray-500 mb-2">by {{ meta.author }}</p>
{% endif %}
{% if meta.description %}
<p class="text-gray-400 mb-6">{{ meta.description }}</p>
{% endif %}
<!-- CID Info -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-6">
<div class="flex items-center justify-between">
<div>
<span class="text-gray-500 text-sm">Content ID (CID)</span>
<p class="font-mono text-sm text-gray-300 mt-1" id="effect-cid">{{ effect.cid }}</p>
</div>
<button onclick="copyToClipboard('{{ effect.cid }}')"
class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded text-sm">
Copy
</button>
</div>
{% if effect.uploaded_at %}
<div class="mt-3 text-gray-500 text-sm">
Uploaded: {{ effect.uploaded_at }}
{% if effect.uploader %}
by {{ effect.uploader }}
{% endif %}
</div>
{% endif %}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left Column: Parameters & Dependencies -->
<div class="lg:col-span-1 space-y-6">
<!-- Parameters -->
{% if meta.params %}
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="border-b border-gray-700 px-4 py-2">
<span class="text-gray-400 text-sm font-medium">Parameters</span>
</div>
<div class="p-4 space-y-4">
{% for param in meta.params %}
<div>
<div class="flex items-center space-x-2 mb-1">
<span class="font-medium text-white">{{ param.name }}</span>
<span class="bg-blue-900 text-blue-300 px-2 py-0.5 rounded text-xs">{{ param.type }}</span>
</div>
{% if param.description %}
<p class="text-gray-400 text-sm">{{ param.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-2 mt-1 text-xs">
{% if param.range %}
<span class="text-gray-500">range: {{ param.range[0] }} - {{ param.range[1] }}</span>
{% endif %}
{% if param.default is defined %}
<span class="text-gray-500">default: {{ param.default }}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Dependencies -->
{% if meta.dependencies %}
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="border-b border-gray-700 px-4 py-2">
<span class="text-gray-400 text-sm font-medium">Dependencies</span>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-2">
{% for dep in meta.dependencies %}
<span class="bg-gray-700 text-gray-300 px-3 py-1 rounded">{{ dep }}</span>
{% endfor %}
</div>
{% if meta.requires_python %}
<p class="text-gray-500 text-sm mt-3">Python {{ meta.requires_python }}</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Usage in Recipe -->
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="border-b border-gray-700 px-4 py-2">
<span class="text-gray-400 text-sm font-medium">Usage in Recipe</span>
</div>
<div class="p-4">
<pre class="text-sm text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto"><code class="language-lisp">(effect {{ meta.name or 'effect' }} :cid "{{ effect.cid }}")</code></pre>
<p class="text-gray-500 text-xs mt-2">
Reference this effect in your recipe S-expression.
</p>
</div>
</div>
</div>
<!-- Right Column: Source Code -->
<div class="lg:col-span-2">
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="border-b border-gray-700 px-4 py-2 flex items-center justify-between">
<span class="text-gray-400 text-sm font-medium">Source Code</span>
<div class="flex items-center space-x-2">
<a href="/effects/{{ effect.cid }}/source"
class="text-gray-400 hover:text-white text-sm"
download="{{ meta.name or 'effect' }}.py">
Download
</a>
</div>
</div>
<div class="p-4">
<pre class="text-sm overflow-x-auto rounded bg-gray-900"><code class="language-python" id="source-code">Loading...</code></pre>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-4 mt-8">
<button onclick="deleteEffect('{{ effect.cid }}')"
class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded font-medium">
Delete
</button>
<span id="action-result"></span>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load source code
fetch('/effects/{{ effect.cid }}/source')
.then(response => response.text())
.then(source => {
const codeEl = document.getElementById('source-code');
codeEl.textContent = source;
hljs.highlightElement(codeEl);
})
.catch(error => {
document.getElementById('source-code').textContent = 'Failed to load source code';
});
});
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = originalText; }, 1500);
});
}
function deleteEffect(cid) {
if (!confirm('Delete this effect from local cache? IPFS copies will persist.')) return;
fetch('/effects/' + cid, { method: 'DELETE' })
.then(response => {
if (!response.ok) throw new Error('Delete failed');
return response.json();
})
.then(data => {
document.getElementById('action-result').innerHTML =
'<span class="text-green-400">Deleted. Redirecting...</span>';
setTimeout(() => { window.location.href = '/effects'; }, 1000);
})
.catch(error => {
document.getElementById('action-result').innerHTML =
'<span class="text-red-400">' + error.message + '</span>';
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Effects - Art-DAG L1{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Effects</h1>
<label class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium cursor-pointer">
Upload Effect
<input type="file" accept=".py" class="hidden" id="effect-upload" />
</label>
</div>
<p class="text-gray-400 mb-8">
Effects are Python scripts that process video frames or whole videos.
Each effect is stored in IPFS and can be referenced by CID in recipes.
</p>
{% if effects %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for effect in effects %}
{% set meta = effect.meta or effect %}
<a href="/effects/{{ effect.cid }}"
class="bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-white">{{ meta.name or 'Unnamed' }}</span>
<span class="text-gray-500 text-sm">v{{ meta.version or '1.0.0' }}</span>
</div>
{% if meta.description %}
<p class="text-gray-400 text-sm mb-3 line-clamp-2">{{ meta.description }}</p>
{% endif %}
<div class="flex items-center justify-between text-sm mb-2">
{% if meta.author %}
<span class="text-gray-500">by {{ meta.author }}</span>
{% else %}
<span></span>
{% endif %}
{% if meta.temporal %}
<span class="bg-purple-900 text-purple-300 px-2 py-0.5 rounded text-xs">temporal</span>
{% endif %}
</div>
{% if meta.params %}
<div class="text-gray-500 text-sm">
{{ meta.params | length }} parameter{{ 's' if meta.params | length != 1 else '' }}
</div>
{% endif %}
{% if meta.dependencies %}
<div class="mt-2 flex flex-wrap gap-1">
{% for dep in meta.dependencies[:3] %}
<span class="bg-gray-700 text-gray-300 px-2 py-0.5 rounded text-xs">{{ dep }}</span>
{% endfor %}
{% if meta.dependencies | length > 3 %}
<span class="text-gray-500 text-xs">+{{ meta.dependencies | length - 3 }} more</span>
{% endif %}
</div>
{% endif %}
<div class="mt-3 text-gray-600 text-xs font-mono truncate">
{{ effect.cid[:24] }}...
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
<p class="text-gray-500 mb-4">No effects uploaded yet.</p>
<p class="text-gray-600 text-sm mb-6">
Effects are Python files with @effect metadata in a docstring.
</p>
<label class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium cursor-pointer inline-block">
Upload Your First Effect
<input type="file" accept=".py" class="hidden" id="effect-upload-empty" />
</label>
</div>
{% endif %}
</div>
<div id="upload-result" class="fixed bottom-4 right-4 max-w-sm"></div>
<script>
function handleEffectUpload(input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
fetch('/effects/upload', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) throw new Error('Upload failed');
return response.json();
})
.then(data => {
const resultDiv = document.getElementById('upload-result');
resultDiv.innerHTML = `
<div class="bg-green-900 border border-green-700 rounded-lg p-4">
<p class="text-green-300 font-medium">Effect uploaded!</p>
<p class="text-green-400 text-sm mt-1">${data.name} v${data.version}</p>
<p class="text-gray-400 text-xs mt-2 font-mono">${data.cid}</p>
</div>
`;
setTimeout(() => {
window.location.reload();
}, 1500);
})
.catch(error => {
const resultDiv = document.getElementById('upload-result');
resultDiv.innerHTML = `
<div class="bg-red-900 border border-red-700 rounded-lg p-4">
<p class="text-red-300 font-medium">Upload failed</p>
<p class="text-red-400 text-sm mt-1">${error.message}</p>
</div>
`;
});
input.value = '';
}
document.getElementById('effect-upload')?.addEventListener('change', function() {
handleEffectUpload(this);
});
document.getElementById('effect-upload-empty')?.addEventListener('change', function() {
handleEffectUpload(this);
});
</script>
{% endblock %}