feat: Office-Preview im neuen Tab + DOCX/XLSX/Text bearbeitbar
Preview-System komplett ueberarbeitet: - Neuer Tab: Doppelklick oder Auge-Icon oeffnet Vorschau im neuen Tab - Dedizierte PreviewView mit Toolbar (Zurueck, Bearbeiten, Speichern, Download) - Token wird als Query-Parameter an Preview/Download-URLs angehaengt (kein 404 mehr) Unterstuetzte Formate: - PDF: Inline-Anzeige im iFrame - Bilder: Zentrierte Anzeige mit Schatten - DOCX: HTML-Darstellung mit Formatierung (Headings, Bold, Italic, Tabellen) - XLSX: Tabellen-Ansicht mit Sheet-Tabs - PPTX: Folien-Navigation (vor/zurueck) - Text/Code: Monospace mit Syntax Bearbeitung (neu!): - DOCX: ContentEditable-Editor, Bold/Italic/Headings bleiben erhalten, Speichern schreibt zurueck als .docx (python-docx) - XLSX: Direkt in der Tabelle bearbeiten (Zellen anklicken), Speichern schreibt zurueck als .xlsx (openpyxl) - Text/Code: Textarea-Editor, Speichern als UTF-8 Backend: POST /files/<id>/save mit type-spezifischer Konvertierung - html -> DOCX (Headings, Bold/Italic/Underline erhalten) - spreadsheet -> XLSX (Zahlen werden automatisch konvertiert) - text -> direkt als Datei Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
67118a34fd
commit
a2ded7f97c
|
|
@ -1,4 +1,7 @@
|
|||
import io
|
||||
import os
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from flask import request, jsonify, current_app, send_file
|
||||
|
|
@ -168,3 +171,146 @@ def _convert_pptx(filepath):
|
|||
'html': '\n'.join(content_parts) if content_parts else '<p>(Leere Folie)</p>',
|
||||
})
|
||||
return slides
|
||||
|
||||
|
||||
# ========== Save (write back edited documents) ==========
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/save', methods=['POST'])
|
||||
@token_required
|
||||
def save_file(file_id):
|
||||
"""Save edited content back to the original file format."""
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'write')
|
||||
if err:
|
||||
return err
|
||||
|
||||
if f.is_folder:
|
||||
return jsonify({'error': 'Ordner koennen nicht gespeichert werden'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
save_type = data.get('type', '')
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
||||
|
||||
try:
|
||||
if save_type == 'html' and f.name.endswith('.docx'):
|
||||
_save_html_to_docx(filepath, data.get('content', ''))
|
||||
elif save_type == 'spreadsheet' and (f.name.endswith('.xlsx') or f.name.endswith('.xls')):
|
||||
_save_sheets_to_xlsx(filepath, data.get('sheets', []))
|
||||
elif save_type == 'text':
|
||||
filepath.write_text(data.get('content', ''), encoding='utf-8')
|
||||
else:
|
||||
return jsonify({'error': f'Speichern fuer diesen Typ nicht unterstuetzt'}), 400
|
||||
|
||||
# Update file metadata
|
||||
f.size = os.path.getsize(str(filepath))
|
||||
h = hashlib.sha256()
|
||||
with open(str(filepath), 'rb') as fh:
|
||||
for chunk in iter(lambda: fh.read(8192), b''):
|
||||
h.update(chunk)
|
||||
f.checksum = h.hexdigest()
|
||||
f.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Gespeichert', 'size': f.size}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Speichern fehlgeschlagen: {str(e)}'}), 500
|
||||
|
||||
|
||||
def _save_html_to_docx(filepath, html_content):
|
||||
"""Convert HTML content back to DOCX."""
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
import re
|
||||
|
||||
doc = Document()
|
||||
|
||||
# Simple HTML to DOCX conversion
|
||||
# Strip tags and convert basic elements
|
||||
html = html_content.replace('\r\n', '\n').replace('\r', '\n')
|
||||
|
||||
# Process block elements
|
||||
blocks = re.split(r'<(?:p|h[1-3]|br\s*/?)(?:\s[^>]*)?>|</(?:p|h[1-3])>', html)
|
||||
|
||||
# Find tag types
|
||||
tags = re.findall(r'<(/?(?:p|h[1-3]|br\s*/?)(?:\s[^>]*)?)>', html)
|
||||
|
||||
current_tag = 'p'
|
||||
for i, block in enumerate(blocks):
|
||||
text = re.sub(r'<[^>]+>', '', block).strip()
|
||||
if not text:
|
||||
if i < len(tags):
|
||||
tag = tags[i] if i < len(tags) else ''
|
||||
if tag.startswith('h1'):
|
||||
current_tag = 'h1'
|
||||
elif tag.startswith('h2'):
|
||||
current_tag = 'h2'
|
||||
elif tag.startswith('h3'):
|
||||
current_tag = 'h3'
|
||||
else:
|
||||
current_tag = 'p'
|
||||
continue
|
||||
|
||||
if current_tag == 'h1':
|
||||
doc.add_heading(text, level=1)
|
||||
elif current_tag == 'h2':
|
||||
doc.add_heading(text, level=2)
|
||||
elif current_tag == 'h3':
|
||||
doc.add_heading(text, level=3)
|
||||
else:
|
||||
# Check for bold/italic in remaining inline tags
|
||||
para = doc.add_paragraph()
|
||||
# Simple inline parsing
|
||||
parts = re.split(r'(</?(?:strong|b|em|i|u)>)', block)
|
||||
bold = False
|
||||
italic = False
|
||||
underline = False
|
||||
for part in parts:
|
||||
if part in ('<strong>', '<b>'):
|
||||
bold = True
|
||||
elif part in ('</strong>', '</b>'):
|
||||
bold = False
|
||||
elif part in ('<em>', '<i>'):
|
||||
italic = True
|
||||
elif part in ('</em>', '</i>'):
|
||||
italic = False
|
||||
elif part in ('<u>',):
|
||||
underline = True
|
||||
elif part in ('</u>',):
|
||||
underline = False
|
||||
else:
|
||||
clean = re.sub(r'<[^>]+>', '', part)
|
||||
if clean:
|
||||
run = para.add_run(clean)
|
||||
run.bold = bold
|
||||
run.italic = italic
|
||||
run.underline = underline
|
||||
|
||||
current_tag = 'p'
|
||||
|
||||
doc.save(str(filepath))
|
||||
|
||||
|
||||
def _save_sheets_to_xlsx(filepath, sheets_data):
|
||||
"""Save spreadsheet data back to XLSX."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
# Remove default sheet
|
||||
wb.remove(wb.active)
|
||||
|
||||
for sheet_data in sheets_data:
|
||||
ws = wb.create_sheet(title=sheet_data.get('name', 'Sheet'))
|
||||
for ri, row in enumerate(sheet_data.get('rows', []), 1):
|
||||
for ci, cell_value in enumerate(row, 1):
|
||||
val = cell_value
|
||||
# Try to convert to number
|
||||
try:
|
||||
if '.' in str(val):
|
||||
val = float(val)
|
||||
else:
|
||||
val = int(val)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
ws.cell(row=ri, column=ci, value=val if val != '' else None)
|
||||
|
||||
wb.save(str(filepath))
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ const routes = [
|
|||
name: 'Files',
|
||||
component: () => import('../views/FilesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'preview/:fileId',
|
||||
name: 'Preview',
|
||||
component: () => import('../views/PreviewView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'trash',
|
||||
name: 'Trash',
|
||||
|
|
|
|||
|
|
@ -74,9 +74,16 @@
|
|||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="" style="width: 140px">
|
||||
<Column header="" style="width: 180px">
|
||||
<template #body="{ data }">
|
||||
<div class="row-actions">
|
||||
<Button
|
||||
v-if="!data.is_folder"
|
||||
icon="pi pi-eye"
|
||||
text rounded size="small"
|
||||
title="Vorschau"
|
||||
@click.stop="openPreview(data)"
|
||||
/>
|
||||
<Button
|
||||
:icon="data.is_folder ? 'pi pi-box' : 'pi pi-download'"
|
||||
text rounded size="small"
|
||||
|
|
@ -285,6 +292,15 @@ function handleDoubleClick(event) {
|
|||
const data = event.data
|
||||
if (data.is_folder) {
|
||||
navigateTo(data.id)
|
||||
} else {
|
||||
openPreview(data)
|
||||
}
|
||||
}
|
||||
|
||||
function openPreview(data) {
|
||||
const previewable = /\.(pdf|docx?|xlsx?|pptx?|txt|md|json|xml|csv|py|js|html|css|yml|yaml|png|jpe?g|gif|svg|webp|bmp)$/i
|
||||
if (previewable.test(data.name)) {
|
||||
window.open(`/preview/${data.id}`, '_blank')
|
||||
} else {
|
||||
downloadFile(data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
<template>
|
||||
<div class="preview-container">
|
||||
<div class="preview-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<Button icon="pi pi-arrow-left" text @click="$router.back()" />
|
||||
<h3>{{ fileName }}</h3>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<Button v-if="canEdit && !editing" icon="pi pi-pencil" label="Bearbeiten" size="small" outlined @click="startEdit" />
|
||||
<Button v-if="editing" icon="pi pi-save" label="Speichern" size="small" @click="saveEdit" :loading="saving" />
|
||||
<Button v-if="editing" label="Abbrechen" size="small" text @click="cancelEdit" />
|
||||
<Button icon="pi pi-download" label="Herunterladen" size="small" outlined @click="downloadFile" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner" style="font-size: 2rem"></i>
|
||||
<p>Vorschau wird geladen...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<i class="pi pi-exclamation-triangle" style="font-size: 2rem; color: var(--p-orange-500)"></i>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- PDF -->
|
||||
<div v-else-if="previewType === 'pdf'" class="preview-content">
|
||||
<iframe :src="previewUrl" class="pdf-frame"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div v-else-if="previewType === 'image'" class="preview-content image-preview">
|
||||
<img :src="previewUrl" :alt="fileName" />
|
||||
</div>
|
||||
|
||||
<!-- HTML (DOCX) -->
|
||||
<div v-else-if="previewType === 'html'" class="preview-content">
|
||||
<div v-if="!editing" class="document-frame" v-html="htmlContent"></div>
|
||||
<div v-else class="editor-frame">
|
||||
<div ref="editorRef" contenteditable="true" class="document-editor" v-html="htmlContent"
|
||||
@input="editorDirty = true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spreadsheet (XLSX) -->
|
||||
<div v-else-if="previewType === 'spreadsheet'" class="preview-content">
|
||||
<div class="sheet-tabs" v-if="sheets.length > 1">
|
||||
<button v-for="(sheet, i) in sheets" :key="i"
|
||||
:class="{ active: activeSheet === i }" @click="activeSheet = i">
|
||||
{{ sheet.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="spreadsheet-wrapper">
|
||||
<table class="spreadsheet" v-if="sheets[activeSheet]">
|
||||
<tr v-for="(row, ri) in sheets[activeSheet].rows" :key="ri">
|
||||
<td v-for="(cell, ci) in row" :key="ci"
|
||||
:contenteditable="editing"
|
||||
@input="onCellEdit(ri, ci, $event)">{{ cell }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slides (PPTX) -->
|
||||
<div v-else-if="previewType === 'slides'" class="preview-content">
|
||||
<div class="slides-nav">
|
||||
<Button icon="pi pi-chevron-left" text :disabled="activeSlide <= 0" @click="activeSlide--" />
|
||||
<span>Folie {{ activeSlide + 1 }} / {{ slides.length }}</span>
|
||||
<Button icon="pi pi-chevron-right" text :disabled="activeSlide >= slides.length - 1" @click="activeSlide++" />
|
||||
</div>
|
||||
<div v-if="slides[activeSlide]" class="slide-frame" v-html="slides[activeSlide].html"></div>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div v-else-if="previewType === 'text'" class="preview-content">
|
||||
<textarea v-if="editing" v-model="textContent" class="text-editor"></textarea>
|
||||
<pre v-else class="text-frame">{{ textContent }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Unsupported -->
|
||||
<div v-else-if="previewType === 'unsupported'" class="error-state">
|
||||
<i class="pi pi-file" style="font-size: 2rem; color: var(--p-surface-400)"></i>
|
||||
<p>Vorschau fuer diesen Dateityp nicht verfuegbar</p>
|
||||
<Button label="Herunterladen" icon="pi pi-download" @click="downloadFile" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const fileId = route.params.fileId
|
||||
const fileName = ref('')
|
||||
const previewType = ref('')
|
||||
const previewUrl = ref('')
|
||||
const htmlContent = ref('')
|
||||
const textContent = ref('')
|
||||
const sheets = ref([])
|
||||
const activeSheet = ref(0)
|
||||
const slides = ref([])
|
||||
const activeSlide = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const editing = ref(false)
|
||||
const editorDirty = ref(false)
|
||||
const saving = ref(false)
|
||||
const editorRef = ref(null)
|
||||
const canEdit = ref(false)
|
||||
|
||||
function getTokenUrl(path) {
|
||||
return `${path}${path.includes('?') ? '&' : '?'}token=${encodeURIComponent(auth.accessToken || '')}`
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiClient.get(`/files/${fileId}/preview`)
|
||||
const data = res.data
|
||||
fileName.value = data.name || ''
|
||||
previewType.value = data.type
|
||||
|
||||
if (data.type === 'pdf') {
|
||||
previewUrl.value = getTokenUrl(`/api/files/${fileId}/download`)
|
||||
canEdit.value = false
|
||||
} else if (data.type === 'image') {
|
||||
previewUrl.value = getTokenUrl(`/api/files/${fileId}/download`)
|
||||
canEdit.value = false
|
||||
} else if (data.type === 'html') {
|
||||
htmlContent.value = data.content
|
||||
canEdit.value = true
|
||||
} else if (data.type === 'spreadsheet') {
|
||||
sheets.value = data.sheets
|
||||
canEdit.value = true
|
||||
} else if (data.type === 'slides') {
|
||||
slides.value = data.slides
|
||||
canEdit.value = false
|
||||
} else if (data.type === 'text') {
|
||||
textContent.value = data.content
|
||||
canEdit.value = true
|
||||
} else {
|
||||
previewType.value = 'unsupported'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Vorschau konnte nicht geladen werden'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
window.location.href = getTokenUrl(`/api/files/${fileId}/download`)
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editing.value = true
|
||||
editorDirty.value = false
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
// Reload to discard changes
|
||||
loadPreview()
|
||||
}
|
||||
|
||||
function onCellEdit(ri, ci, event) {
|
||||
sheets.value[activeSheet.value].rows[ri][ci] = event.target.textContent
|
||||
editorDirty.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
saving.value = true
|
||||
try {
|
||||
let payload = {}
|
||||
|
||||
if (previewType.value === 'html') {
|
||||
// Get HTML from contenteditable
|
||||
const content = editorRef.value ? editorRef.value.innerHTML : htmlContent.value
|
||||
payload = { type: 'html', content }
|
||||
} else if (previewType.value === 'spreadsheet') {
|
||||
payload = { type: 'spreadsheet', sheets: sheets.value }
|
||||
} else if (previewType.value === 'text') {
|
||||
payload = { type: 'text', content: textContent.value }
|
||||
}
|
||||
|
||||
await apiClient.post(`/files/${fileId}/save`, payload)
|
||||
editing.value = false
|
||||
editorDirty.value = false
|
||||
toast.add({ severity: 'success', summary: 'Gespeichert', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Speichern fehlgeschlagen', detail: err.response?.data?.error || String(err), life: 5000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPreview)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-container { display: flex; flex-direction: column; height: calc(100vh - 0px); }
|
||||
.preview-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.5rem 1rem; border-bottom: 1px solid var(--p-surface-200);
|
||||
background: var(--p-surface-0); flex-shrink: 0;
|
||||
}
|
||||
.toolbar-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.toolbar-left h3 { margin: 0; font-size: 1rem; }
|
||||
.toolbar-right { display: flex; gap: 0.5rem; }
|
||||
.loading, .error-state {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
flex: 1; gap: 0.75rem; color: var(--p-text-muted-color);
|
||||
}
|
||||
.preview-content { flex: 1; overflow: auto; }
|
||||
.pdf-frame { width: 100%; height: 100%; border: none; }
|
||||
.image-preview { display: flex; align-items: center; justify-content: center; padding: 1rem; background: var(--p-surface-100); }
|
||||
.image-preview img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.document-frame {
|
||||
max-width: 800px; margin: 0 auto; padding: 2rem; background: white;
|
||||
min-height: 100%; box-shadow: 0 0 10px rgba(0,0,0,0.05);
|
||||
font-family: serif; font-size: 14px; line-height: 1.6;
|
||||
}
|
||||
.editor-frame { max-width: 800px; margin: 0 auto; background: white; min-height: 100%; }
|
||||
.document-editor {
|
||||
padding: 2rem; min-height: 600px; outline: none;
|
||||
font-family: serif; font-size: 14px; line-height: 1.6;
|
||||
border: 2px solid var(--p-primary-color);
|
||||
}
|
||||
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--p-surface-200); padding: 0 1rem; background: var(--p-surface-50); }
|
||||
.sheet-tabs button {
|
||||
padding: 0.5rem 1rem; border: none; background: none; cursor: pointer;
|
||||
font-size: 0.85rem; border-bottom: 2px solid transparent;
|
||||
}
|
||||
.sheet-tabs button.active { border-bottom-color: var(--p-primary-color); color: var(--p-primary-color); font-weight: 600; }
|
||||
.spreadsheet-wrapper { overflow: auto; padding: 0.5rem; }
|
||||
.spreadsheet { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
||||
.spreadsheet td {
|
||||
border: 1px solid var(--p-surface-200); padding: 4px 8px;
|
||||
min-width: 80px; white-space: nowrap;
|
||||
}
|
||||
.spreadsheet tr:first-child td { font-weight: 600; background: var(--p-surface-100); }
|
||||
.spreadsheet td[contenteditable="true"] { cursor: text; }
|
||||
.spreadsheet td[contenteditable="true"]:focus { outline: 2px solid var(--p-primary-color); background: var(--p-primary-50); }
|
||||
.slides-nav { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--p-surface-200); }
|
||||
.slide-frame {
|
||||
max-width: 900px; margin: 2rem auto; padding: 3rem; background: white;
|
||||
min-height: 500px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.text-frame { padding: 1.5rem; margin: 0; font-size: 0.9rem; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.text-editor {
|
||||
width: 100%; height: 100%; min-height: 500px; padding: 1.5rem;
|
||||
font-family: monospace; font-size: 0.9rem; border: none; outline: none; resize: none;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue