feat(server): add D&D resource upload with metadata extraction and tag suggestions
- Add ResourceAnalyzer: PDF (PDFBox), EPUB, image (EXIF/IPTC) metadata extraction - Add POST /api/admin/resources/analyze + /confirm endpoints - Add GET /api/admin/resources/tags with default tag seeding - Admin UI: D&D zone, review panel with textarea description (4096 chars, MD), tag chips - Dependencies: PDFBox 3.0.4, Commons Compress 1.27.1, metadata-extractor 2.19.0
This commit is contained in:
parent
25c5f4675f
commit
e88e2d04c0
5 changed files with 850 additions and 0 deletions
|
|
@ -26,6 +26,9 @@ postgresql = "42.7.4"
|
|||
hikaricp = "6.2.1"
|
||||
jbcrypt = "0.4"
|
||||
flyway = "9.22.3"
|
||||
pdfbox = "3.0.4"
|
||||
commonsCompress = "1.27.1"
|
||||
metadataExtractor = "2.19.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
|
|
@ -83,6 +86,9 @@ tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "
|
|||
tink = { module = "com.google.crypto.tink:tink", version.ref = "tink" }
|
||||
postgresql = { group = "org.postgresql", name = "postgresql", version.ref = "postgresql" }
|
||||
hikaricp = { group = "com.zaxxer", name = "HikariCP", version.ref = "hikaricp" }
|
||||
pdfbox = { group = "org.apache.pdfbox", name = "pdfbox", version.ref = "pdfbox" }
|
||||
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" }
|
||||
metadata-extractor = { group = "com.drewnoakes", name = "metadata-extractor", version.ref = "metadataExtractor" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ dependencies {
|
|||
implementation(libs.hikaricp)
|
||||
implementation(libs.flyway.core)
|
||||
implementation(libs.tink)
|
||||
implementation(libs.pdfbox)
|
||||
implementation(libs.commons.compress)
|
||||
implementation(libs.metadata.extractor)
|
||||
|
||||
testImplementation(libs.h2.database)
|
||||
testImplementation(libs.ktor.server.test.host)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package de.bollwerk.server.routes
|
|||
import de.bollwerk.server.model.ErrorResponse
|
||||
import de.bollwerk.server.repository.ResourceRepository
|
||||
import de.bollwerk.server.security.UserPrincipal
|
||||
import de.bollwerk.server.service.AnalyzedResource
|
||||
import de.bollwerk.server.service.ResourceAnalyzer
|
||||
import de.bollwerk.shared.model.ResourceDto
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.*
|
||||
|
|
@ -10,12 +12,14 @@ import io.ktor.server.auth.*
|
|||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
private const val MAX_RESOURCE_UPLOAD_SIZE = 25L * 1024 * 1024 // 25 MB
|
||||
private val RESOURCE_STORAGE_DIR = File(System.getenv("BOLLWERK_RESOURCE_DIR") ?: "/opt/bollwerk/resources")
|
||||
private val resourceAnalyzer = ResourceAnalyzer()
|
||||
|
||||
internal fun Route.resourceRoutes(resourceRepository: ResourceRepository) {
|
||||
// Authenticated: catalog + download
|
||||
|
|
@ -43,6 +47,28 @@ internal fun Route.resourceRoutes(resourceRepository: ResourceRepository) {
|
|||
}
|
||||
}
|
||||
|
||||
// Tags endpoint: return all known tags with frequency, or defaults if none exist
|
||||
route("/api/admin/resources/tags") {
|
||||
get {
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
|
||||
if (!principal.isAdmin) {
|
||||
return@get call.respond(HttpStatusCode.Forbidden, ErrorResponse(403, "Admin access required"))
|
||||
}
|
||||
val allResources = resourceRepository.getAll()
|
||||
val tagFrequency = mutableMapOf<String, Int>()
|
||||
allResources.forEach { res ->
|
||||
res.tags.forEach { tag -> tagFrequency[tag] = (tagFrequency[tag] ?: 0) + 1 }
|
||||
}
|
||||
// If no tags exist, provide defaults
|
||||
if (tagFrequency.isEmpty()) {
|
||||
DEFAULT_TAGS.forEach { tagFrequency[it] = 0 }
|
||||
}
|
||||
val sorted = tagFrequency.entries.sortedByDescending { it.value }.map { TagInfo(it.key, it.value) }
|
||||
call.respond(HttpStatusCode.OK, sorted)
|
||||
}
|
||||
}
|
||||
|
||||
// Admin-only: CRUD
|
||||
route("/api/admin/resources") {
|
||||
post {
|
||||
|
|
@ -131,4 +157,105 @@ internal fun Route.resourceRoutes(resourceRepository: ResourceRepository) {
|
|||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze endpoint: upload file, extract metadata, return suggestions (without persisting)
|
||||
route("/api/admin/resources/analyze") {
|
||||
post {
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
|
||||
if (!principal.isAdmin) {
|
||||
return@post call.respond(HttpStatusCode.Forbidden, ErrorResponse(403, "Admin access required"))
|
||||
}
|
||||
|
||||
val multipart = call.receiveMultipart()
|
||||
var filename = "unknown"
|
||||
var fileBytes: ByteArray? = null
|
||||
|
||||
multipart.forEachPart { part ->
|
||||
when (part) {
|
||||
is PartData.FileItem -> {
|
||||
if (part.name == "file") {
|
||||
filename = part.originalFileName ?: "unknown"
|
||||
fileBytes = part.streamProvider().readBytes()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
part.dispose()
|
||||
}
|
||||
|
||||
val bytes = fileBytes
|
||||
?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Missing file"))
|
||||
|
||||
if (bytes.size > MAX_RESOURCE_UPLOAD_SIZE) {
|
||||
return@post call.respond(HttpStatusCode.PayloadTooLarge, ErrorResponse(413, "File too large (max 25 MB)"))
|
||||
}
|
||||
|
||||
val analyzed = resourceAnalyzer.analyze(filename, bytes)
|
||||
|
||||
// Store temporary files so they can be confirmed later
|
||||
val tempDir = File(RESOURCE_STORAGE_DIR, ".tmp").also { it.mkdirs() }
|
||||
val responseItems = analyzed.map { item ->
|
||||
val tempId = UUID.randomUUID().toString()
|
||||
val targetFile = File(tempDir, "${tempId}.${item.fileFormat}")
|
||||
targetFile.writeBytes(item.fileBytes ?: bytes)
|
||||
item.copy(fileBytes = null, filename = "${tempId}|${item.filename}")
|
||||
}
|
||||
|
||||
call.respond(HttpStatusCode.OK, responseItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm a previously analyzed resource
|
||||
route("/api/admin/resources/confirm") {
|
||||
post {
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(401, "Unauthorized"))
|
||||
if (!principal.isAdmin) {
|
||||
return@post call.respond(HttpStatusCode.Forbidden, ErrorResponse(403, "Admin access required"))
|
||||
}
|
||||
|
||||
val request = call.receive<AnalyzedResource>()
|
||||
val parts = request.filename.split("|", limit = 2)
|
||||
val tempId = parts[0]
|
||||
val originalFilename = parts.getOrElse(1) { request.filename }
|
||||
|
||||
val tempFile = File(RESOURCE_STORAGE_DIR, ".tmp/${tempId}.${request.fileFormat}")
|
||||
if (!tempFile.exists()) {
|
||||
return@post call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Temp file not found – please re-upload"))
|
||||
}
|
||||
|
||||
val guid = UUID.randomUUID().toString()
|
||||
val now = System.currentTimeMillis()
|
||||
val dto = ResourceDto(
|
||||
guid = guid,
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
tags = request.tags,
|
||||
fileFormat = request.fileFormat,
|
||||
mimeType = request.mimeType,
|
||||
fileSize = tempFile.length(),
|
||||
releaseDate = request.releaseDate,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
author = request.author,
|
||||
language = request.language,
|
||||
edition = request.edition,
|
||||
downloadUrl = "/api/resources/$guid/download"
|
||||
)
|
||||
|
||||
RESOURCE_STORAGE_DIR.mkdirs()
|
||||
tempFile.renameTo(File(RESOURCE_STORAGE_DIR, "${guid}.${dto.fileFormat}"))
|
||||
resourceRepository.create(dto)
|
||||
call.respond(HttpStatusCode.Created, dto)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class TagInfo(val tag: String, val count: Int)
|
||||
|
||||
private val DEFAULT_TAGS = listOf(
|
||||
"gurps", "rulebook", "novel", "map", "handbuch",
|
||||
"anleitung", "nachschlagewerk", "karte", "abenteuer", "quellenbuch"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
package de.bollwerk.server.service
|
||||
|
||||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.metadata.exif.ExifIFD0Directory
|
||||
import com.drew.metadata.iptc.IptcDirectory
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile
|
||||
import org.apache.commons.compress.utils.SeekableInMemoryByteChannel
|
||||
import org.apache.pdfbox.Loader
|
||||
import org.apache.pdfbox.pdmodel.PDDocument
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
@Serializable
|
||||
data class AnalyzedResource(
|
||||
val filename: String,
|
||||
val title: String,
|
||||
val description: String = "",
|
||||
val author: String? = null,
|
||||
val tags: List<String> = emptyList(),
|
||||
val fileFormat: String,
|
||||
val mimeType: String,
|
||||
val fileSize: Long,
|
||||
val language: String? = null,
|
||||
val edition: String? = null,
|
||||
val releaseDate: String? = null,
|
||||
val fileBytes: ByteArray? = null
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AnalyzedResource) return false
|
||||
return filename == other.filename && title == other.title
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = filename.hashCode() * 31 + title.hashCode()
|
||||
}
|
||||
|
||||
internal class ResourceAnalyzer {
|
||||
|
||||
private val supportedExtensions = setOf("pdf", "epub", "zip", "7z", "png", "jpg", "jpeg", "webp", "gif", "bmp")
|
||||
|
||||
fun analyze(filename: String, bytes: ByteArray): List<AnalyzedResource> {
|
||||
val ext = filename.substringAfterLast('.', "").lowercase()
|
||||
return when (ext) {
|
||||
"zip" -> analyzeZip(bytes)
|
||||
"7z" -> analyze7z(bytes)
|
||||
else -> listOf(analyzeSingleFile(filename, bytes))
|
||||
}
|
||||
}
|
||||
|
||||
private fun analyzeZip(bytes: ByteArray): List<AnalyzedResource> {
|
||||
val results = mutableListOf<AnalyzedResource>()
|
||||
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory) {
|
||||
val name = entry.name.substringAfterLast('/')
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
if (ext in supportedExtensions && ext != "zip" && ext != "7z") {
|
||||
val fileBytes = zis.readBytes()
|
||||
results.add(analyzeSingleFile(name, fileBytes))
|
||||
}
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fun analyze7z(bytes: ByteArray): List<AnalyzedResource> {
|
||||
val results = mutableListOf<AnalyzedResource>()
|
||||
val channel = SeekableInMemoryByteChannel(bytes)
|
||||
SevenZFile.builder().setSeekableByteChannel(channel).get().use { sevenZ ->
|
||||
var entry = sevenZ.nextEntry
|
||||
while (entry != null) {
|
||||
if (!entry.isDirectory) {
|
||||
val name = entry.name.substringAfterLast('/')
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
if (ext in supportedExtensions && ext != "zip" && ext != "7z") {
|
||||
val fileBytes = sevenZ.getInputStream(entry).readBytes()
|
||||
results.add(analyzeSingleFile(name, fileBytes))
|
||||
}
|
||||
}
|
||||
entry = sevenZ.nextEntry
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fun analyzeSingleFile(filename: String, bytes: ByteArray): AnalyzedResource {
|
||||
val ext = filename.substringAfterLast('.', "").lowercase()
|
||||
val mimeType = mimeTypeFor(ext)
|
||||
val cleanTitle = cleanTitle(filename.substringBeforeLast('.'))
|
||||
|
||||
return when (ext) {
|
||||
"pdf" -> analyzePdf(filename, bytes, cleanTitle, ext, mimeType)
|
||||
"epub" -> analyzeEpub(filename, bytes, cleanTitle, ext, mimeType)
|
||||
"png", "jpg", "jpeg", "webp", "gif", "bmp" -> analyzeImage(filename, bytes, cleanTitle, ext, mimeType)
|
||||
else -> AnalyzedResource(
|
||||
filename = filename,
|
||||
title = cleanTitle,
|
||||
fileFormat = ext,
|
||||
mimeType = mimeType,
|
||||
fileSize = bytes.size.toLong(),
|
||||
fileBytes = bytes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun analyzePdf(filename: String, bytes: ByteArray, fallbackTitle: String, ext: String, mimeType: String): AnalyzedResource {
|
||||
var title = fallbackTitle
|
||||
var author: String? = null
|
||||
var description = ""
|
||||
var language: String? = null
|
||||
var tags = emptyList<String>()
|
||||
|
||||
try {
|
||||
Loader.loadPDF(bytes).use { doc: PDDocument ->
|
||||
val info = doc.documentInformation
|
||||
if (!info.title.isNullOrBlank()) {
|
||||
title = cleanTitle(info.title)
|
||||
}
|
||||
if (!info.author.isNullOrBlank()) {
|
||||
author = info.author.trim()
|
||||
}
|
||||
if (!info.subject.isNullOrBlank()) {
|
||||
description = info.subject.trim()
|
||||
}
|
||||
if (!info.keywords.isNullOrBlank()) {
|
||||
tags = info.keywords.split(",", ";").map { it.trim() }.filter { it.isNotBlank() }
|
||||
}
|
||||
// Try to detect language from metadata (custom property)
|
||||
val custom = info.metadataKeys
|
||||
if ("Language" in custom) {
|
||||
language = info.getCustomMetadataValue("Language")?.trim()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// PDF parsing failed – use defaults
|
||||
}
|
||||
|
||||
return AnalyzedResource(
|
||||
filename = filename,
|
||||
title = title,
|
||||
description = description,
|
||||
author = author,
|
||||
tags = tags,
|
||||
fileFormat = ext,
|
||||
mimeType = mimeType,
|
||||
fileSize = bytes.size.toLong(),
|
||||
language = language,
|
||||
fileBytes = bytes
|
||||
)
|
||||
}
|
||||
|
||||
private fun analyzeEpub(filename: String, bytes: ByteArray, fallbackTitle: String, ext: String, mimeType: String): AnalyzedResource {
|
||||
var title = fallbackTitle
|
||||
var author: String? = null
|
||||
var description = ""
|
||||
var language: String? = null
|
||||
var tags = emptyList<String>()
|
||||
|
||||
try {
|
||||
// EPUB is a zip – look for content.opf
|
||||
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name.endsWith(".opf")) {
|
||||
val opfContent = zis.readBytes().decodeToString()
|
||||
title = extractXmlTag(opfContent, "dc:title") ?: title
|
||||
author = extractXmlTag(opfContent, "dc:creator")
|
||||
description = extractXmlTag(opfContent, "dc:description") ?: ""
|
||||
language = extractXmlTag(opfContent, "dc:language")
|
||||
val subject = extractXmlTag(opfContent, "dc:subject")
|
||||
if (subject != null) {
|
||||
tags = subject.split(",", ";").map { it.trim() }.filter { it.isNotBlank() }
|
||||
}
|
||||
break
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// EPUB parsing failed
|
||||
}
|
||||
|
||||
return AnalyzedResource(
|
||||
filename = filename,
|
||||
title = cleanTitle(title),
|
||||
description = description,
|
||||
author = author,
|
||||
tags = tags,
|
||||
fileFormat = ext,
|
||||
mimeType = mimeType,
|
||||
fileSize = bytes.size.toLong(),
|
||||
language = language,
|
||||
fileBytes = bytes
|
||||
)
|
||||
}
|
||||
|
||||
private fun analyzeImage(filename: String, bytes: ByteArray, fallbackTitle: String, ext: String, mimeType: String): AnalyzedResource {
|
||||
var title = fallbackTitle
|
||||
var author: String? = null
|
||||
var description = ""
|
||||
var tags = emptyList<String>()
|
||||
|
||||
try {
|
||||
val metadata = ImageMetadataReader.readMetadata(ByteArrayInputStream(bytes))
|
||||
|
||||
// IPTC metadata
|
||||
val iptc = metadata.getFirstDirectoryOfType(IptcDirectory::class.java)
|
||||
if (iptc != null) {
|
||||
iptc.getString(IptcDirectory.TAG_HEADLINE)?.takeIf { it.isNotBlank() }?.let { title = it.trim() }
|
||||
iptc.getString(IptcDirectory.TAG_BY_LINE)?.takeIf { it.isNotBlank() }?.let { author = it.trim() }
|
||||
iptc.getString(IptcDirectory.TAG_CAPTION)?.takeIf { it.isNotBlank() }?.let { description = it.trim() }
|
||||
iptc.getStringArray(IptcDirectory.TAG_KEYWORDS)?.let { kw ->
|
||||
tags = kw.toList().filter { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
// EXIF fallback for author
|
||||
if (author == null) {
|
||||
val exif = metadata.getFirstDirectoryOfType(ExifIFD0Directory::class.java)
|
||||
exif?.getString(ExifIFD0Directory.TAG_ARTIST)?.takeIf { it.isNotBlank() }?.let { author = it.trim() }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Image metadata parsing failed
|
||||
}
|
||||
|
||||
return AnalyzedResource(
|
||||
filename = filename,
|
||||
title = title,
|
||||
description = description,
|
||||
author = author,
|
||||
tags = tags,
|
||||
fileFormat = ext,
|
||||
mimeType = mimeType,
|
||||
fileSize = bytes.size.toLong(),
|
||||
fileBytes = bytes
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractXmlTag(xml: String, tag: String): String? {
|
||||
val regex = Regex("<$tag[^>]*>([^<]+)</$tag>", RegexOption.IGNORE_CASE)
|
||||
return regex.find(xml)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
internal fun cleanTitle(raw: String): String {
|
||||
return raw
|
||||
.replace(Regex("[_\\-]+"), " ") // underscores/hyphens → spaces
|
||||
.replace(Regex("\\s+"), " ") // collapse whitespace
|
||||
.replace(Regex("^\\d{4,}\\s*"), "") // remove leading numeric IDs
|
||||
.replace(Regex("\\(\\d+\\)$"), "") // remove trailing "(123)"
|
||||
.replace(Regex("\\[.*?]"), "") // remove [bracketed] content
|
||||
.trim()
|
||||
.replaceFirstChar { it.uppercaseChar() } // capitalize first letter
|
||||
}
|
||||
|
||||
private fun mimeTypeFor(ext: String): String = when (ext) {
|
||||
"pdf" -> "application/pdf"
|
||||
"epub" -> "application/epub+zip"
|
||||
"zip" -> "application/zip"
|
||||
"7z" -> "application/x-7z-compressed"
|
||||
"png" -> "image/png"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"webp" -> "image/webp"
|
||||
"gif" -> "image/gif"
|
||||
"bmp" -> "image/bmp"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +124,7 @@
|
|||
<nav class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="tab-users" onclick="switchTab('tab-users')">User</button>
|
||||
<button class="tab-btn" data-tab="tab-inventories" onclick="switchTab('tab-inventories')">Inventare</button>
|
||||
<button class="tab-btn" data-tab="tab-resources" onclick="switchTab('tab-resources')">Ressourcen</button>
|
||||
<button class="tab-btn" data-tab="tab-backups" onclick="switchTab('tab-backups')">Backups</button>
|
||||
</nav>
|
||||
|
||||
|
|
@ -229,6 +230,75 @@
|
|||
|
||||
</div><!-- /tab-inventories -->
|
||||
|
||||
<!-- Tab: Ressourcen -->
|
||||
<div id="tab-resources" class="tab-content">
|
||||
<section class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
|
||||
<h2>Ressourcen-Katalog</h2>
|
||||
<div style="display:flex; gap:8px">
|
||||
<button class="secondary" onclick="loadResources()">↻ Aktualisieren</button>
|
||||
<button class="primary" onclick="toggleResourceForm()">+ Manuell hochladen</button>
|
||||
<button class="primary" onclick="toggleDndZone()">⇪ Drag & Drop</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag & Drop Zone -->
|
||||
<div id="dnd-zone" style="display:none; margin-bottom:16px;">
|
||||
<div id="dnd-area" style="border:2px dashed #4A3F2F; border-radius:3px; padding:48px 24px; text-align:center; background:#1E1C17; cursor:pointer; transition: border-color .2s, background .2s;">
|
||||
<p style="font-size:1.1rem; color:#A89070; margin-bottom:8px;">Datei hierher ziehen</p>
|
||||
<p style="font-size:.85rem; color:#6B5B3E;">PDF, EPUB, Bilder, ZIP, 7z – max 25 MB</p>
|
||||
<p style="font-size:.85rem; color:#6B5B3E; margin-top:4px;">Metadaten werden automatisch extrahiert</p>
|
||||
</div>
|
||||
<div id="dnd-progress" style="display:none; text-align:center; padding:20px; color:#A89070;">
|
||||
Wird analysiert…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review Panel for analyzed resources -->
|
||||
<div id="dnd-review" style="display:none; margin-bottom:16px;"></div>
|
||||
|
||||
<div id="add-resource-form" style="display:none; background:#1E1C17; border:1px solid #4A3F2F; border-radius:3px; padding:20px; margin-bottom:16px;">
|
||||
<h3 style="margin-bottom:14px; color:#E8D5B0;">Neue Ressource hochladen</h3>
|
||||
<div id="resource-error" class="error" style="display:none"></div>
|
||||
<label>Titel *</label>
|
||||
<input id="res-title" type="text" placeholder="z.B. GURPS Basic Set">
|
||||
<label>Beschreibung (Markdown, max 4096 Zeichen)</label>
|
||||
<textarea id="res-description" maxlength="4096" rows="4" placeholder="Freitext-Beschreibung…" style="width:100%; resize:vertical; background:#1C1A14; color:#E8D5B0; border:1px solid #4A3F2F; border-radius:3px; padding:8px 12px; font-size:1rem; font-family:inherit; margin-bottom:14px;"></textarea>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px">
|
||||
<div><label>Autor</label><input id="res-author" type="text"></div>
|
||||
<div><label>Sprache</label><input id="res-language" type="text" placeholder="de, en, …"></div>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px">
|
||||
<div><label>Edition</label><input id="res-edition" type="text" placeholder="z.B. 4th Edition"></div>
|
||||
<div><label>Erscheinungsdatum</label><input id="res-releaseDate" type="date"></div>
|
||||
</div>
|
||||
<label>Tags (kommagetrennt)</label>
|
||||
<input id="res-tags" type="text" placeholder="gurps, rulebook, deutsch">
|
||||
<label>Datei * (max 25 MB)</label>
|
||||
<input id="res-file" type="file" accept=".epub,.pdf,.zip,.7z" style="padding:6px">
|
||||
<div style="display:flex; gap:10px; margin-top:10px">
|
||||
<button class="primary" onclick="uploadResource()">Hochladen</button>
|
||||
<button class="secondary" onclick="toggleResourceForm()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id="resources-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Format</th>
|
||||
<th>Größe</th>
|
||||
<th>Tags</th>
|
||||
<th>Autor</th>
|
||||
<th>Hochgeladen</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resources-body"><tr><td colspan="7" style="text-align:center;color:#6B5B3E">Tab auswählen um zu laden…</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div><!-- /tab-resources -->
|
||||
|
||||
<!-- Tab: Backups -->
|
||||
<div id="tab-backups" class="tab-content">
|
||||
<section class="card">
|
||||
|
|
@ -683,6 +753,7 @@
|
|||
// ── Tab Navigation ───────────────────────────────────────────────────────
|
||||
|
||||
let backupsLoaded = false;
|
||||
let resourcesLoaded = false;
|
||||
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabId));
|
||||
|
|
@ -690,6 +761,9 @@
|
|||
if (tabId === 'tab-backups' && !backupsLoaded) {
|
||||
loadBackups();
|
||||
}
|
||||
if (tabId === 'tab-resources' && !resourcesLoaded) {
|
||||
loadResources();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backups ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -722,6 +796,374 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ── Ressourcen ─────────────────────────────────────────────────────────
|
||||
|
||||
function toggleResourceForm() {
|
||||
const form = document.getElementById('add-resource-form');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function toggleDndZone() {
|
||||
const zone = document.getElementById('dnd-zone');
|
||||
zone.style.display = zone.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Drag & Drop setup
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dndArea = document.getElementById('dnd-area');
|
||||
if (!dndArea) return;
|
||||
|
||||
['dragenter', 'dragover'].forEach(ev => {
|
||||
dndArea.addEventListener(ev, e => {
|
||||
e.preventDefault();
|
||||
dndArea.style.borderColor = '#C1440E';
|
||||
dndArea.style.background = '#2E2B22';
|
||||
});
|
||||
});
|
||||
['dragleave', 'drop'].forEach(ev => {
|
||||
dndArea.addEventListener(ev, e => {
|
||||
e.preventDefault();
|
||||
dndArea.style.borderColor = '#4A3F2F';
|
||||
dndArea.style.background = '#1E1C17';
|
||||
});
|
||||
});
|
||||
dndArea.addEventListener('drop', e => {
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) analyzeDroppedFile(files[0]);
|
||||
});
|
||||
dndArea.addEventListener('click', () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pdf,.epub,.png,.jpg,.jpeg,.webp,.gif,.zip,.7z';
|
||||
input.onchange = () => { if (input.files.length) analyzeDroppedFile(input.files[0]); };
|
||||
input.click();
|
||||
});
|
||||
});
|
||||
|
||||
async function analyzeDroppedFile(file) {
|
||||
const progress = document.getElementById('dnd-progress');
|
||||
const dndArea = document.getElementById('dnd-area');
|
||||
const review = document.getElementById('dnd-review');
|
||||
dndArea.style.display = 'none';
|
||||
progress.style.display = 'block';
|
||||
review.style.display = 'none';
|
||||
review.innerHTML = '';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/resources/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + accessToken },
|
||||
body: formData
|
||||
});
|
||||
progress.style.display = 'none';
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
alert('Analyse fehlgeschlagen: ' + (body.message || res.status));
|
||||
dndArea.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
const items = await res.json();
|
||||
if (!items.length) {
|
||||
alert('Keine unterstützten Dateien gefunden.');
|
||||
dndArea.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
renderReviewPanel(items);
|
||||
} catch (e) {
|
||||
progress.style.display = 'none';
|
||||
dndArea.style.display = 'block';
|
||||
alert('Verbindungsfehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReviewPanel(items) {
|
||||
const review = document.getElementById('dnd-review');
|
||||
review.style.display = 'block';
|
||||
review.innerHTML = '<h3 style="color:#E8D5B0; margin-bottom:12px;">Erkannte Ressourcen (' + items.length + ')</h3>';
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = 'background:#1E1C17; border:1px solid #4A3F2F; border-radius:3px; padding:16px; margin-bottom:12px;';
|
||||
card.innerHTML = `
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
||||
<strong style="color:#E8D5B0;">${item.fileFormat.toUpperCase()} – ${formatBytes(item.fileSize)}</strong>
|
||||
<button class="primary" onclick="confirmAnalyzedResource(${idx})">✓ Übernehmen</button>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<div><label>Titel *</label><input class="rv-title" value="${escHtml(item.title)}" style="width:100%"></div>
|
||||
<div><label>Autor</label><input class="rv-author" value="${escHtml(item.author || '')}" style="width:100%"></div>
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<label>Beschreibung (Markdown)</label>
|
||||
<textarea class="rv-description" maxlength="4096" rows="4" style="width:100%; resize:vertical; background:#1C1A14; color:#E8D5B0; border:1px solid #4A3F2F; border-radius:3px; padding:8px 12px; font-size:1rem; font-family:inherit;">${escHtml(item.description)}</textarea>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-top:8px;">
|
||||
<div><label>Sprache</label><input class="rv-language" value="${escHtml(item.language || '')}" style="width:100%" placeholder="de, en, …"></div>
|
||||
<div><label>Edition</label><input class="rv-edition" value="${escHtml(item.edition || '')}" style="width:100%"></div>
|
||||
<div><label>Erscheinungsdatum</label><input class="rv-releaseDate" type="date" value="${escHtml(item.releaseDate || '')}" style="width:100%"></div>
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<label>Tags</label>
|
||||
<input class="rv-tags" value="${escHtml((item.tags||[]).join(', '))}" style="width:100%; margin-bottom:6px;" placeholder="Kommagetrennt eingeben oder unten klicken">
|
||||
<div class="rv-tag-suggestions" style="display:flex; flex-wrap:wrap; gap:6px;"></div>
|
||||
</div>
|
||||
`;
|
||||
card.dataset.item = JSON.stringify(item);
|
||||
review.appendChild(card);
|
||||
});
|
||||
|
||||
// Load tag suggestions
|
||||
loadTagSuggestions();
|
||||
|
||||
const btnAll = document.createElement('button');
|
||||
btnAll.className = 'primary';
|
||||
btnAll.textContent = 'Alle übernehmen (' + items.length + ')';
|
||||
btnAll.style.marginTop = '8px';
|
||||
btnAll.onclick = confirmAllAnalyzed;
|
||||
review.appendChild(btnAll);
|
||||
|
||||
const btnCancel = document.createElement('button');
|
||||
btnCancel.className = 'secondary';
|
||||
btnCancel.textContent = 'Abbrechen';
|
||||
btnCancel.style.cssText = 'margin-top:8px; margin-left:8px;';
|
||||
btnCancel.onclick = () => {
|
||||
review.style.display = 'none';
|
||||
review.innerHTML = '';
|
||||
document.getElementById('dnd-area').style.display = 'block';
|
||||
};
|
||||
review.appendChild(btnCancel);
|
||||
}
|
||||
|
||||
async function confirmAnalyzedResource(idx) {
|
||||
const review = document.getElementById('dnd-review');
|
||||
const cards = review.querySelectorAll('[data-item]');
|
||||
const card = cards[idx];
|
||||
if (!card) return;
|
||||
|
||||
const item = JSON.parse(card.dataset.item);
|
||||
const payload = {
|
||||
filename: item.filename,
|
||||
title: card.querySelector('.rv-title').value.trim(),
|
||||
description: card.querySelector('.rv-description').value.trim(),
|
||||
author: card.querySelector('.rv-author').value.trim() || null,
|
||||
tags: card.querySelector('.rv-tags').value.split(',').map(t => t.trim()).filter(Boolean),
|
||||
fileFormat: item.fileFormat,
|
||||
mimeType: item.mimeType,
|
||||
fileSize: item.fileSize,
|
||||
language: card.querySelector('.rv-language').value.trim() || null,
|
||||
edition: card.querySelector('.rv-edition').value.trim() || null,
|
||||
releaseDate: card.querySelector('.rv-releaseDate').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/resources/confirm', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
card.style.opacity = '0.4';
|
||||
card.querySelector('button').disabled = true;
|
||||
card.querySelector('button').textContent = '✓ Gespeichert';
|
||||
} else {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
alert('Fehler: ' + (body.message || res.status));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Verbindungsfehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAllAnalyzed() {
|
||||
const review = document.getElementById('dnd-review');
|
||||
const cards = review.querySelectorAll('[data-item]');
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
if (cards[i].style.opacity !== '0.4') {
|
||||
await confirmAnalyzedResource(i);
|
||||
}
|
||||
}
|
||||
resourcesLoaded = false;
|
||||
loadResources();
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return (str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
let cachedTags = null;
|
||||
|
||||
async function loadTagSuggestions() {
|
||||
if (!cachedTags) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/resources/tags', { headers: { 'Authorization': 'Bearer ' + accessToken } });
|
||||
if (res.ok) cachedTags = await res.json();
|
||||
else cachedTags = [];
|
||||
} catch (e) { cachedTags = []; }
|
||||
}
|
||||
document.querySelectorAll('.rv-tag-suggestions').forEach(container => {
|
||||
container.innerHTML = '';
|
||||
const input = container.parentElement.querySelector('.rv-tags');
|
||||
cachedTags.forEach(t => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.textContent = t.tag + (t.count > 0 ? ' (' + t.count + ')' : '');
|
||||
chip.style.cssText = 'background:#3A2E10; color:#C8A840; border:1px solid #8B6914; border-radius:3px; padding:3px 8px; font-size:.75rem; font-family:monospace; cursor:pointer; transition:background .15s;';
|
||||
chip.onmouseenter = () => { chip.style.background = '#5A4A20'; };
|
||||
chip.onmouseleave = () => { updateChipStyle(chip, input); };
|
||||
chip.onclick = () => toggleTag(chip, input, t.tag);
|
||||
// Check if already selected
|
||||
const current = input.value.split(',').map(s => s.trim().toLowerCase());
|
||||
if (current.includes(t.tag.toLowerCase())) {
|
||||
chip.style.background = '#C1440E';
|
||||
chip.style.color = '#E8D5B0';
|
||||
chip.style.borderColor = '#D95A20';
|
||||
}
|
||||
container.appendChild(chip);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTag(chip, input, tag) {
|
||||
const tags = input.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const idx = tags.findIndex(t => t.toLowerCase() === tag.toLowerCase());
|
||||
if (idx >= 0) {
|
||||
tags.splice(idx, 1);
|
||||
chip.style.background = '#3A2E10';
|
||||
chip.style.color = '#C8A840';
|
||||
chip.style.borderColor = '#8B6914';
|
||||
} else {
|
||||
tags.push(tag);
|
||||
chip.style.background = '#C1440E';
|
||||
chip.style.color = '#E8D5B0';
|
||||
chip.style.borderColor = '#D95A20';
|
||||
}
|
||||
input.value = tags.join(', ');
|
||||
}
|
||||
|
||||
function updateChipStyle(chip, input) {
|
||||
const tag = chip.textContent.replace(/\s*\(\d+\)$/, '');
|
||||
const current = input.value.split(',').map(s => s.trim().toLowerCase());
|
||||
if (current.includes(tag.toLowerCase())) {
|
||||
chip.style.background = '#C1440E';
|
||||
chip.style.color = '#E8D5B0';
|
||||
chip.style.borderColor = '#D95A20';
|
||||
} else {
|
||||
chip.style.background = '#3A2E10';
|
||||
chip.style.color = '#C8A840';
|
||||
chip.style.borderColor = '#8B6914';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResources() {
|
||||
const tbody = document.getElementById('resources-body');
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr>';
|
||||
try {
|
||||
const res = await fetch('/api/resources', { headers: { 'Authorization': 'Bearer ' + accessToken } });
|
||||
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="7" class="error">Fehler beim Laden</td></tr>'; return; }
|
||||
const resources = await res.json();
|
||||
resourcesLoaded = true;
|
||||
if (!resources.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6B5B3E">Keine Ressourcen vorhanden</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = '';
|
||||
resources.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
const tdTitle = document.createElement('td'); tdTitle.textContent = r.title;
|
||||
const tdFormat = document.createElement('td'); tdFormat.textContent = (r.fileFormat || '').toUpperCase();
|
||||
const tdSize = document.createElement('td'); tdSize.textContent = formatBytes(r.fileSize || 0);
|
||||
const tdTags = document.createElement('td'); tdTags.textContent = (r.tags || []).join(', ');
|
||||
const tdAuthor = document.createElement('td'); tdAuthor.textContent = r.author || '–';
|
||||
const tdDate = document.createElement('td');
|
||||
tdDate.textContent = r.createdAt ? new Date(r.createdAt).toLocaleDateString('de-DE') : '–';
|
||||
const tdAct = document.createElement('td'); tdAct.className = 'actions';
|
||||
const btnDl = document.createElement('button'); btnDl.className = 'secondary'; btnDl.textContent = 'Download';
|
||||
btnDl.onclick = () => { window.open('/api/resources/' + r.guid + '/download?token=' + accessToken, '_blank'); };
|
||||
const btnDel = document.createElement('button'); btnDel.className = 'danger'; btnDel.textContent = 'Löschen';
|
||||
btnDel.onclick = () => deleteResource(r.guid, r.title);
|
||||
tdAct.append(btnDl, btnDel);
|
||||
tr.append(tdTitle, tdFormat, tdSize, tdTags, tdAuthor, tdDate, tdAct);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="error">Verbindungsfehler</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadResource() {
|
||||
const err = document.getElementById('resource-error');
|
||||
err.style.display = 'none';
|
||||
const title = document.getElementById('res-title').value.trim();
|
||||
const fileInput = document.getElementById('res-file');
|
||||
if (!title) { err.textContent = 'Titel ist erforderlich'; err.style.display = 'block'; return; }
|
||||
if (!fileInput.files.length) { err.textContent = 'Datei ist erforderlich'; err.style.display = 'block'; return; }
|
||||
|
||||
const file = fileInput.files[0];
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
const mimeMap = { pdf: 'application/pdf', epub: 'application/epub+zip', zip: 'application/zip', '7z': 'application/x-7z-compressed' };
|
||||
|
||||
const metadata = {
|
||||
guid: '',
|
||||
title: title,
|
||||
description: document.getElementById('res-description').value.trim(),
|
||||
tags: document.getElementById('res-tags').value.split(',').map(t => t.trim()).filter(Boolean),
|
||||
fileFormat: ext,
|
||||
mimeType: mimeMap[ext] || 'application/octet-stream',
|
||||
fileSize: 0,
|
||||
releaseDate: document.getElementById('res-releaseDate').value || null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
author: document.getElementById('res-author').value.trim() || null,
|
||||
language: document.getElementById('res-language').value.trim() || null,
|
||||
edition: document.getElementById('res-edition').value.trim() || null,
|
||||
downloadUrl: ''
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('metadata', JSON.stringify(metadata));
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/resources', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + accessToken },
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
err.textContent = body.message || 'Upload fehlgeschlagen (HTTP ' + res.status + ')';
|
||||
err.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
// Reset form
|
||||
document.getElementById('res-title').value = '';
|
||||
document.getElementById('res-description').value = '';
|
||||
document.getElementById('res-author').value = '';
|
||||
document.getElementById('res-language').value = '';
|
||||
document.getElementById('res-edition').value = '';
|
||||
document.getElementById('res-releaseDate').value = '';
|
||||
document.getElementById('res-tags').value = '';
|
||||
fileInput.value = '';
|
||||
toggleResourceForm();
|
||||
loadResources();
|
||||
} catch (e) {
|
||||
err.textContent = 'Verbindungsfehler';
|
||||
err.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteResource(guid, title) {
|
||||
if (!confirm('Ressource "' + title + '" wirklich löschen?')) return;
|
||||
await fetch('/api/admin/resources/' + guid, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + accessToken }
|
||||
});
|
||||
loadResources();
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
|
|
|||
Loading…
Reference in a new issue