Import L1 (celery) as l1/

This commit is contained in:
giles
2026-02-24 23:07:19 +00:00
225 changed files with 57298 additions and 0 deletions

14
l1/app/templates/404.html Normal file
View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Not Found - Art-DAG L1{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto text-center py-16">
<h1 class="text-6xl font-bold text-gray-400 mb-4">404</h1>
<h2 class="text-2xl font-semibold mb-4">Page Not Found</h2>
<p class="text-gray-400 mb-8">The page you're looking for doesn't exist or has been moved.</p>
<a href="/" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium">
Go Home
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "_base.html" %}
{% block brand %}
<a href="https://blog.rose-ash.com/" class="no-underline text-stone-900">Rose Ash</a>
<span class="text-stone-400 mx-1">|</span>
<a href="/" class="no-underline text-stone-900">Art-DAG</a>
{% endblock %}
{% block cart_mini %}
{% if request and request.state.cart_mini_html %}
{{ request.state.cart_mini_html | safe }}
{% endif %}
{% endblock %}
{% block nav_tree %}
{% if request and request.state.nav_tree_html %}
{{ request.state.nav_tree_html | safe }}
{% endif %}
{% endblock %}
{% block auth_menu %}
{% if request and request.state.auth_menu_html %}
{{ request.state.auth_menu_html | safe }}
{% endif %}
{% endblock %}
{% block auth_menu_mobile %}
{% if request and request.state.auth_menu_html %}
{{ request.state.auth_menu_html | safe }}
{% endif %}
{% endblock %}
{% block sub_nav %}
<div class="bg-stone-200 border-b border-stone-300">
<div class="max-w-screen-2xl mx-auto px-4">
<nav class="flex items-center gap-4 py-2 text-sm overflow-x-auto no-scrollbar">
<a href="/runs" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'runs' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Runs{% if nav_counts and nav_counts.runs %} ({{ nav_counts.runs }}){% endif %}</a>
<a href="/recipes" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'recipes' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Recipes{% if nav_counts and nav_counts.recipes %} ({{ nav_counts.recipes }}){% endif %}</a>
<a href="/effects" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'effects' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Effects{% if nav_counts and nav_counts.effects %} ({{ nav_counts.effects }}){% endif %}</a>
<a href="/media" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'media' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Media{% if nav_counts and nav_counts.media %} ({{ nav_counts.media }}){% endif %}</a>
<a href="/storage" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'storage' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Storage{% if nav_counts and nav_counts.storage %} ({{ nav_counts.storage }}){% endif %}</a>
<a href="/download/client" class="whitespace-nowrap px-3 py-1.5 rounded text-stone-700 hover:bg-stone-300" title="Download CLI client">Client</a>
</nav>
</div>
</div>
{% endblock %}

182
l1/app/templates/cache/detail.html vendored Normal file
View File

@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}{{ cache.cid[:16] }} - Cache - Art-DAG L1{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="flex items-center space-x-4 mb-6">
<a href="/media" class="text-gray-400 hover:text-white">&larr; Media</a>
<h1 class="text-xl font-bold font-mono">{{ cache.cid[:24] }}...</h1>
</div>
<!-- Preview -->
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6 overflow-hidden">
{% if cache.mime_type and cache.mime_type.startswith('image/') %}
{% if cache.remote_only and cache.ipfs_cid %}
<img src="https://ipfs.io/ipfs/{{ cache.ipfs_cid }}" alt=""
class="w-full max-h-96 object-contain bg-gray-900">
{% else %}
<img src="/cache/{{ cache.cid }}/raw" alt=""
class="w-full max-h-96 object-contain bg-gray-900">
{% endif %}
{% elif cache.mime_type and cache.mime_type.startswith('video/') %}
{% if cache.remote_only and cache.ipfs_cid %}
<video src="https://ipfs.io/ipfs/{{ cache.ipfs_cid }}" controls
class="w-full max-h-96 bg-gray-900">
</video>
{% else %}
<video src="/cache/{{ cache.cid }}/raw" controls
class="w-full max-h-96 bg-gray-900">
</video>
{% endif %}
{% elif cache.mime_type and cache.mime_type.startswith('audio/') %}
<div class="p-8 bg-gray-900">
{% if cache.remote_only and cache.ipfs_cid %}
<audio src="https://ipfs.io/ipfs/{{ cache.ipfs_cid }}" controls class="w-full"></audio>
{% else %}
<audio src="/cache/{{ cache.cid }}/raw" controls class="w-full"></audio>
{% endif %}
</div>
{% elif cache.mime_type == 'application/json' %}
<div class="p-4 bg-gray-900 max-h-96 overflow-auto">
<pre class="text-sm text-gray-300">{{ cache.content_preview }}</pre>
</div>
{% else %}
<div class="p-8 bg-gray-900 text-center text-gray-500">
<div class="text-4xl mb-2">{{ cache.mime_type or 'Unknown type' }}</div>
<div>{{ cache.size | filesizeformat if cache.size else 'Unknown size' }}</div>
</div>
{% endif %}
</div>
<!-- Friendly Name -->
<div id="friendly-name-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
<div class="flex items-center justify-between mb-2">
<span class="text-gray-500 text-sm">Friendly Name</span>
<button hx-get="/cache/{{ cache.cid }}/name-form"
hx-target="#friendly-name-section"
hx-swap="innerHTML"
class="text-blue-400 hover:text-blue-300 text-sm">
Edit
</button>
</div>
{% if cache.friendly_name %}
<p class="text-blue-400 font-medium text-lg">{{ cache.friendly_name }}</p>
<p class="text-gray-500 text-xs mt-1">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">{{ cache.base_name }}</code></p>
{% else %}
<p class="text-gray-500 text-sm">No friendly name assigned. Click Edit to add one.</p>
{% endif %}
</div>
<!-- User Metadata (editable) -->
<div id="metadata-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold">Details</h3>
<button hx-get="/cache/{{ cache.cid }}/meta-form"
hx-target="#metadata-section"
hx-swap="innerHTML"
class="text-blue-400 hover:text-blue-300 text-sm">
Edit
</button>
</div>
{% if cache.title or cache.description or cache.filename %}
<div class="space-y-2 mb-4">
{% if cache.title %}
<h4 class="text-white font-medium">{{ cache.title }}</h4>
{% elif cache.filename %}
<h4 class="text-white font-medium">{{ cache.filename }}</h4>
{% endif %}
{% if cache.description %}
<p class="text-gray-400">{{ cache.description }}</p>
{% endif %}
</div>
{% else %}
<p class="text-gray-500 text-sm mb-4">No title or description set. Click Edit to add metadata.</p>
{% endif %}
{% if cache.tags %}
<div class="flex flex-wrap gap-2 mb-4">
{% for tag in cache.tags %}
<span class="bg-gray-700 text-gray-300 px-2 py-1 rounded text-sm">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
{% if cache.source_type or cache.source_note %}
<div class="text-sm text-gray-500">
{% if cache.source_type %}Source: {{ cache.source_type }}{% endif %}
{% if cache.source_note %} - {{ cache.source_note }}{% endif %}
</div>
{% endif %}
</div>
<!-- Technical Metadata -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">CID</div>
<div class="font-mono text-sm text-white break-all">{{ cache.cid }}</div>
</div>
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">Content Type</div>
<div class="text-white">{{ cache.mime_type or 'Unknown' }}</div>
</div>
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">Size</div>
<div class="text-white">{{ cache.size | filesizeformat if cache.size else 'Unknown' }}</div>
</div>
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">Created</div>
<div class="text-white">{{ cache.created_at or 'Unknown' }}</div>
</div>
</div>
<!-- IPFS -->
{% if cache.ipfs_cid %}
<div class="bg-gray-800 rounded-lg p-4 mb-6">
<div class="text-gray-500 text-sm mb-1">IPFS CID</div>
<div class="flex items-center justify-between">
<span class="font-mono text-sm text-white">{{ cache.ipfs_cid }}</span>
<a href="https://ipfs.io/ipfs/{{ cache.ipfs_cid }}"
target="_blank"
class="text-blue-400 hover:text-blue-300 text-sm">
View on IPFS Gateway &rarr;
</a>
</div>
</div>
{% endif %}
<!-- Related Runs -->
{% if cache.runs %}
<h2 class="text-lg font-semibold mb-4">Related Runs</h2>
<div class="space-y-2">
{% for run in cache.runs %}
<a href="/runs/{{ run.run_id }}"
class="block bg-gray-800 rounded p-3 hover:bg-gray-750 transition-colors">
<div class="flex items-center justify-between">
<span class="font-mono text-sm">{{ run.run_id[:16] }}...</span>
<span class="text-gray-500 text-sm">{{ run.created_at }}</span>
</div>
</a>
{% endfor %}
</div>
{% endif %}
<!-- Actions -->
<div class="flex items-center space-x-4 mt-8">
<a href="/cache/{{ cache.cid }}/raw"
download
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
Download
</a>
<button hx-post="/cache/{{ cache.cid }}/publish"
hx-target="#share-result"
class="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded font-medium">
Share to L2
</button>
<span id="share-result"></span>
</div>
</div>
{% endblock %}

325
l1/app/templates/cache/media_list.html vendored Normal file
View File

@@ -0,0 +1,325 @@
{% extends "base.html" %}
{% block title %}Media - 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">Media</h1>
<div class="flex items-center space-x-4">
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
Upload Media
</button>
<select id="type-filter" onchange="filterMedia()"
class="bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white">
<option value="">All Types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
</select>
</div>
</div>
<!-- Upload Modal -->
<div id="upload-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md border border-gray-700">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Upload Media</h2>
<button onclick="document.getElementById('upload-modal').classList.add('hidden')"
class="text-gray-400 hover:text-white">
<svg 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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form id="upload-form" enctype="multipart/form-data" class="space-y-4">
<div>
<label class="block text-gray-400 text-sm mb-1">Files</label>
<input type="file" name="files" id="upload-file" required multiple
accept="image/*,video/*,audio/*"
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
<p class="text-gray-500 text-xs mt-1">Select one or more files to upload</p>
</div>
<div id="single-name-field">
<label class="block text-gray-400 text-sm mb-1">Name (optional, for single file)</label>
<input type="text" name="display_name" id="upload-name" placeholder="e.g., my-background-video"
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
<p class="text-gray-500 text-xs mt-1">A friendly name to reference this media in recipes</p>
</div>
<div id="upload-progress" class="hidden">
<div class="bg-gray-700 rounded-full h-2">
<div id="progress-bar" class="bg-blue-600 h-2 rounded-full transition-all" style="width: 0%"></div>
</div>
<p id="progress-text" class="text-gray-400 text-sm mt-1">Uploading...</p>
</div>
<div id="upload-result" class="hidden max-h-48 overflow-y-auto"></div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('upload-modal').classList.add('hidden')"
class="px-4 py-2 rounded border border-gray-600 hover:bg-gray-700">
Cancel
</button>
<button type="submit" id="upload-btn"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
Upload
</button>
</div>
</form>
</div>
</div>
{% if items %}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id="media-grid">
{% for item in items %}
{# Determine media category from type or filename #}
{% set is_image = item.type in ('image', 'image/jpeg', 'image/png', 'image/gif', 'image/webp') or (item.filename and item.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp'))) %}
{% set is_video = item.type in ('video', 'video/mp4', 'video/webm', 'video/x-matroska') or (item.filename and item.filename.lower().endswith(('.mp4', '.mkv', '.webm', '.mov'))) %}
{% set is_audio = item.type in ('audio', 'audio/mpeg', 'audio/wav', 'audio/flac') or (item.filename and item.filename.lower().endswith(('.mp3', '.wav', '.flac', '.ogg'))) %}
<a href="/cache/{{ item.cid }}"
class="media-item bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
data-type="{% if is_image %}image{% elif is_video %}video{% elif is_audio %}audio{% else %}other{% endif %}">
{% if is_image %}
<img src="/cache/{{ item.cid }}/raw"
alt=""
loading="lazy"
class="w-full h-40 object-cover">
{% elif is_video %}
<div class="relative">
<video src="/cache/{{ item.cid }}/raw"
class="w-full h-40 object-cover"
muted
onmouseover="this.play()"
onmouseout="this.pause(); this.currentTime=0;">
</video>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="bg-black bg-opacity-50 rounded-full p-2">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"/>
</svg>
</div>
</div>
</div>
{% elif is_audio %}
<div class="w-full h-40 bg-gray-900 flex flex-col items-center justify-center">
<svg class="w-12 h-12 text-gray-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
<span class="text-gray-500 text-sm">Audio</span>
</div>
{% else %}
<div class="w-full h-40 bg-gray-900 flex items-center justify-center">
<span class="text-gray-600 text-sm">{{ item.type or 'Media' }}</span>
</div>
{% endif %}
<div class="p-3">
{% if item.friendly_name %}
<div class="text-xs text-blue-400 font-medium truncate">{{ item.friendly_name }}</div>
{% else %}
<div class="font-mono text-xs text-gray-500 truncate">{{ item.cid[:16] }}...</div>
{% endif %}
{% if item.filename %}
<div class="text-xs text-gray-600 truncate">{{ item.filename }}</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% if has_more %}
<div hx-get="/media?offset={{ offset + limit }}"
hx-trigger="revealed"
hx-swap="beforeend"
hx-target="#media-grid"
hx-select=".media-item"
class="h-20 flex items-center justify-center text-gray-500 mt-4">
Loading more...
</div>
{% endif %}
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-gray-500 mb-4">No media files yet</p>
<p class="text-gray-600 text-sm">Run a recipe to generate media artifacts.</p>
</div>
{% endif %}
</div>
<script>
function filterMedia() {
const filter = document.getElementById('type-filter').value;
document.querySelectorAll('.media-item').forEach(item => {
if (!filter || item.dataset.type === filter) {
item.classList.remove('hidden');
} else {
item.classList.add('hidden');
}
});
}
// Show/hide name field based on file count
document.getElementById('upload-file').addEventListener('change', function(e) {
const nameField = document.getElementById('single-name-field');
if (e.target.files.length > 1) {
nameField.style.display = 'none';
} else {
nameField.style.display = 'block';
}
});
// Handle upload form
document.getElementById('upload-form').addEventListener('submit', async function(e) {
e.preventDefault();
const form = e.target;
const fileInput = document.getElementById('upload-file');
const files = fileInput.files;
const displayName = document.getElementById('upload-name').value;
const progressDiv = document.getElementById('upload-progress');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const resultDiv = document.getElementById('upload-result');
const uploadBtn = document.getElementById('upload-btn');
// Show progress
progressDiv.classList.remove('hidden');
resultDiv.classList.add('hidden');
uploadBtn.disabled = true;
const results = [];
const errors = [];
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
for (let i = 0; i < files.length; i++) {
const file = files[i];
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadId = crypto.randomUUID();
const useChunked = file.size > CHUNK_SIZE * 2; // Use chunked for files > 2MB
progressText.textContent = `Uploading ${i + 1} of ${files.length}: ${file.name}`;
try {
let data;
if (useChunked && totalChunks > 1) {
// Chunked upload for large files
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const chunkForm = new FormData();
chunkForm.append('chunk', chunk);
chunkForm.append('upload_id', uploadId);
chunkForm.append('chunk_index', chunkIndex);
chunkForm.append('total_chunks', totalChunks);
chunkForm.append('filename', file.name);
if (files.length === 1 && displayName) {
chunkForm.append('display_name', displayName);
}
const chunkProgress = ((i + (chunkIndex + 1) / totalChunks) / files.length) * 100;
progressBar.style.width = `${chunkProgress}%`;
progressText.textContent = `Uploading ${i + 1} of ${files.length}: ${file.name} (${chunkIndex + 1}/${totalChunks} chunks)`;
const response = await fetch('/media/upload/chunk', {
method: 'POST',
body: chunkForm,
});
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
throw new Error(`Server error (${response.status}): ${text.substring(0, 100)}`);
}
data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Chunk upload failed');
}
}
} else {
// Regular upload for small files
const formData = new FormData();
formData.append('file', file);
if (files.length === 1 && displayName) {
formData.append('display_name', displayName);
}
progressBar.style.width = `${((i + 0.5) / files.length) * 100}%`;
const response = await fetch('/media/upload', {
method: 'POST',
body: formData,
});
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
throw new Error(`Server error (${response.status}): ${text.substring(0, 100)}`);
}
data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Upload failed');
}
}
results.push({ filename: file.name, friendly_name: data.friendly_name, cid: data.cid });
} catch (err) {
errors.push({ filename: file.name, error: err.message });
}
progressBar.style.width = `${((i + 1) / files.length) * 100}%`;
}
progressText.textContent = 'Upload complete!';
// Show results
let html = '';
if (results.length > 0) {
html += '<div class="bg-green-900 border border-green-700 rounded p-3 text-green-300 mb-2">';
html += `<p class="font-medium">${results.length} file(s) uploaded successfully!</p>`;
for (const r of results) {
html += `<p class="text-sm mt-1">${r.filename} → <span class="font-mono">${r.friendly_name}</span></p>`;
}
html += '</div>';
}
if (errors.length > 0) {
html += '<div class="bg-red-900 border border-red-700 rounded p-3 text-red-300">';
html += `<p class="font-medium">${errors.length} file(s) failed:</p>`;
for (const e of errors) {
html += `<p class="text-sm mt-1">${e.filename}: ${e.error}</p>`;
}
html += '</div>';
}
resultDiv.innerHTML = html;
resultDiv.classList.remove('hidden');
if (results.length > 0) {
// Reload page after 2 seconds
setTimeout(() => location.reload(), 2000);
} else {
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload';
}
});
</script>
{% endblock %}

21
l1/app/templates/cache/not_found.html vendored Normal file
View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Content Not Found - Art-DAG L1{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto text-center py-16">
<h1 class="text-6xl font-bold text-gray-400 mb-4">404</h1>
<h2 class="text-2xl font-semibold mb-4">Content Not Found</h2>
<p class="text-gray-400 mb-8">
The content with hash <code class="bg-gray-800 px-2 py-1 rounded">{{ cid[:24] if cid else 'unknown' }}...</code> was not found in the cache.
</p>
<div class="flex justify-center gap-4">
<a href="/cache/" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium">
Browse Media
</a>
<a href="/" class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-medium">
Go Home
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,203 @@
{% 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/lisp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/scheme.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 %}
<!-- Friendly Name & CID Info -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-6">
{% if effect.friendly_name %}
<div class="mb-4 pb-4 border-b border-gray-700">
<span class="text-gray-500 text-sm">Friendly Name</span>
<p class="text-blue-400 font-medium text-lg mt-1">{{ effect.friendly_name }}</p>
<p class="text-gray-500 text-xs mt-1">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">(effect {{ effect.base_name }})</code></p>
</div>
{% endif %}
<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 %}
<!-- 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">
{% if effect.base_name %}
<pre class="text-sm text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto"><code class="language-lisp">({{ effect.base_name }} ...)</code></pre>
<p class="text-gray-500 text-xs mt-2">
Use the friendly name to reference this effect.
</p>
{% else %}
<pre class="text-sm text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto"><code class="language-lisp">(effect :cid "{{ effect.cid }}")</code></pre>
<p class="text-gray-500 text-xs mt-2">
Reference this effect by CID in your recipe.
</p>
{% endif %}
</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 (S-expression)</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' }}.sexp">
Download
</a>
</div>
</div>
<div class="p-4">
<pre class="text-sm overflow-x-auto rounded bg-gray-900"><code class="language-lisp" id="source-code">Loading...</code></pre>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-4 mt-8">
{% if effect.cid.startswith('Qm') or effect.cid.startswith('bafy') %}
<a href="https://ipfs.io/ipfs/{{ effect.cid }}"
target="_blank"
class="bg-cyan-600 hover:bg-cyan-700 px-4 py-2 rounded font-medium">
View on IPFS
</a>
{% endif %}
<button hx-post="/effects/{{ effect.cid }}/publish"
hx-target="#action-result"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
Share to L2
</button>
<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,200 @@
{% 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>
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
Upload Effect
</button>
</div>
<!-- Upload Modal -->
<div id="upload-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md border border-gray-700">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Upload Effect</h2>
<button onclick="document.getElementById('upload-modal').classList.add('hidden')"
class="text-gray-400 hover:text-white">
<svg 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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form id="upload-form" enctype="multipart/form-data" class="space-y-4">
<div>
<label class="block text-gray-400 text-sm mb-1">Effect File (.sexp)</label>
<input type="file" name="file" id="upload-file" required
accept=".sexp,.lisp"
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
</div>
<div>
<label class="block text-gray-400 text-sm mb-1">Friendly Name (optional)</label>
<input type="text" name="display_name" id="upload-name" placeholder="e.g., color-shift"
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
<p class="text-gray-500 text-xs mt-1">A name to reference this effect in recipes</p>
</div>
<div id="upload-result" class="hidden"></div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('upload-modal').classList.add('hidden')"
class="px-4 py-2 rounded border border-gray-600 hover:bg-gray-700">
Cancel
</button>
<button type="submit" id="upload-btn"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
Upload
</button>
</div>
</form>
</div>
</div>
<p class="text-gray-400 mb-8">
Effects are S-expression files that define video processing operations.
Each effect is stored in IPFS and can be referenced by name in recipes.
</p>
{% if effects %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="effects-list">
{% for effect in effects %}
{% set meta = effect.meta or effect %}
<a href="/effects/{{ effect.cid }}"
class="effect-card 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 %}
<div class="mt-3 text-xs">
{% if effect.friendly_name %}
<span class="text-blue-400 font-medium">{{ effect.friendly_name }}</span>
{% else %}
<span class="text-gray-600 font-mono truncate">{{ effect.cid[:24] }}...</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% if has_more %}
<div hx-get="/effects?offset={{ offset + limit }}&limit={{ limit }}"
hx-trigger="revealed"
hx-swap="afterend"
hx-select="#effects-list > *"
class="h-20 flex items-center justify-center text-gray-500">
Loading more...
</div>
{% endif %}
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
<p class="text-gray-500 mb-4">No effects uploaded yet.</p>
<p class="text-gray-600 text-sm mb-6">
Effects are S-expression files with metadata in comment headers.
</p>
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium">
Upload Your First Effect
</button>
</div>
{% endif %}
</div>
<script>
// Handle upload form
document.getElementById('upload-form').addEventListener('submit', async function(e) {
e.preventDefault();
const form = e.target;
const fileInput = document.getElementById('upload-file');
const displayName = document.getElementById('upload-name').value;
const resultDiv = document.getElementById('upload-result');
const uploadBtn = document.getElementById('upload-btn');
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
if (displayName) {
formData.append('display_name', displayName);
}
uploadBtn.disabled = true;
uploadBtn.textContent = 'Uploading...';
resultDiv.classList.add('hidden');
try {
const response = await fetch('/effects/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok) {
resultDiv.innerHTML = `
<div class="bg-green-900 border border-green-700 rounded p-3 text-green-300">
<p class="font-medium">Effect uploaded!</p>
<p class="text-sm mt-1">${data.name} <span class="font-mono">${data.friendly_name}</span></p>
</div>
`;
resultDiv.classList.remove('hidden');
setTimeout(() => location.reload(), 1500);
} else {
resultDiv.innerHTML = `
<div class="bg-red-900 border border-red-700 rounded p-3 text-red-300">
<p class="font-medium">Upload failed</p>
<p class="text-sm mt-1">${data.detail || 'Unknown error'}</p>
</div>
`;
resultDiv.classList.remove('hidden');
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload';
}
} catch (error) {
resultDiv.innerHTML = `
<div class="bg-red-900 border border-red-700 rounded p-3 text-red-300">
<p class="font-medium">Upload failed</p>
<p class="text-sm mt-1">${error.message}</p>
</div>
`;
resultDiv.classList.remove('hidden');
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload';
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,22 @@
<a href="{{ link }}" class="block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" data-fragment="link-card" data-app="artdag" data-hx-disable>
<div class="flex flex-row items-center gap-3 p-3">
<div class="flex-shrink-0 w-10 h-10 rounded bg-stone-100 flex items-center justify-center text-stone-500">
{% if content_type == "recipe" %}
<i class="fas fa-scroll text-sm"></i>
{% elif content_type == "effect" %}
<i class="fas fa-magic text-sm"></i>
{% elif content_type == "run" %}
<i class="fas fa-play-circle text-sm"></i>
{% else %}
<i class="fas fa-cube text-sm"></i>
{% endif %}
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-stone-900 text-sm truncate">{{ title }}</div>
{% if description %}
<div class="text-xs text-stone-500 clamp-2">{{ description }}</div>
{% endif %}
<div class="text-xs text-stone-400 mt-0.5">{{ content_type }} &middot; {{ cid[:12] }}&hellip;</div>
</div>
</div>
</a>

View File

@@ -0,0 +1,7 @@
<div class="relative nav-group">
<a href="{{ artdag_url }}"
class="justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
data-hx-disable>
<i class="fas fa-project-diagram text-sm"></i> art-dag
</a>
</div>

View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Art-DAG L1{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto text-center py-12">
<h1 class="text-4xl font-bold mb-4">Art-DAG L1</h1>
<p class="text-xl text-gray-400 mb-8">Content-Addressable Media Processing</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-3xl mx-auto mb-12">
<a href="/runs"
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-blue-500 transition-colors">
<div class="text-blue-400 text-3xl font-bold mb-2">{{ stats.runs or 0 }}</div>
<div class="text-gray-400">Execution Runs</div>
</a>
<a href="/recipes"
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-green-500 transition-colors">
<div class="text-green-400 text-3xl font-bold mb-2">{{ stats.recipes or 0 }}</div>
<div class="text-gray-400">Recipes</div>
</a>
<a href="/effects"
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-cyan-500 transition-colors">
<div class="text-cyan-400 text-3xl font-bold mb-2">{{ stats.effects or 0 }}</div>
<div class="text-gray-400">Effects</div>
</a>
<a href="/media"
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-purple-500 transition-colors">
<div class="text-purple-400 text-3xl font-bold mb-2">{{ stats.media or 0 }}</div>
<div class="text-gray-400">Media Files</div>
</a>
<a href="/storage"
class="bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-orange-500 transition-colors">
<div class="text-orange-400 text-3xl font-bold mb-2">{{ stats.storage or 0 }}</div>
<div class="text-gray-400">Storage Providers</div>
</a>
</div>
{% if not user %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-8 max-w-md mx-auto mb-12">
<p class="text-gray-400 mb-4">Sign in through your L2 server to access all features.</p>
<a href="/auth" class="text-blue-400 hover:text-blue-300">Sign In &rarr;</a>
</div>
{% endif %}
{% if readme_html %}
<div class="text-left bg-gray-800 border border-gray-700 rounded-lg p-8 prose prose-invert max-w-none">
{{ readme_html | safe }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,265 @@
{% extends "base.html" %}
{% block title %}{{ recipe.name }} - Recipe - Art-DAG L1{% endblock %}
{% block head %}
{{ super() }}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.23.0/cytoscape.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.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="/recipes" class="text-gray-400 hover:text-white">&larr; Recipes</a>
<h1 class="text-2xl font-bold">{{ recipe.name or 'Unnamed Recipe' }}</h1>
{% if recipe.version %}
<span class="text-gray-500">v{{ recipe.version }}</span>
{% endif %}
</div>
{% if recipe.description %}
<p class="text-gray-400 mb-4">{{ recipe.description }}</p>
{% endif %}
<!-- Metadata -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span class="text-gray-500">Recipe ID</span>
<p class="text-gray-300 font-mono text-xs truncate" title="{{ recipe.recipe_id }}">{{ recipe.recipe_id[:16] }}...</p>
</div>
{% if recipe.ipfs_cid %}
<div>
<span class="text-gray-500">IPFS CID</span>
<p class="text-gray-300 font-mono text-xs truncate" title="{{ recipe.ipfs_cid }}">{{ recipe.ipfs_cid[:16] }}...</p>
</div>
{% endif %}
<div>
<span class="text-gray-500">Steps</span>
<p class="text-gray-300">{{ recipe.step_count or recipe.steps|length }}</p>
</div>
{% if recipe.author %}
<div>
<span class="text-gray-500">Author</span>
<p class="text-gray-300">{{ recipe.author }}</p>
</div>
{% endif %}
</div>
</div>
{% if recipe.type == 'streaming' %}
<!-- Streaming Recipe Info -->
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6 p-4">
<div class="flex items-center space-x-2 mb-2">
<span class="bg-purple-900 text-purple-300 px-2 py-1 rounded text-sm">Streaming Recipe</span>
</div>
<p class="text-gray-400 text-sm">
This recipe uses frame-by-frame streaming rendering. The pipeline is defined as an S-expression that generates frames dynamically.
</p>
</div>
{% else %}
<!-- DAG Visualization -->
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6">
<div class="border-b border-gray-700 px-4 py-2 flex items-center justify-between">
<span class="text-gray-400 text-sm">Pipeline DAG</span>
<span class="text-gray-500 text-sm">{{ recipe.steps | length }} steps</span>
</div>
<div id="dag-container" class="h-80"></div>
</div>
<!-- Steps -->
<h2 class="text-lg font-semibold mb-4">Steps</h2>
<div class="space-y-3 mb-8">
{% for step in recipe.steps %}
{% set colors = {
'effect': 'blue',
'analyze': 'purple',
'transform': 'green',
'combine': 'orange',
'output': 'cyan'
} %}
{% set color = colors.get(step.type, 'gray') %}
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3">
<span class="w-8 h-8 rounded bg-{{ color }}-900 text-{{ color }}-300 flex items-center justify-center font-mono text-sm">
{{ loop.index }}
</span>
<span class="font-medium">{{ step.name }}</span>
<span class="bg-{{ color }}-900 text-{{ color }}-300 px-2 py-0.5 rounded text-xs">
{{ step.type }}
</span>
</div>
</div>
{% if step.inputs %}
<div class="text-sm text-gray-400 mb-1">
Inputs: {{ step.inputs | join(', ') }}
</div>
{% endif %}
{% if step.params %}
<div class="mt-2 bg-gray-900 rounded p-2">
<code class="text-xs text-gray-400">{{ step.params | tojson }}</code>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Source Code -->
<h2 class="text-lg font-semibold mb-4">Recipe (S-expression)</h2>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
{% if recipe.sexp %}
<pre class="text-sm font-mono text-gray-300 overflow-x-auto whitespace-pre-wrap sexp-code">{{ recipe.sexp }}</pre>
{% else %}
<p class="text-gray-500">No source available</p>
{% endif %}
</div>
<script>
// Single-pass S-expression syntax highlighter (avoids regex corruption)
function highlightSexp(text) {
const SPECIAL = new Set(['plan','recipe','def','->','stream','let','lambda','if','cond','define']);
const PRIMS = new Set(['source','effect','sequence','segment','resize','transform','layer','blend','mux','analyze','fused-pipeline']);
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function span(cls, s) { return '<span class="' + cls + '">' + esc(s) + '</span>'; }
let out = '', i = 0, len = text.length;
while (i < len) {
if (text[i] === ';' && i + 1 < len && text[i+1] === ';') {
let end = text.indexOf('\n', i);
if (end === -1) end = len;
out += span('text-gray-500', text.slice(i, end));
i = end;
}
else if (text[i] === '"') {
let j = i + 1;
while (j < len && text[j] !== '"') { if (text[j] === '\\') j++; j++; }
if (j < len) j++;
out += span('text-green-400', text.slice(i, j));
i = j;
}
else if (text[i] === ':' && i + 1 < len && /[a-zA-Z_-]/.test(text[i+1])) {
let j = i + 1;
while (j < len && /[a-zA-Z0-9_-]/.test(text[j])) j++;
out += span('text-purple-400', text.slice(i, j));
i = j;
}
else if (text[i] === '(') {
out += span('text-yellow-500', '(');
i++;
let ws = '';
while (i < len && (text[i] === ' ' || text[i] === '\t')) { ws += text[i]; i++; }
out += esc(ws);
if (i < len && /[a-zA-Z_>-]/.test(text[i])) {
let j = i;
while (j < len && /[a-zA-Z0-9_>-]/.test(text[j])) j++;
let word = text.slice(i, j);
if (SPECIAL.has(word)) out += span('text-pink-400 font-semibold', word);
else if (PRIMS.has(word)) out += span('text-blue-400', word);
else out += esc(word);
i = j;
}
}
else if (text[i] === ')') {
out += span('text-yellow-500', ')');
i++;
}
else if (/[0-9]/.test(text[i]) && (i === 0 || /[\s(]/.test(text[i-1]))) {
let j = i;
while (j < len && /[0-9.]/.test(text[j])) j++;
out += span('text-orange-300', text.slice(i, j));
i = j;
}
else {
let j = i;
while (j < len && !'(;":)'.includes(text[j])) {
if (text[j] === ':' && j + 1 < len && /[a-zA-Z_-]/.test(text[j+1])) break;
if (/[0-9]/.test(text[j]) && (j === 0 || /[\s(]/.test(text[j-1]))) break;
j++;
}
if (j === i) { out += esc(text[i]); i++; }
else { out += esc(text.slice(i, j)); i = j; }
}
}
return out;
}
document.querySelectorAll('.sexp-code').forEach(el => {
el.innerHTML = highlightSexp(el.textContent);
});
</script>
<!-- Actions -->
<div class="flex items-center space-x-4 mt-8">
<button hx-post="/runs/rerun/{{ recipe.recipe_id }}"
hx-target="#action-result"
hx-swap="innerHTML"
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded font-medium">
Run Recipe
</button>
{% if recipe.ipfs_cid %}
<a href="https://ipfs.io/ipfs/{{ recipe.ipfs_cid }}"
target="_blank"
class="bg-cyan-600 hover:bg-cyan-700 px-4 py-2 rounded font-medium">
View on IPFS
</a>
{% elif recipe.recipe_id.startswith('Qm') or recipe.recipe_id.startswith('bafy') %}
<a href="https://ipfs.io/ipfs/{{ recipe.recipe_id }}"
target="_blank"
class="bg-cyan-600 hover:bg-cyan-700 px-4 py-2 rounded font-medium">
View on IPFS
</a>
{% endif %}
<button hx-post="/recipes/{{ recipe.recipe_id }}/publish"
hx-target="#action-result"
class="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded font-medium">
Share to L2
</button>
<button hx-delete="/recipes/{{ recipe.recipe_id }}/ui"
hx-target="#action-result"
hx-confirm="Delete this recipe? This cannot be undone."
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() {
const cy = cytoscape({
container: document.getElementById('dag-container'),
style: [
{ selector: 'node', style: {
'label': 'data(label)',
'background-color': 'data(color)',
'color': '#fff',
'text-valign': 'center',
'text-halign': 'center',
'font-size': '11px',
'width': 'label',
'height': 35,
'padding': '10px',
'shape': 'round-rectangle'
}},
{ selector: 'edge', style: {
'width': 2,
'line-color': '#4b5563',
'target-arrow-color': '#4b5563',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier'
}}
],
elements: {{ dag_elements | tojson }},
layout: { name: 'dagre', rankDir: 'LR', padding: 30 }
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}Recipes - 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">Recipes</h1>
<label class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium cursor-pointer">
Upload Recipe
<input type="file" accept=".sexp,.yaml,.yml" class="hidden" id="recipe-upload" />
</label>
</div>
<p class="text-gray-400 mb-8">
Recipes define processing pipelines for audio and media. Each recipe is a DAG of effects.
</p>
{% if recipes %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="recipes-list">
{% for recipe in recipes %}
<a href="/recipes/{{ recipe.recipe_id }}"
class="recipe-card 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">{{ recipe.name }}</span>
{% if recipe.version %}
<span class="text-gray-500 text-sm">v{{ recipe.version }}</span>
{% endif %}
</div>
{% if recipe.description %}
<p class="text-gray-400 text-sm mb-3 line-clamp-2">{{ recipe.description }}</p>
{% endif %}
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500">{{ recipe.step_count or 0 }} steps</span>
{% if recipe.last_run %}
<span class="text-gray-500">Last run: {{ recipe.last_run }}</span>
{% endif %}
</div>
{% if recipe.tags %}
<div class="mt-2 flex flex-wrap gap-1">
{% for tag in recipe.tags %}
<span class="bg-gray-700 text-gray-300 px-2 py-0.5 rounded text-xs">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<div class="mt-3 text-xs">
{% if recipe.friendly_name %}
<span class="text-blue-400 font-medium">{{ recipe.friendly_name }}</span>
{% else %}
<span class="text-gray-600 font-mono truncate">{{ recipe.recipe_id[:24] }}...</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% if has_more %}
<div hx-get="/recipes?offset={{ offset + limit }}&limit={{ limit }}"
hx-trigger="revealed"
hx-swap="afterend"
hx-select="#recipes-list > *"
class="h-20 flex items-center justify-center text-gray-500">
Loading more...
</div>
{% endif %}
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
<p class="text-gray-500 mb-4">No recipes available.</p>
<p class="text-gray-600 text-sm mb-6">
Recipes are S-expression files (.sexp) that define processing pipelines.
</p>
<label class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium cursor-pointer inline-block">
Upload Your First Recipe
<input type="file" accept=".sexp,.yaml,.yml" class="hidden" id="recipe-upload-empty" />
</label>
</div>
{% endif %}
</div>
<div id="upload-result" class="fixed bottom-4 right-4 max-w-sm"></div>
<script>
function handleRecipeUpload(input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
fetch('/recipes/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">Recipe 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.recipe_id}</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('recipe-upload')?.addEventListener('change', function() {
handleRecipeUpload(this);
});
document.getElementById('recipe-upload-empty')?.addEventListener('change', function() {
handleRecipeUpload(this);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{# Run card partial - expects 'run' variable #}
{% set status_colors = {
'completed': 'green',
'running': 'blue',
'pending': 'yellow',
'failed': 'red',
'cached': 'purple'
} %}
{% set color = status_colors.get(run.status, 'gray') %}
<a href="/runs/{{ run.run_id }}"
class="block 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">
<div class="flex items-center space-x-3">
<span class="font-mono text-sm text-gray-400">{{ run.run_id[:12] }}...</span>
<span class="bg-{{ color }}-900 text-{{ color }}-300 px-2 py-0.5 rounded text-xs uppercase">
{{ run.status }}
</span>
{% if run.cached %}
<span class="bg-purple-900 text-purple-300 px-2 py-0.5 rounded text-xs">cached</span>
{% endif %}
</div>
<span class="text-gray-500 text-sm">{{ run.created_at }}</span>
</div>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-4 text-sm">
<span class="text-gray-400">
Recipe: <span class="text-white">{{ run.recipe_name or (run.recipe[:12] ~ '...' if run.recipe and run.recipe|length > 12 else run.recipe) or 'Unknown' }}</span>
</span>
{% if run.total_steps %}
<span class="text-gray-400">
Steps: <span class="text-white">{{ run.executed or 0 }}/{{ run.total_steps }}</span>
</span>
{% endif %}
</div>
</div>
{# Media previews row #}
<div class="flex items-center space-x-4">
{# Input previews #}
{% if run.input_previews %}
<div class="flex items-center space-x-1">
<span class="text-xs text-gray-500 mr-1">In:</span>
{% for inp in run.input_previews %}
{% if inp.media_type and inp.media_type.startswith('image/') %}
<img src="/cache/{{ inp.cid }}/raw" alt="" class="w-10 h-10 object-cover rounded">
{% elif inp.media_type and inp.media_type.startswith('video/') %}
<video src="/cache/{{ inp.cid }}/raw" class="w-10 h-10 object-cover rounded" muted></video>
{% else %}
<div class="w-10 h-10 bg-gray-700 rounded flex items-center justify-center text-gray-500 text-xs">?</div>
{% endif %}
{% endfor %}
{% if run.inputs and run.inputs|length > 3 %}
<span class="text-xs text-gray-500">+{{ run.inputs|length - 3 }}</span>
{% endif %}
</div>
{% elif run.inputs %}
<div class="text-xs text-gray-500">
{{ run.inputs|length }} input(s)
</div>
{% endif %}
{# Arrow #}
<span class="text-gray-600">-></span>
{# Output preview - prefer IPFS URLs when available #}
{% if run.output_cid %}
<div class="flex items-center space-x-1">
<span class="text-xs text-gray-500 mr-1">Out:</span>
{% if run.output_media_type and run.output_media_type.startswith('image/') %}
<img src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" alt="" class="w-10 h-10 object-cover rounded">
{% elif run.output_media_type and run.output_media_type.startswith('video/') %}
<video src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" class="w-10 h-10 object-cover rounded" muted></video>
{% else %}
<div class="w-10 h-10 bg-gray-700 rounded flex items-center justify-center text-gray-500 text-xs">?</div>
{% endif %}
</div>
{% else %}
<span class="text-xs text-gray-500">No output yet</span>
{% endif %}
<div class="flex-grow"></div>
{% if run.output_cid %}
<span class="font-mono text-xs text-gray-600">{{ run.output_cid[:12] }}...</span>
{% endif %}
</div>
</a>

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Run Artifacts{% endblock %}
{% block content %}
<div class="mb-6">
<a href="/runs/{{ run_id }}/detail" class="inline-flex items-center text-blue-400 hover:text-blue-300">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Run
</a>
</div>
<h1 class="text-2xl font-bold text-white mb-6">Run Artifacts</h1>
{% if artifacts %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for artifact in artifacts %}
<div class="bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<span class="px-2 py-1 text-xs rounded
{% if artifact.role == 'input' %}bg-blue-600
{% elif artifact.role == 'output' %}bg-green-600
{% else %}bg-purple-600{% endif %}">
{{ artifact.role }}
</span>
<span class="text-sm text-gray-400">{{ artifact.step_name }}</span>
</div>
<div class="mb-3">
<p class="text-xs text-gray-500 mb-1">Content Hash</p>
<p class="font-mono text-xs text-gray-300 truncate">{{ artifact.hash }}</p>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">
{% if artifact.media_type == 'video' %}Video
{% elif artifact.media_type == 'image' %}Image
{% elif artifact.media_type == 'audio' %}Audio
{% else %}File{% endif %}
</span>
<span class="text-gray-500">{{ (artifact.size_bytes / 1024)|round(1) }} KB</span>
</div>
<div class="mt-3 flex gap-2">
<a href="/cache/{{ artifact.hash }}" class="flex-1 px-3 py-1 bg-gray-700 hover:bg-gray-600 text-center text-sm rounded transition-colors">
View
</a>
<a href="/cache/{{ artifact.hash }}/raw" class="flex-1 px-3 py-1 bg-blue-600 hover:bg-blue-700 text-center text-sm rounded transition-colors">
Download
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-800 rounded-lg p-6 text-center">
<p class="text-gray-400">No artifacts found for this run.</p>
</div>
{% endif %}
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Runs - 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">Execution Runs</h1>
<a href="/recipes" class="text-gray-400 hover:text-white">Browse Recipes &rarr;</a>
</div>
{% if runs %}
<div class="space-y-4" id="runs-list">
{% for run in runs %}
{% include "runs/_run_card.html" %}
{% endfor %}
</div>
{% if has_more %}
<div hx-get="/runs?offset={{ offset + limit }}"
hx-trigger="revealed"
hx-swap="afterend"
hx-select="#runs-list > *"
class="h-20 flex items-center justify-center text-gray-500">
Loading more...
</div>
{% endif %}
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
<div class="text-gray-500 mb-4">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<p class="text-xl">No runs yet</p>
</div>
<p class="text-gray-600 mb-6">Execute a recipe to see your runs here.</p>
<a href="/recipes" class="bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded font-medium">
Browse Recipes
</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}Run Plan - {{ run_id[:16] }}{% endblock %}
{% block head %}
<script src="https://unpkg.com/cytoscape@3.25.0/dist/cytoscape.min.js"></script>
{% endblock %}
{% block content %}
<div class="mb-6">
<a href="/runs/{{ run_id }}/detail" class="inline-flex items-center text-blue-400 hover:text-blue-300">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Run
</a>
</div>
<h1 class="text-2xl font-bold text-white mb-6">Execution Plan</h1>
{% if plan %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- DAG Visualization -->
<div class="bg-gray-800 rounded-lg p-4">
<h2 class="text-lg font-semibold text-white mb-4">DAG Visualization</h2>
<div id="dag-container" class="w-full h-96 bg-gray-900 rounded"></div>
</div>
<!-- Steps List -->
<div class="bg-gray-800 rounded-lg p-4">
<h2 class="text-lg font-semibold text-white mb-4">Steps ({{ plan.steps|length if plan.steps else 0 }})</h2>
<div class="space-y-3 max-h-96 overflow-y-auto">
{% for step in plan.get('steps', []) %}
<div class="bg-gray-900 rounded-lg p-3">
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-white">{{ step.name or step.id or 'Step ' ~ loop.index }}</span>
<span class="px-2 py-0.5 text-xs rounded {% if step.status == 'completed' %}bg-green-600{% elif step.cached %}bg-blue-600{% else %}bg-gray-600{% endif %}">
{{ step.status or ('cached' if step.cached else 'pending') }}
</span>
</div>
{% if step.cache_id %}
<div class="text-xs text-gray-400 font-mono truncate">
{{ step.cache_id[:24] }}...
</div>
{% endif %}
</div>
{% else %}
<p class="text-gray-500">No steps defined</p>
{% endfor %}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const elements = {{ dag_elements | tojson | safe }};
if (elements.length > 0) {
cytoscape({
container: document.getElementById('dag-container'),
elements: elements,
style: [
{
selector: 'node',
style: {
'background-color': 'data(color)',
'label': 'data(label)',
'color': '#fff',
'text-valign': 'bottom',
'text-margin-y': 5,
'font-size': '10px'
}
},
{
selector: 'edge',
style: {
'width': 2,
'line-color': '#6b7280',
'target-arrow-color': '#6b7280',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier'
}
}
],
layout: {
name: 'breadthfirst',
directed: true,
padding: 20
}
});
}
});
</script>
{% else %}
<div class="bg-gray-800 rounded-lg p-6 text-center">
<p class="text-gray-400">No execution plan available for this run.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,99 @@
{# Plan node detail panel - loaded via HTMX #}
{% set status_color = 'green' if status in ('cached', 'completed') else 'yellow' %}
<div class="flex justify-between items-start mb-4">
<div>
<h4 class="text-lg font-semibold text-white">{{ step.name or step.step_id[:20] }}</h4>
<div class="flex items-center gap-2 mt-1">
<span class="px-2 py-0.5 rounded text-xs text-white" style="background-color: {{ node_color }}">
{{ step.node_type or 'EFFECT' }}
</span>
<span class="text-{{ status_color }}-400 text-xs">{{ status }}</span>
<span class="text-gray-500 text-xs">Level {{ step.level or 0 }}</span>
</div>
</div>
<button onclick="closeNodeDetail()" class="text-gray-400 hover:text-white p-1">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{# Output preview #}
{% if output_preview %}
<div class="mb-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Output</h5>
{% if output_media_type == 'video' %}
<video src="/cache/{{ cache_id }}/raw" controls muted class="w-full max-h-48 rounded-lg"></video>
{% elif output_media_type == 'image' %}
<img src="/cache/{{ cache_id }}/raw" class="w-full max-h-48 rounded-lg object-contain">
{% elif output_media_type == 'audio' %}
<audio src="/cache/{{ cache_id }}/raw" controls class="w-full"></audio>
{% endif %}
</div>
{% elif ipfs_cid %}
<div class="mb-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Output (IPFS)</h5>
<video src="{{ ipfs_gateway }}/{{ ipfs_cid }}" controls muted class="w-full max-h-48 rounded-lg"></video>
</div>
{% endif %}
{# Output link #}
{% if ipfs_cid %}
<a href="/ipfs/{{ ipfs_cid }}" class="flex items-center justify-between bg-gray-800 rounded p-2 hover:bg-gray-700 transition-colors text-xs mb-4">
<span class="font-mono text-gray-300 truncate">{{ ipfs_cid[:24] }}...</span>
<span class="px-2 py-1 bg-blue-600 text-white rounded ml-2">View</span>
</a>
{% elif has_cached and cache_id %}
<a href="/cache/{{ cache_id }}" class="flex items-center justify-between bg-gray-800 rounded p-2 hover:bg-gray-700 transition-colors text-xs mb-4">
<span class="font-mono text-gray-300 truncate">{{ cache_id[:24] }}...</span>
<span class="px-2 py-1 bg-blue-600 text-white rounded ml-2">View</span>
</a>
{% endif %}
{# Input media previews #}
{% if inputs %}
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Inputs ({{ inputs|length }})</h5>
<div class="grid grid-cols-2 gap-2">
{% for inp in inputs %}
<a href="/cache/{{ inp.cache_id }}" class="block bg-gray-800 rounded-lg overflow-hidden hover:bg-gray-700 transition-colors">
{% if inp.media_type == 'video' %}
<video src="/cache/{{ inp.cache_id }}/raw" class="w-full h-20 object-cover rounded-t" muted></video>
{% elif inp.media_type == 'image' %}
<img src="/cache/{{ inp.cache_id }}/raw" class="w-full h-20 object-cover rounded-t">
{% else %}
<div class="w-full h-20 bg-gray-700 rounded-t flex items-center justify-center text-xs text-gray-400">
{{ inp.media_type or 'File' }}
</div>
{% endif %}
<div class="p-2">
<div class="text-xs text-white truncate">{{ inp.name }}</div>
<div class="text-xs text-gray-500 font-mono truncate">{{ inp.cache_id[:12] }}...</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{# Parameters/Config #}
{% if config %}
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Parameters</h5>
<div class="bg-gray-800 rounded p-3 text-xs space-y-1">
{% for key, value in config.items() %}
<div class="flex justify-between">
<span class="text-gray-400">{{ key }}:</span>
<span class="text-white">{{ value if value is string else value|tojson }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Metadata #}
<div class="mt-4 text-xs text-gray-500 space-y-1">
<div><span class="text-gray-400">Step ID:</span> <span class="font-mono">{{ step.step_id[:32] }}...</span></div>
<div><span class="text-gray-400">Cache ID:</span> <span class="font-mono">{{ cache_id[:32] }}...</span></div>
</div>

View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Storage Providers - Art-DAG L1{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Storage Providers</h1>
<p class="text-gray-400 mb-8">
Configure your IPFS pinning services. Data is pinned to your accounts, giving you full control.
</p>
<!-- Provider Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{% for key, info in providers_info.items() %}
<a href="/storage/type/{{ key }}"
class="bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-{{ info.color }}-500 transition-colors">
<div class="flex items-center justify-between mb-2">
<span class="text-lg font-medium text-{{ info.color }}-400">{{ info.name }}</span>
{% set count = storages | selectattr('provider_type', 'equalto', key) | list | length %}
{% if count > 0 %}
<span class="bg-{{ info.color }}-900 text-{{ info.color }}-300 px-2 py-0.5 rounded text-sm">
{{ count }} configured
</span>
{% endif %}
</div>
<p class="text-gray-400 text-sm">{{ info.desc }}</p>
</a>
{% endfor %}
</div>
<!-- Configured Providers -->
{% if storages %}
<h2 class="text-xl font-semibold mb-4">Your Storage Providers</h2>
<div class="space-y-4">
{% for storage in storages %}
{% set info = providers_info.get(storage.provider_type, {'name': storage.provider_type, 'color': 'gray'}) %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4" id="storage-{{ storage.id }}">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<span class="text-{{ info.color }}-400 font-medium">{{ storage.provider_name or info.name }}</span>
{% if storage.is_active %}
<span class="bg-green-900 text-green-300 px-2 py-0.5 rounded text-xs">Active</span>
{% else %}
<span class="bg-gray-700 text-gray-400 px-2 py-0.5 rounded text-xs">Inactive</span>
{% endif %}
</div>
<div class="flex items-center space-x-2">
<button hx-post="/storage/{{ storage.id }}/test"
hx-target="#test-result-{{ storage.id }}"
class="text-gray-400 hover:text-white text-sm">
Test
</button>
<button hx-delete="/storage/{{ storage.id }}"
hx-target="#storage-{{ storage.id }}"
hx-swap="outerHTML"
hx-confirm="Remove this storage provider?"
class="text-red-400 hover:text-red-300 text-sm">
Remove
</button>
</div>
</div>
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-gray-500">Capacity:</span>
<span class="text-gray-300">{{ storage.capacity_gb }} GB</span>
</div>
<div>
<span class="text-gray-500">Used:</span>
<span class="text-gray-300">{{ (storage.used_bytes / 1024 / 1024 / 1024) | round(2) }} GB</span>
</div>
<div>
<span class="text-gray-500">Pins:</span>
<span class="text-gray-300">{{ storage.pin_count }}</span>
</div>
</div>
<div id="test-result-{{ storage.id }}" class="mt-2 text-sm"></div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-8 text-center">
<p class="text-gray-400 mb-4">No storage providers configured yet.</p>
<p class="text-gray-500 text-sm">Click on a provider above to add your first one.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block title %}{{ provider_info.name }} - Storage - Art-DAG L1{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="flex items-center space-x-4 mb-6">
<a href="/storage" class="text-gray-400 hover:text-white">&larr; All Providers</a>
<h1 class="text-2xl font-bold text-{{ provider_info.color }}-400">{{ provider_info.name }}</h1>
</div>
<p class="text-gray-400 mb-8">{{ provider_info.desc }}</p>
<!-- Add New -->
<div class="bg-gray-800 border border-gray-700 rounded-lg p-6 mb-8">
<h2 class="text-lg font-semibold mb-4">Add {{ provider_info.name }} Account</h2>
<form hx-post="/storage/add"
hx-target="#add-result"
class="space-y-4">
<input type="hidden" name="provider_type" value="{{ provider_type }}">
<div>
<label class="block text-gray-400 text-sm mb-1">Name (optional)</label>
<input type="text" name="provider_name"
placeholder="{{ provider_type }}-1"
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
{% if provider_type == 'pinata' %}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-gray-400 text-sm mb-1">API Key *</label>
<input type="text" name="api_key" required
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
<div>
<label class="block text-gray-400 text-sm mb-1">Secret Key *</label>
<input type="password" name="secret_key" required
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
</div>
{% elif provider_type in ['web3storage', 'nftstorage'] %}
<div>
<label class="block text-gray-400 text-sm mb-1">API Token *</label>
<input type="password" name="api_token" required
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
{% elif provider_type == 'infura' %}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-gray-400 text-sm mb-1">Project ID *</label>
<input type="text" name="project_id" required
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
<div>
<label class="block text-gray-400 text-sm mb-1">Project Secret *</label>
<input type="password" name="project_secret" required
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
</div>
{% elif provider_type in ['filebase', 'storj'] %}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-gray-400 text-sm mb-1">Access Key *</label>
<input type="text" name="access_key" required
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
<div>
<label class="block text-gray-400 text-sm mb-1">Secret Key *</label>
<input type="password" name="secret_key" required
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
</div>
<div>
<label class="block text-gray-400 text-sm mb-1">Bucket *</label>
<input type="text" name="bucket" required
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
{% elif provider_type == 'local' %}
<div>
<label class="block text-gray-400 text-sm mb-1">Path *</label>
<input type="text" name="path" required placeholder="/data/ipfs"
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
{% endif %}
<div>
<label class="block text-gray-400 text-sm mb-1">Capacity (GB)</label>
<input type="number" name="capacity_gb" value="5" min="1"
class="w-32 bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
</div>
<div class="pt-2">
<button type="submit"
class="bg-{{ provider_info.color }}-600 hover:bg-{{ provider_info.color }}-700 px-4 py-2 rounded font-medium">
Add Provider
</button>
</div>
<div id="add-result"></div>
</form>
</div>
<!-- Existing Configs -->
{% if storages %}
<h2 class="text-lg font-semibold mb-4">Configured Accounts</h2>
<div class="space-y-4">
{% for storage in storages %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4" id="storage-{{ storage.id }}">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<span class="font-medium">{{ storage.provider_name }}</span>
{% if storage.is_active %}
<span class="bg-green-900 text-green-300 px-2 py-0.5 rounded text-xs">Active</span>
{% endif %}
</div>
<div class="flex items-center space-x-3">
<button hx-post="/storage/{{ storage.id }}/test"
hx-target="#test-{{ storage.id }}"
class="text-gray-400 hover:text-white text-sm">
Test Connection
</button>
<button hx-delete="/storage/{{ storage.id }}"
hx-target="#storage-{{ storage.id }}"
hx-swap="outerHTML"
hx-confirm="Remove this storage provider?"
class="text-red-400 hover:text-red-300 text-sm">
Remove
</button>
</div>
</div>
{% if storage.config_display %}
<div class="text-sm text-gray-400 space-x-4">
{% for key, value in storage.config_display.items() %}
<span>{{ key }}: <code class="text-gray-300">{{ value }}</code></span>
{% endfor %}
</div>
{% endif %}
<div id="test-{{ storage.id }}" class="mt-2 text-sm"></div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}