Add fullscreen file editor for email templates, project docs and agent files
This commit is contained in:
parent
7844e82c95
commit
5c75ad575d
3 changed files with 274 additions and 66 deletions
85
app.py
85
app.py
|
|
@ -3494,6 +3494,91 @@ def delete_project_file(filename):
|
||||||
return redirect(url_for('files'))
|
return redirect(url_for('files'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/files/project/save/<filename>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def save_project_file(filename):
|
||||||
|
"""Speichert den Inhalt einer Projektdatei (JSON POST)."""
|
||||||
|
base_dir = os.path.dirname(__file__)
|
||||||
|
filepath = os.path.join(base_dir, filename)
|
||||||
|
if os.path.dirname(os.path.abspath(filepath)) != os.path.abspath(base_dir):
|
||||||
|
return jsonify({'ok': False, 'error': 'Zugriff verweigert'}), 403
|
||||||
|
if not filename.lower().endswith(('.md', '.txt')):
|
||||||
|
return jsonify({'ok': False, 'error': 'Nur .md und .txt Dateien editierbar'}), 400
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
content = data.get('content', '') if data else ''
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/files/agent/<agent_key>/save/<filename>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def save_agent_file(agent_key, filename):
|
||||||
|
"""Speichert den Inhalt einer Agent-Datei (JSON POST)."""
|
||||||
|
agents_base = os.path.join(os.path.dirname(__file__), 'agents')
|
||||||
|
work_dir = os.path.join(agents_base, agent_key, 'work')
|
||||||
|
filepath = os.path.join(work_dir, filename)
|
||||||
|
if not os.path.abspath(filepath).startswith(os.path.abspath(work_dir)):
|
||||||
|
return jsonify({'ok': False, 'error': 'Zugriff verweigert'}), 403
|
||||||
|
if not filename.lower().endswith(('.md', '.txt', '.json', '.csv')):
|
||||||
|
return jsonify({'ok': False, 'error': 'Dateityp nicht editierbar'}), 400
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
content = data.get('content', '') if data else ''
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/files/editor')
|
||||||
|
@login_required
|
||||||
|
def file_editor():
|
||||||
|
"""Vollbild-Editor für Dateien (Email-Vorlagen, Projektdokumente, Agent-Dateien)."""
|
||||||
|
file_type = request.args.get('type', 'email') # email | project | agent
|
||||||
|
filename = request.args.get('name', '')
|
||||||
|
agent_key = request.args.get('agent', '')
|
||||||
|
|
||||||
|
# Inhalt laden
|
||||||
|
content = ''
|
||||||
|
error = ''
|
||||||
|
try:
|
||||||
|
base = os.path.dirname(__file__)
|
||||||
|
if file_type == 'email':
|
||||||
|
fp = os.path.join(base, 'emails', filename)
|
||||||
|
safe = os.path.abspath(fp).startswith(os.path.abspath(os.path.join(base, 'emails')))
|
||||||
|
elif file_type == 'project':
|
||||||
|
fp = os.path.join(base, filename)
|
||||||
|
safe = os.path.dirname(os.path.abspath(fp)) == os.path.abspath(base)
|
||||||
|
elif file_type == 'agent':
|
||||||
|
fp = os.path.join(base, 'agents', agent_key, 'work', filename)
|
||||||
|
safe = os.path.abspath(fp).startswith(os.path.abspath(os.path.join(base, 'agents', agent_key, 'work')))
|
||||||
|
else:
|
||||||
|
safe = False
|
||||||
|
|
||||||
|
if not safe:
|
||||||
|
error = 'Zugriff verweigert'
|
||||||
|
elif not os.path.isfile(fp):
|
||||||
|
error = 'Datei nicht gefunden'
|
||||||
|
else:
|
||||||
|
with open(fp, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
return render_template('file_editor.html',
|
||||||
|
file_type=file_type,
|
||||||
|
filename=filename,
|
||||||
|
agent_key=agent_key,
|
||||||
|
content=content,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/emails', methods=['GET', 'POST'])
|
@app.route('/emails', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def emails():
|
def emails():
|
||||||
|
|
|
||||||
179
templates/file_editor.html
Normal file
179
templates/file_editor.html
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Editor · {{ filename }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
/* Vollbild-Editor Layout */
|
||||||
|
.editor-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 130px);
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.editor-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.editor-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.editor-filename {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.editor-breadcrumb {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: .1rem;
|
||||||
|
}
|
||||||
|
#editor-textarea {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
resize: none;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
#editor-textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(99,102,241,.15);
|
||||||
|
}
|
||||||
|
.editor-statusbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 .1rem;
|
||||||
|
}
|
||||||
|
#save-status {
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">{{ error }}</div>
|
||||||
|
<a href="/files" class="btn btn-secondary">← Zurück zu Dateien</a>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="editor-wrap">
|
||||||
|
<!-- Topbar -->
|
||||||
|
<div class="editor-topbar">
|
||||||
|
<a href="/files" class="btn btn-outline-secondary btn-sm">← Zurück</a>
|
||||||
|
<div class="editor-meta">
|
||||||
|
<div class="editor-filename">{{ filename }}</div>
|
||||||
|
<div class="editor-breadcrumb">
|
||||||
|
{% if file_type == 'email' %}✉ Email-Vorlage · emails/{{ filename }}
|
||||||
|
{% elif file_type == 'project' %}📄 Projektdokument · {{ filename }}
|
||||||
|
{% elif file_type == 'agent' %}🤖 {{ agent_key }} · agents/{{ agent_key }}/work/{{ filename }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="save-btn" class="btn btn-primary btn-sm" onclick="saveFile()">💾 Speichern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<textarea id="editor-textarea" spellcheck="false">{{ content }}</textarea>
|
||||||
|
|
||||||
|
<!-- Statusbar -->
|
||||||
|
<div class="editor-statusbar">
|
||||||
|
<span id="cursor-pos">Zeile 1, Spalte 1</span>
|
||||||
|
<span id="save-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const FILE_TYPE = {{ file_type|tojson }};
|
||||||
|
const FILENAME = {{ filename|tojson }};
|
||||||
|
const AGENT_KEY = {{ agent_key|tojson }};
|
||||||
|
|
||||||
|
const ta = document.getElementById('editor-textarea');
|
||||||
|
const saveStatus = document.getElementById('save-status');
|
||||||
|
const cursorPos = document.getElementById('cursor-pos');
|
||||||
|
|
||||||
|
// Cursor-Position anzeigen
|
||||||
|
ta.addEventListener('keyup', updateCursor);
|
||||||
|
ta.addEventListener('click', updateCursor);
|
||||||
|
function updateCursor() {
|
||||||
|
const text = ta.value.substring(0, ta.selectionStart);
|
||||||
|
const line = text.split('\n').length;
|
||||||
|
const col = text.split('\n').pop().length + 1;
|
||||||
|
cursorPos.textContent = `Zeile ${line}, Spalte ${col}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab-Taste einfügen statt Fokus verlieren
|
||||||
|
ta.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
const s = ta.selectionStart, end = ta.selectionEnd;
|
||||||
|
ta.value = ta.value.substring(0, s) + ' ' + ta.value.substring(end);
|
||||||
|
ta.selectionStart = ta.selectionEnd = s + 2;
|
||||||
|
}
|
||||||
|
// Ctrl/Cmd+S → Speichern
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveFile();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speichern
|
||||||
|
function saveFile() {
|
||||||
|
const btn = document.getElementById('save-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
saveStatus.style.color = 'var(--text-muted)';
|
||||||
|
saveStatus.textContent = 'Speichern…';
|
||||||
|
|
||||||
|
let url;
|
||||||
|
if (FILE_TYPE === 'email') url = '/files/email/save/' + encodeURIComponent(FILENAME);
|
||||||
|
if (FILE_TYPE === 'project') url = '/files/project/save/' + encodeURIComponent(FILENAME);
|
||||||
|
if (FILE_TYPE === 'agent') url = '/files/agent/' + encodeURIComponent(AGENT_KEY) + '/save/' + encodeURIComponent(FILENAME);
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({content: ta.value})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.ok) {
|
||||||
|
saveStatus.style.color = 'var(--success, #22c55e)';
|
||||||
|
saveStatus.textContent = '✓ Gespeichert';
|
||||||
|
setTimeout(() => { saveStatus.textContent = ''; }, 3000);
|
||||||
|
} else {
|
||||||
|
saveStatus.style.color = 'var(--danger, #ef4444)';
|
||||||
|
saveStatus.textContent = '✗ ' + (d.error || 'Fehler');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
saveStatus.style.color = 'var(--danger, #ef4444)';
|
||||||
|
saveStatus.textContent = '✗ ' + err.message;
|
||||||
|
})
|
||||||
|
.finally(() => { btn.disabled = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCursor();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -103,8 +103,7 @@
|
||||||
<div class="file-actions">
|
<div class="file-actions">
|
||||||
<button class="btn btn-sm btn-secondary"
|
<button class="btn btn-sm btn-secondary"
|
||||||
onclick="viewEmailFile(event, '{{ file.name }}')" title="Anzeigen">👁</button>
|
onclick="viewEmailFile(event, '{{ file.name }}')" title="Anzeigen">👁</button>
|
||||||
<button class="btn btn-sm btn-primary"
|
<a href="/files/editor?type=email&name={{ file.name|urlencode }}" class="btn btn-sm btn-primary" title="Bearbeiten">✎</a>
|
||||||
onclick="editEmailFile('{{ file.name }}')" title="Bearbeiten">✎</button>
|
|
||||||
<a href="/files/email/delete/{{ file.name }}" class="btn btn-sm btn-danger"
|
<a href="/files/email/delete/{{ file.name }}" class="btn btn-sm btn-danger"
|
||||||
onclick="return confirm('Email-Vorlage löschen?')" title="Löschen">✕</a>
|
onclick="return confirm('Email-Vorlage löschen?')" title="Löschen">✕</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,7 +136,11 @@
|
||||||
<div class="file-actions">
|
<div class="file-actions">
|
||||||
<button class="btn btn-sm btn-secondary"
|
<button class="btn btn-sm btn-secondary"
|
||||||
onclick="viewProjectFile(event, '{{ file.name }}')" title="Anzeigen">👁</button>
|
onclick="viewProjectFile(event, '{{ file.name }}')" title="Anzeigen">👁</button>
|
||||||
|
{% if file.name.lower().endswith(('.md', '.txt')) %}
|
||||||
|
<a href="/files/editor?type=project&name={{ file.name|urlencode }}" class="btn btn-sm btn-primary" title="Bearbeiten">✎</a>
|
||||||
|
{% else %}
|
||||||
<a href="/files/project/{{ file.name }}?download=1" class="btn btn-sm btn-primary" title="Herunterladen" download>↓</a>
|
<a href="/files/project/{{ file.name }}?download=1" class="btn btn-sm btn-primary" title="Herunterladen" download>↓</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/files/project/delete/{{ file.name }}" class="btn btn-sm btn-danger"
|
<a href="/files/project/delete/{{ file.name }}" class="btn btn-sm btn-danger"
|
||||||
onclick="return confirm('Projektdokument löschen?')" title="Löschen">✕</a>
|
onclick="return confirm('Projektdokument löschen?')" title="Löschen">✕</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,7 +173,11 @@
|
||||||
<div class="file-actions">
|
<div class="file-actions">
|
||||||
<button class="btn btn-sm btn-secondary"
|
<button class="btn btn-sm btn-secondary"
|
||||||
onclick="viewAgentFile(event, '{{ agent_key }}', '{{ file.name }}')" title="Anzeigen">👁</button>
|
onclick="viewAgentFile(event, '{{ agent_key }}', '{{ file.name }}')" title="Anzeigen">👁</button>
|
||||||
|
{% if file.name.lower().endswith(('.md', '.txt', '.json', '.csv')) %}
|
||||||
|
<a href="/files/editor?type=agent&agent={{ agent_key|urlencode }}&name={{ file.name|urlencode }}" class="btn btn-sm btn-primary" title="Bearbeiten">✎</a>
|
||||||
|
{% else %}
|
||||||
<a href="/files/agent/{{ agent_key }}/{{ file.name }}?download=1" class="btn btn-sm btn-primary" title="Herunterladen" download>↓</a>
|
<a href="/files/agent/{{ agent_key }}/{{ file.name }}?download=1" class="btn btn-sm btn-primary" title="Herunterladen" download>↓</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/files/agent/{{ agent_key }}/delete/{{ file.name }}" class="btn btn-sm btn-danger"
|
<a href="/files/agent/{{ agent_key }}/delete/{{ file.name }}" class="btn btn-sm btn-danger"
|
||||||
onclick="return confirm('Agent-Datei löschen?')" title="Löschen">✕</a>
|
onclick="return confirm('Agent-Datei löschen?')" title="Löschen">✕</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,31 +212,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email Editor Modal -->
|
|
||||||
<div class="modal fade" id="editModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-xl">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">✎ Bearbeiten: <span id="editModalFilename"></span></h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<textarea id="editModalContent" class="inline-editor" spellcheck="false"></textarea>
|
|
||||||
<div id="editSaveStatus" style="font-size:.8rem;color:var(--text-muted);margin-top:.5rem;height:1.2em;"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveEmailFile()">💾 Speichern</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
let currentEditFile = null;
|
|
||||||
|
|
||||||
function openFileModal(title, url) {
|
function openFileModal(title, url) {
|
||||||
document.getElementById('fileModalTitle').textContent = title;
|
document.getElementById('fileModalTitle').textContent = title;
|
||||||
const content = document.getElementById('fileModalContent');
|
const content = document.getElementById('fileModalContent');
|
||||||
|
|
@ -257,48 +244,5 @@ function viewAgentFile(event, agentKey, name) {
|
||||||
openFileModal(`${agentKey}/${name}`, `/files/agent/${agentKey}/view/${encodeURIComponent(name)}?json=1`);
|
openFileModal(`${agentKey}/${name}`, `/files/agent/${agentKey}/view/${encodeURIComponent(name)}?json=1`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function editEmailFile(name) {
|
|
||||||
currentEditFile = name;
|
|
||||||
document.getElementById('editModalFilename').textContent = name;
|
|
||||||
const textarea = document.getElementById('editModalContent');
|
|
||||||
const status = document.getElementById('editSaveStatus');
|
|
||||||
textarea.value = 'Wird geladen…';
|
|
||||||
status.textContent = '';
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('editModal'));
|
|
||||||
modal.show();
|
|
||||||
fetch('/files/email/view/' + encodeURIComponent(name) + '?json=1')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => { textarea.value = d.content || d.error || ''; })
|
|
||||||
.catch(e => { textarea.value = 'Fehler: ' + e.message; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveEmailFile() {
|
|
||||||
if (!currentEditFile) return;
|
|
||||||
const content = document.getElementById('editModalContent').value;
|
|
||||||
const status = document.getElementById('editSaveStatus');
|
|
||||||
status.style.color = 'var(--text-muted)';
|
|
||||||
status.textContent = 'Speichern…';
|
|
||||||
|
|
||||||
fetch('/files/email/save/' + encodeURIComponent(currentEditFile), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ content })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => {
|
|
||||||
if (d.ok) {
|
|
||||||
status.style.color = 'var(--success)';
|
|
||||||
status.textContent = '✓ Gespeichert';
|
|
||||||
setTimeout(() => { status.textContent = ''; }, 3000);
|
|
||||||
} else {
|
|
||||||
status.style.color = 'var(--danger)';
|
|
||||||
status.textContent = '✗ Fehler: ' + (d.error || 'Unbekannt');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
status.style.color = 'var(--danger)';
|
|
||||||
status.textContent = '✗ ' + e.message;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue