feat: Papierkorb + Bestaetigungsdialoge bei allen Loeschaktionen
Papierkorb: - Dateien/Ordner werden beim Loeschen in den Papierkorb verschoben (Soft-Delete) statt sofort geloescht - Papierkorb-Seite in der Sidebar mit Tabelle aller geloeschten Elemente - Pro Element: Wiederherstellen (am Originalort) oder endgueltig loeschen - "Papierkorb leeren" Button loescht alles unwiderruflich - Backend: is_trashed, trashed_at, original_parent_id Felder im File-Model - Getrashte Dateien erscheinen nicht in der normalen Dateiliste Bestaetigungsdialoge (vorher fehlend): - Kontakte: "Moechtest du XY wirklich loeschen?" - Kalender Events: Bestaetigung vor dem Loeschen - Kalender: Bestaetigung vor dem Loeschen (mit Hinweis auf Events) - E-Mail Nachrichten: Bestaetigung mit Betreff-Vorschau - Share-Link Dateien: Bestaetigung beim Loeschen aus geteiltem Ordner - Admin SFTP-Backup-Ziele: Bestaetigung - Admin Email-Konten: Bestaetigung Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1ee80e650d
commit
82f3091f2e
|
|
@ -62,8 +62,8 @@ def list_files():
|
|||
user = request.current_user
|
||||
parent_id = request.args.get('parent_id', None, type=int)
|
||||
|
||||
# Own files in this folder
|
||||
query = File.query.filter_by(owner_id=user.id, parent_id=parent_id)
|
||||
# Own files in this folder (exclude trashed)
|
||||
query = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)
|
||||
files = query.order_by(File.is_folder.desc(), File.name).all()
|
||||
|
||||
# Shared files at root level
|
||||
|
|
@ -328,7 +328,7 @@ def update_file(file_id):
|
|||
return jsonify(f.to_dict()), 200
|
||||
|
||||
|
||||
# --- Delete ---
|
||||
# --- Delete (soft-delete -> trash) ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
|
|
@ -336,21 +336,37 @@ def delete_file(file_id):
|
|||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'admin')
|
||||
if err:
|
||||
# Owner can always delete
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
_delete_recursive(f, user.id)
|
||||
# Soft-delete: move to trash
|
||||
_trash_recursive(f)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Geloescht'}), 200
|
||||
return jsonify({'message': 'In Papierkorb verschoben'}), 200
|
||||
|
||||
|
||||
def _delete_recursive(file_obj, user_id):
|
||||
def _trash_recursive(file_obj):
|
||||
"""Move file/folder to trash (soft-delete)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
if not file_obj.is_trashed:
|
||||
file_obj.original_parent_id = file_obj.parent_id
|
||||
file_obj.parent_id = None
|
||||
file_obj.is_trashed = True
|
||||
file_obj.trashed_at = now
|
||||
if file_obj.is_folder:
|
||||
children = File.query.filter_by(parent_id=file_obj.id, is_trashed=False).all()
|
||||
for child in children:
|
||||
child.is_trashed = True
|
||||
child.trashed_at = now
|
||||
|
||||
|
||||
def _delete_permanent(file_obj):
|
||||
"""Permanently delete a file/folder and its disk data."""
|
||||
if file_obj.is_folder:
|
||||
children = File.query.filter_by(parent_id=file_obj.id).all()
|
||||
for child in children:
|
||||
_delete_recursive(child, user_id)
|
||||
_delete_permanent(child)
|
||||
else:
|
||||
if file_obj.storage_path:
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(file_obj.owner_id) / file_obj.storage_path
|
||||
|
|
@ -359,6 +375,100 @@ def _delete_recursive(file_obj, user_id):
|
|||
db.session.delete(file_obj)
|
||||
|
||||
|
||||
# --- Trash / Papierkorb ---
|
||||
|
||||
@api_bp.route('/files/trash', methods=['GET'])
|
||||
@token_required
|
||||
def list_trash():
|
||||
"""List all trashed files for the current user."""
|
||||
user = request.current_user
|
||||
trashed = File.query.filter_by(owner_id=user.id, is_trashed=True)\
|
||||
.filter(File.original_parent_id.isnot(None) | (File.original_parent_id.is_(None)))\
|
||||
.order_by(File.trashed_at.desc()).all()
|
||||
|
||||
# Only show top-level trashed items (not children of trashed folders)
|
||||
top_level = []
|
||||
trashed_folder_ids = {f.id for f in trashed if f.is_folder}
|
||||
for f in trashed:
|
||||
# Show if parent is not also trashed
|
||||
parent_trashed = False
|
||||
if f.original_parent_id:
|
||||
parent = db.session.get(File, f.original_parent_id)
|
||||
if parent and parent.is_trashed:
|
||||
parent_trashed = True
|
||||
if not parent_trashed:
|
||||
top_level.append(f.to_dict())
|
||||
|
||||
return jsonify(top_level), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/trash/<int:file_id>/restore', methods=['POST'])
|
||||
@token_required
|
||||
def restore_from_trash(file_id):
|
||||
"""Restore a file/folder from trash."""
|
||||
user = request.current_user
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id or not f.is_trashed:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
# Restore to original parent (or root if parent no longer exists)
|
||||
original_parent = db.session.get(File, f.original_parent_id) if f.original_parent_id else None
|
||||
if original_parent and not original_parent.is_trashed:
|
||||
f.parent_id = f.original_parent_id
|
||||
else:
|
||||
f.parent_id = None
|
||||
|
||||
f.is_trashed = False
|
||||
f.trashed_at = None
|
||||
f.original_parent_id = None
|
||||
|
||||
# Also restore children
|
||||
if f.is_folder:
|
||||
_restore_children(f)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Wiederhergestellt'}), 200
|
||||
|
||||
|
||||
def _restore_children(folder):
|
||||
children = File.query.filter_by(is_trashed=True).all()
|
||||
for child in children:
|
||||
# Check if this child was inside the restored folder
|
||||
if child.original_parent_id == folder.id or child.parent_id == folder.id:
|
||||
child.is_trashed = False
|
||||
child.trashed_at = None
|
||||
child.parent_id = folder.id
|
||||
child.original_parent_id = None
|
||||
if child.is_folder:
|
||||
_restore_children(child)
|
||||
|
||||
|
||||
@api_bp.route('/files/trash/<int:file_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_permanently(file_id):
|
||||
"""Permanently delete a trashed file."""
|
||||
user = request.current_user
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id or not f.is_trashed:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
_delete_permanent(f)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Endgueltig geloescht'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/trash/empty', methods=['POST'])
|
||||
@token_required
|
||||
def empty_trash():
|
||||
"""Permanently delete all trashed files."""
|
||||
user = request.current_user
|
||||
trashed = File.query.filter_by(owner_id=user.id, is_trashed=True).all()
|
||||
for f in trashed:
|
||||
_delete_permanent(f)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Papierkorb geleert'}), 200
|
||||
|
||||
|
||||
# --- Permissions ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/permissions', methods=['GET'])
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ class File(db.Model):
|
|||
size = db.Column(db.BigInteger, default=0)
|
||||
storage_path = db.Column(db.String(500), nullable=True) # UUID-based path on disk
|
||||
checksum = db.Column(db.String(64), nullable=True) # SHA-256 for sync
|
||||
is_trashed = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
trashed_at = db.Column(db.DateTime, nullable=True)
|
||||
original_parent_id = db.Column(db.Integer, nullable=True) # to restore to original location
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
|
@ -28,7 +31,7 @@ class File(db.Model):
|
|||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
d = {
|
||||
'id': self.id,
|
||||
'owner_id': self.owner_id,
|
||||
'parent_id': self.parent_id,
|
||||
|
|
@ -39,6 +42,10 @@ class File(db.Model):
|
|||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
if self.is_trashed:
|
||||
d['is_trashed'] = True
|
||||
d['trashed_at'] = self.trashed_at.isoformat() if self.trashed_at else None
|
||||
return d
|
||||
|
||||
|
||||
class FilePermission(db.Model):
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ const routes = [
|
|||
name: 'Files',
|
||||
component: () => import('../views/FilesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'trash',
|
||||
name: 'Trash',
|
||||
component: () => import('../views/TrashView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'Calendar',
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@
|
|||
<Button icon="pi pi-list" label="Versionen" size="small" outlined @click="openVersions(tgt)" />
|
||||
<Button icon="pi pi-check-circle" label="Testen" size="small" text @click="testTarget(tgt)" />
|
||||
<Button icon="pi pi-pencil" text size="small" @click="openEditTarget(tgt)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="deleteSftpTarget(tgt)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="confirmDeleteSftp(tgt)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -374,7 +374,7 @@
|
|||
</div>
|
||||
<div class="acc-actions">
|
||||
<Button icon="pi pi-pencil" text size="small" @click="openEditEmailAccount(acc)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="deleteEmailAccount(acc)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="confirmDeleteEmail(acc)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -451,6 +451,24 @@
|
|||
<Button label="Loeschen" severity="danger" @click="doDeleteUser" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Confirm delete SFTP target -->
|
||||
<Dialog v-model:visible="showDeleteSftpConfirm" header="Backup-Ziel loeschen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du das Backup-Ziel <strong>{{ deleteSftpTarget_ref?.name }}</strong> wirklich loeschen?</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showDeleteSftpConfirm = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="doDeleteSftpTarget" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Confirm delete email account -->
|
||||
<Dialog v-model:visible="showDeleteEmailConfirm" header="E-Mail-Konto loeschen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du das E-Mail-Konto <strong>{{ deleteEmailTarget?.display_name }}</strong> wirklich loeschen?</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showDeleteEmailConfirm = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="doDeleteEmailTarget" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -560,6 +578,10 @@ const filteredUsers = computed(() => {
|
|||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
const showDeleteSftpConfirm = ref(false)
|
||||
const deleteSftpTarget_ref = ref(null)
|
||||
const showDeleteEmailConfirm = ref(false)
|
||||
const deleteEmailTarget = ref(null)
|
||||
|
||||
// Email accounts for edited user
|
||||
const userEmailAccounts = ref([])
|
||||
|
|
@ -674,9 +696,16 @@ async function saveTarget() {
|
|||
}
|
||||
}
|
||||
|
||||
async function deleteSftpTarget(tgt) {
|
||||
function confirmDeleteSftp(tgt) {
|
||||
deleteSftpTarget_ref.value = tgt
|
||||
showDeleteSftpConfirm.value = true
|
||||
}
|
||||
|
||||
async function doDeleteSftpTarget() {
|
||||
if (!deleteSftpTarget_ref.value) return
|
||||
try {
|
||||
await apiClient.delete(`/admin/backup/targets/${tgt.id}`)
|
||||
await apiClient.delete(`/admin/backup/targets/${deleteSftpTarget_ref.value.id}`)
|
||||
showDeleteSftpConfirm.value = false
|
||||
await loadTargets()
|
||||
toast.add({ severity: 'success', summary: 'Backup-Ziel geloescht', life: 3000 })
|
||||
} catch (err) {
|
||||
|
|
@ -1054,10 +1083,17 @@ async function saveEmailAccount() {
|
|||
}
|
||||
}
|
||||
|
||||
async function deleteEmailAccount(acc) {
|
||||
function confirmDeleteEmail(acc) {
|
||||
deleteEmailTarget.value = acc
|
||||
showDeleteEmailConfirm.value = true
|
||||
}
|
||||
|
||||
async function doDeleteEmailTarget() {
|
||||
if (!deleteEmailTarget.value) return
|
||||
try {
|
||||
await apiClient.delete(`/admin/email-accounts/${acc.id}`)
|
||||
userEmailAccounts.value = userEmailAccounts.value.filter(a => a.id !== acc.id)
|
||||
await apiClient.delete(`/admin/email-accounts/${deleteEmailTarget.value.id}`)
|
||||
userEmailAccounts.value = userEmailAccounts.value.filter(a => a.id !== deleteEmailTarget.value.id)
|
||||
showDeleteEmailConfirm.value = false
|
||||
toast.add({ severity: 'success', summary: 'Konto geloescht', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@
|
|||
<i class="pi pi-key"></i>
|
||||
<span>Passwoerter</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/trash" class="nav-item" active-class="active">
|
||||
<i class="pi pi-trash"></i>
|
||||
<span>Papierkorb</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="deleteEvent" />
|
||||
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="confirmDeleteEvent = true" />
|
||||
<Button label="Abbrechen" text @click="showEventDialog = false" />
|
||||
<Button :label="editingEvent ? 'Speichern' : 'Erstellen'" @click="saveEvent" />
|
||||
</template>
|
||||
|
|
@ -114,9 +114,27 @@
|
|||
</div>
|
||||
|
||||
<Button v-if="selectedCal.permission === 'owner'" label="Kalender loeschen"
|
||||
severity="danger" text size="small" @click="deleteCalendar" />
|
||||
severity="danger" text size="small" @click="confirmDeleteCal = true" />
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Confirm delete event -->
|
||||
<Dialog v-model:visible="confirmDeleteEvent" header="Event loeschen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du <strong>{{ editingEvent?.summary }}</strong> wirklich loeschen?</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="confirmDeleteEvent = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="deleteEvent; confirmDeleteEvent = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Confirm delete calendar -->
|
||||
<Dialog v-model:visible="confirmDeleteCal" header="Kalender loeschen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du den Kalender <strong>{{ selectedCal?.name }}</strong> mit allen Events loeschen?</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="confirmDeleteCal = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="deleteCalendar(); confirmDeleteCal = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -147,6 +165,8 @@ const selectedCal = ref(null)
|
|||
const shareUsername = ref('')
|
||||
const sharePermission = ref('read')
|
||||
const icalUrl = ref('')
|
||||
const confirmDeleteEvent = ref(false)
|
||||
const confirmDeleteCal = ref(false)
|
||||
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
|
||||
|
||||
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
<Column field="phone" header="Telefon" />
|
||||
<Column header="" style="width: 80px">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="deleteContact(data)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="confirmDeleteContact(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
|
@ -77,6 +77,15 @@
|
|||
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirm -->
|
||||
<Dialog v-model:visible="showDeleteConfirm" header="Kontakt loeschen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du <strong>{{ deleteTarget?.display_name }}</strong> wirklich loeschen?</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="doDeleteContact" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -178,8 +187,18 @@ async function saveContact() {
|
|||
}
|
||||
}
|
||||
|
||||
async function deleteContact(contact) {
|
||||
await apiClient.delete(`/contacts/${contact.id}`)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
|
||||
function confirmDeleteContact(contact) {
|
||||
deleteTarget.value = contact
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function doDeleteContact() {
|
||||
if (!deleteTarget.value) return
|
||||
await apiClient.delete(`/contacts/${deleteTarget.value.id}`)
|
||||
showDeleteConfirm.value = false
|
||||
await loadContacts()
|
||||
await loadBooks()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
</div>
|
||||
<div class="msg-actions">
|
||||
<Button icon="pi pi-reply" label="Antworten" size="small" outlined @click="replyTo" />
|
||||
<Button icon="pi pi-trash" size="small" severity="danger" text @click="deleteMessage" />
|
||||
<Button icon="pi pi-trash" size="small" severity="danger" text @click="confirmDeleteMsg = true" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="messageDetail.html_body" class="msg-body" v-html="messageDetail.html_body"></div>
|
||||
|
|
@ -100,6 +100,16 @@
|
|||
<Button label="Senden" icon="pi pi-send" @click="sendEmail" :loading="sending" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete message confirm -->
|
||||
<Dialog v-model:visible="confirmDeleteMsg" header="Nachricht loeschen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du diese Nachricht wirklich loeschen?</p>
|
||||
<p v-if="messageDetail" class="msg-subject-preview"><strong>{{ messageDetail.subject }}</strong></p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="confirmDeleteMsg = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="deleteMessage(); confirmDeleteMsg = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -125,6 +135,7 @@ const currentFolderName = ref('INBOX')
|
|||
const currentAccount = ref(null)
|
||||
const loadingMessages = ref(false)
|
||||
|
||||
const confirmDeleteMsg = ref(false)
|
||||
const showCompose = ref(false)
|
||||
const sending = ref(false)
|
||||
const composeForm = ref({ account_id: null, to: '', cc: '', subject: '', body_text: '' })
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
<Button v-if="!f.is_folder" icon="pi pi-download" text size="small"
|
||||
@click.stop="downloadFolderFile(f)" />
|
||||
<Button v-if="fileInfo.permission === 'write'" icon="pi pi-trash" text size="small"
|
||||
severity="danger" @click.stop="deleteFolderFile(f)" />
|
||||
severity="danger" @click.stop="confirmDeleteShareFile(f)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!folderFiles.length" class="empty-folder">
|
||||
|
|
@ -111,6 +111,15 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirm for shared folder files -->
|
||||
<Dialog v-model:visible="showShareDeleteConfirm" header="Datei loeschen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du <strong>{{ shareDeleteTarget?.name }}</strong> wirklich loeschen?</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showShareDeleteConfirm = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="doDeleteShareFile" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -122,6 +131,7 @@ import Button from 'primevue/button'
|
|||
import Password from 'primevue/password'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import Dialog from 'primevue/dialog'
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.params.token
|
||||
|
|
@ -139,6 +149,8 @@ const folderBreadcrumb = ref([])
|
|||
const currentParentId = ref(null)
|
||||
const loadingFiles = ref(false)
|
||||
|
||||
const showShareDeleteConfirm = ref(false)
|
||||
const shareDeleteTarget = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadPercent = ref(0)
|
||||
|
|
@ -243,10 +255,17 @@ function downloadFolderFile(file) {
|
|||
window.location.href = url
|
||||
}
|
||||
|
||||
async function deleteFolderFile(file) {
|
||||
function confirmDeleteShareFile(file) {
|
||||
shareDeleteTarget.value = file
|
||||
showShareDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function doDeleteShareFile() {
|
||||
if (!shareDeleteTarget.value) return
|
||||
try {
|
||||
await axios.delete(`/api/share/${token}/files/${file.id}`, { headers: getAuthHeaders() })
|
||||
folderFiles.value = folderFiles.value.filter(f => f.id !== file.id)
|
||||
await axios.delete(`/api/share/${token}/files/${shareDeleteTarget.value.id}`, { headers: getAuthHeaders() })
|
||||
folderFiles.value = folderFiles.value.filter(f => f.id !== shareDeleteTarget.value.id)
|
||||
showShareDeleteConfirm.value = false
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error || 'Fehler beim Loeschen')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Papierkorb</h2>
|
||||
<Button v-if="trashItems.length" icon="pi pi-trash" label="Papierkorb leeren"
|
||||
size="small" severity="danger" outlined @click="confirmEmpty = true" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<i class="pi pi-spin pi-spinner"></i> Laden...
|
||||
</div>
|
||||
|
||||
<div v-else-if="!trashItems.length" class="empty-state">
|
||||
<i class="pi pi-trash" style="font-size: 3rem; color: var(--p-surface-300)"></i>
|
||||
<p>Der Papierkorb ist leer</p>
|
||||
</div>
|
||||
|
||||
<DataTable v-else :value="trashItems" striped-rows>
|
||||
<Column field="name" header="Name" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="file-name">
|
||||
<i :class="data.is_folder ? 'pi pi-folder' : 'pi pi-file'"></i>
|
||||
<span>{{ data.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="size" header="Groesse" sortable style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
{{ data.is_folder ? '' : formatSize(data.size) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="trashed_at" header="Geloescht am" sortable style="width: 180px">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.trashed_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Aktionen" style="width: 160px">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-replay" label="Wiederherstellen" text size="small"
|
||||
@click.stop="restoreItem(data)" />
|
||||
<Button icon="pi pi-times" text size="small" severity="danger"
|
||||
title="Endgueltig loeschen" @click.stop="confirmPermanentDelete(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- Confirm permanent delete -->
|
||||
<Dialog v-model:visible="showDeleteConfirm" header="Endgueltig loeschen" modal :style="{ width: '420px' }">
|
||||
<p>Moechtest du <strong>{{ deleteTarget?.name }}</strong> wirklich endgueltig loeschen?</p>
|
||||
<p class="warn-text">Diese Aktion kann nicht rueckgaengig gemacht werden!</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
|
||||
<Button label="Endgueltig loeschen" severity="danger" @click="deletePermanently" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Confirm empty trash -->
|
||||
<Dialog v-model:visible="confirmEmpty" header="Papierkorb leeren" modal :style="{ width: '420px' }">
|
||||
<p>Moechtest du den gesamten Papierkorb endgueltig leeren?</p>
|
||||
<p class="warn-text">Alle {{ trashItems.length }} Eintraege werden unwiderruflich geloescht!</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="confirmEmpty = false" />
|
||||
<Button label="Papierkorb leeren" severity="danger" @click="emptyTrash" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import apiClient from '../api/client'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
|
||||
const toast = useToast()
|
||||
const trashItems = ref([])
|
||||
const loading = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
const confirmEmpty = ref(false)
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0; let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
async function loadTrash() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiClient.get('/files/trash')
|
||||
trashItems.value = res.data
|
||||
} catch {
|
||||
trashItems.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreItem(item) {
|
||||
try {
|
||||
await apiClient.post(`/files/trash/${item.id}/restore`)
|
||||
toast.add({ severity: 'success', summary: `"${item.name}" wiederhergestellt`, life: 3000 })
|
||||
await loadTrash()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
function confirmPermanentDelete(item) {
|
||||
deleteTarget.value = item
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function deletePermanently() {
|
||||
if (!deleteTarget.value) return
|
||||
try {
|
||||
await apiClient.delete(`/files/trash/${deleteTarget.value.id}`)
|
||||
toast.add({ severity: 'success', summary: 'Endgueltig geloescht', life: 3000 })
|
||||
showDeleteConfirm.value = false
|
||||
await loadTrash()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function emptyTrash() {
|
||||
try {
|
||||
await apiClient.post('/files/trash/empty')
|
||||
toast.add({ severity: 'success', summary: 'Papierkorb geleert', life: 3000 })
|
||||
confirmEmpty.value = false
|
||||
await loadTrash()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTrash)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.view-header h2 { margin: 0; }
|
||||
.loading { text-align: center; padding: 2rem; color: var(--p-text-muted-color); }
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0.75rem; padding: 4rem 2rem; color: var(--p-text-muted-color);
|
||||
}
|
||||
.file-name { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.warn-text { color: var(--p-red-500); font-size: 0.875rem; }
|
||||
</style>
|
||||
Loading…
Reference in New Issue